18 Commits

Author SHA1 Message Date
FrigaT
3c4eda7f57 Доработан js2
All checks were successful
CI / build-test (push) Successful in 40s
Release / pack-and-publish (release) Successful in 47s
2025-12-28 23:26:51 +03:00
FrigaT
119d94b0e8 изменен стиль v2 html 2025-12-28 23:25:51 +03:00
FrigaT
bf6c0b9229 Доработан js 2025-12-28 20:10:37 +03:00
FrigaT
0470309978 Добавлена настройка генерации деталировки
All checks were successful
CI / build-test (push) Successful in 50s
Release / pack-and-publish (release) Successful in 42s
2025-12-28 16:47:35 +03:00
FrigaT
cc7809871e Добавлено формирование детальной ошибки 2025-12-28 16:33:32 +03:00
FrigaT
e4acae11f0 добавлена оптимизированная версия нового дизайна 2025-12-28 12:56:59 +03:00
FrigaT
f988d9af1e Добавлена страница Summary
All checks were successful
CI / build-test (push) Successful in 31s
Release / pack-and-publish (release) Successful in 30s
2025-12-27 03:05:01 +03:00
FrigaT
66f1166166 Изменено поведение табов 2025-12-27 01:49:59 +03:00
FrigaT
f103bc4ec4 fix IReportFormatter
All checks were successful
CI / build-test (push) Successful in 37s
Release / pack-and-publish (release) Successful in 37s
2025-12-26 23:07:13 +03:00
FrigaT
0267d52d28 Добавлена минификация для старого формата html и поправлены стили
All checks were successful
CI / build-test (push) Successful in 38s
Release / pack-and-publish (release) Successful in 39s
2025-12-26 22:56:12 +03:00
FrigaT
e3dfc12abe Добавлен старый формат html и новый с mermaid
All checks were successful
CI / build-test (push) Successful in 39s
2025-12-26 22:51:22 +03:00
FrigaT
52ac8f509e Доработан js и минимизация json 2025-12-26 22:45:08 +03:00
FrigaT
4a0e9d7d6b Изменено формирование ошибок на темплейты 2025-12-26 21:52:27 +03:00
FrigaT
3c2ee7f9a7 Добавлена минификация html 2025-12-26 21:18:14 +03:00
FrigaT
c71e15c37f Доработан билдер BPMN 2025-12-26 19:52:44 +03:00
FrigaT
0711d06884 fluent ui + mermaid 2025-12-26 02:16:51 +03:00
FrigaT
0dae811dd0 Добавлены mermaid диаграммы 2025-12-25 12:59:20 +03:00
9abf8daf90 Исправлена проверка с глубиной подзапросов
All checks were successful
CI / build-test (push) Successful in 38s
Release / pack-and-publish (release) Successful in 40s
2025-12-08 17:35:33 +03:00
52 changed files with 11595 additions and 102 deletions

View File

@@ -1,5 +1,8 @@
using SQLLinter.Infrastructure.Configuration;
using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Parser;
using SQLLinter.Infrastructure.Reporters;
using F = SQLLinter.Infrastructure.Reporters.Formatters.Html;
namespace SQLLinter.CLI
{
@@ -7,7 +10,7 @@ namespace SQLLinter.CLI
{
static void Main(string[] args)
{
var rep = new MarkdownFileReporter();
var rep = new Reporter();
var con = new Config()
{
CompatibilityLevel = 170,
@@ -40,19 +43,50 @@ namespace SQLLinter.CLI
["UpdateWhere"] = Common.RuleViolationSeverity.Critical,
["UpperLower"] = Common.RuleViolationSeverity.Critical,
["SetVariable"] = Common.RuleViolationSeverity.Critical,
}
},
GenerateDetails = true,
};
var linter = new Linter(con, rep);
//var linter = new Linter(con, rep);
var fragmentBuilder = new FragmentBuilder(rep, con.CompatibilityLevel);
var sqlStreamReaderBuilder = new SqlStreamReaderBuilder();
var bpmn = new BpmnDiagram();
using (StreamReader reader = new StreamReader(@"C:\Users\frost\Desktop\DISTR-2599\test.sql"))
var linter = new Linter(con, rep, fragmentBuilder, sqlStreamReaderBuilder);
var diagramer = new Diagramer(bpmn, fragmentBuilder, sqlStreamReaderBuilder);
using (StreamReader reader = new StreamReader(@"C:\Users\frost\Downloads\Telegram Desktop\test.sql"))
{
linter.Run("test.sql", reader.BaseStream);
var name = "test-qwewq-asdcxczc-asdsa -s--sadsasd-dsads-dsa-d-sd--dsa - 0.sql";
Dictionary<string, Stream> files = new();
for (int i = 0; i < 15; i++)
{
files[name + i + ".sql"] = reader.BaseStream;
}
linter.Run(files);
diagramer.Run("test.sql", reader.BaseStream);
}
//linter.Run(@"C:\Users\frost\Desktop\DISTR-2599\test.sql");
rep.SaveReport(@"C:\Users\frost\Desktop\DISTR-2599\test.md");
IReportFormatter formatter = new F.v3.HtmlReportFormatter();
var content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test3.html", content);
formatter = new F.v2.HtmlReportFormatter();
content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test2.html", content);
formatter = new F.v1.HtmlReportFormatter();
content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test1.html", content);
}
}
}

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"SQLLinter.CLI": {
"commandName": "Project",
"hotReloadEnabled": false
}
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using System.Text;
namespace SQLLinter.Common;
@@ -6,6 +7,15 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
{
protected readonly List<Violation> _violations = new();
protected Dictionary<TSqlFragment, TSqlFragment?>? _parents = null;
public void SetParents(Dictionary<TSqlFragment, TSqlFragment?>? parents)
=> _parents = parents;
protected TSqlFragment? GetParent(TSqlFragment node)
=> _parents is null ? null : _parents.TryGetValue(node, out var parent) ? parent : null;
public int DynamicSqlStartColumn { get; set; }
public int DynamicSqlStartLine { get; set; }
public virtual string Name { get => GetDefaultRuleName(GetType().Name); }
@@ -55,18 +65,326 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
_violations.Add(violation);
}
protected void AddViolation(string RuleName, string Message, int Line, int Column)
protected void AddViolation(string RuleName, string Template, int Line, int Column, params string[] param)
{
_violations.Add(new(RuleName, Message, Line, Column));
_violations.Add(new(RuleName, Template, Line, Column, null, param));
}
protected void AddViolation(TSqlFragment node, params string[] param)
{
AddViolation(Name, this.GetText(param), GetLineNumber(node), GetColumnNumber(node));
var context = FindContextBlock(node);
var block = ExtractBlock(context, node);
_violations.Add(new(this.Name, this.Text, GetLineNumber(node), GetColumnNumber(node), block, param));
}
protected string GetText(params string[] param)
{
return string.Format(this.Text, param);
}
}
private TSqlFragment? FindContextBlock(TSqlFragment node)
{
var current = node;
while (current != null)
{
switch (current)
{
// SELECT-блоки
case QuerySpecification:
case QueryDerivedTable:
case ScalarSubquery:
case QualifiedJoin:
case UnqualifiedJoin:
return current;
// Таблицы и функции в FROM
case NamedTableReference:
case SchemaObjectFunctionTableReference:
return current;
// DML
case InsertStatement:
case UpdateStatement:
case DeleteStatement:
case MergeStatement:
return current;
// ---- Новые блоки для SqlDataTypeReference ----
// Определение столбца
case ColumnDefinition:
return current;
// DECLARE @x INT
case DeclareVariableStatement:
return current;
// Параметры процедур/функций
case ProcedureParameter:
return current;
// CAST(x AS INT)
case CastCall:
case ConvertCall:
return current;
}
current = GetParent(current);
}
return current;
}
// Вспомогательные классы
public class ExtractedBlock
{
public string Text { get; set; } = "";
public int StartLine { get; set; }
public List<(int LineNumber, string Text)> Lines { get; set; } = new();
public int ErrorStartLine { get; set; }
public int ErrorStartColumn { get; set; }
public int ErrorEndLine { get; set; }
public int ErrorEndColumn { get; set; }
public int MinIndent { get; set; }
}
private class LineInfo
{
public int OriginalLineNumber { get; set; }
public string Text { get; set; } = "";
public int OriginalIndent { get; set; }
public int StartOffset { get; set; }
public int EndOffset { get; set; }
}
protected ExtractedBlock? ExtractBlock(TSqlFragment? node, TSqlFragment errorNode)
{
if (node == null || node.ScriptTokenStream == null)
return null;
var endLine = node.ScriptTokenStream.Where(t => t.Offset < node.StartOffset + node.FragmentLength).Max(t => t.Line) + 2;
// 1. Получаем токены для блока
var tokens = node.ScriptTokenStream
.Where(t => t.Line >= node.StartLine - 2 &&
t.Line <= endLine)
.ToList();
if (tokens.Count == 0)
return null;
// 2. Строим строки из токенов
var lines = BuildLinesFromTokens(tokens);
// 3. Вычисляем минимальный отступ
int minIndent = CalculateMinIndent(lines);
// 4. Нормализуем строки (убираем общий отступ)
var normalizedLines = NormalizeLines(lines, minIndent);
// 5. Вычисляем позиции ошибки
var (errorStartIdx, errorStartCol, errorEndIdx, errorEndCol) =
CalculateErrorPositionsSimple(errorNode, lines, normalizedLines, minIndent);
// 6. Формируем результат
return new ExtractedBlock
{
Text = string.Join("\n", normalizedLines.Select(l => l.Text)),
StartLine = tokens.First().Line,
Lines = normalizedLines,
ErrorStartLine = errorStartIdx + 1,
ErrorStartColumn = errorStartCol,
ErrorEndLine = errorEndIdx + 1,
ErrorEndColumn = errorEndCol
};
}
/// <summary>
/// Строит список строк из токенов
/// </summary>
private List<(int LineNumber, string Text)> BuildLinesFromTokens(List<TSqlParserToken> tokens)
{
var result = new List<(int LineNumber, string Text)>();
if (tokens.Count == 0)
return result;
var currentLine = new StringBuilder();
int currentLineNumber = tokens[0].Line;
foreach (var token in tokens)
{
// Если токен на новой строке, сохраняем предыдущую строку
if (token.Line > currentLineNumber)
{
result.Add((currentLineNumber, currentLine.ToString()));
currentLine.Clear();
currentLineNumber = token.Line;
// Добавляем пробелы для выравнивания
if (token.Column > 1)
{
currentLine.Append(' ', token.Column - 1);
}
}
// Добавляем текст токена
currentLine.Append(token.Text);
}
// Добавляем последнюю строку
result.Add((currentLineNumber, currentLine.ToString()));
return result;
}
/// <summary>
/// Вычисляет минимальный отступ среди непустых строк
/// </summary>
private int CalculateMinIndent(List<(int LineNumber, string Text)> lines)
{
int minIndent = int.MaxValue;
foreach (var (lineNumber, text) in lines)
{
if (string.IsNullOrWhiteSpace(text))
continue;
int indent = 0;
while (indent < text.Length && char.IsWhiteSpace(text[indent]))
indent++;
if (indent < minIndent)
minIndent = indent;
}
return minIndent == int.MaxValue ? 0 : minIndent;
}
/// <summary>
/// Нормализует строки, убирая минимальный отступ
/// </summary>
private List<(int LineNumber, string Text)> NormalizeLines(
List<(int LineNumber, string Text)> lines, int minIndent)
{
var result = new List<(int LineNumber, string Text)>();
foreach (var (lineNumber, text) in lines)
{
string normalizedText;
if (string.IsNullOrWhiteSpace(text))
{
normalizedText = text;
}
else if (text.Length >= minIndent)
{
normalizedText = text.Substring(minIndent);
}
else
{
normalizedText = text;
}
result.Add((lineNumber, normalizedText));
}
return result;
}
/// <summary>
/// Вычисляет позиции ошибки (упрощенный вариант)
/// </summary>
private (int StartIdx, int StartCol, int EndIdx, int EndCol) CalculateErrorPositionsSimple(
TSqlFragment errorNode,
List<(int LineNumber, string Text)> originalLines,
List<(int LineNumber, string Text)> normalizedLines,
int minIndent)
{
// Находим строки ошибки
int errorStartLine = errorNode.StartLine;
int errorEndLine = GetErrorEndLine(errorNode);
// Находим индексы в нормализованных строках
int startIdx = -1;
int endIdx = -1;
for (int i = 0; i < normalizedLines.Count; i++)
{
if (normalizedLines[i].LineNumber == errorStartLine)
startIdx = i;
if (normalizedLines[i].LineNumber == errorEndLine)
endIdx = i;
}
if (startIdx == -1) startIdx = 0;
if (endIdx == -1) endIdx = normalizedLines.Count - 1;
// Вычисляем столбцы
int startCol = CalculateAdjustedColumn(errorNode.StartColumn, minIndent);
int endCol;
if (errorStartLine == errorEndLine)
{
endCol = CalculateAdjustedColumn(errorNode.StartColumn + errorNode.FragmentLength, minIndent);
}
else
{
var errorTokens = GetErrorTokens(errorNode);
if (errorTokens.Any())
{
var lastToken = errorTokens.Last();
endCol = CalculateAdjustedColumn(lastToken.Column + lastToken.Text.Length, minIndent);
}
else
{
endCol = startCol;
}
}
// Ограничиваем столбцы
startCol = Math.Max(startCol, 1);
endCol = Math.Max(endCol, 1);
return (startIdx, startCol, endIdx, endCol);
}
/// <summary>
/// Корректирует столбец после удаления отступов
/// </summary>
private int CalculateAdjustedColumn(int originalColumn, int minIndent)
{
return Math.Max(originalColumn - minIndent, 1);
}
/// <summary>
/// Получает последнюю строку ошибки
/// </summary>
private int GetErrorEndLine(TSqlFragment errorNode)
{
var errorTokens = errorNode.ScriptTokenStream?
.Where(t => t.Offset >= errorNode.StartOffset &&
t.Offset < errorNode.StartOffset + errorNode.FragmentLength)
.ToList();
return errorTokens?.LastOrDefault()?.Line ?? errorNode.StartLine;
}
/// <summary>
/// Получает токены ошибки
/// </summary>
private List<TSqlParserToken> GetErrorTokens(TSqlFragment errorNode)
{
return errorNode.ScriptTokenStream?
.Where(t => t.Offset >= errorNode.StartOffset &&
t.Offset < errorNode.StartOffset + errorNode.FragmentLength)
.ToList() ?? new List<TSqlParserToken>();
}
}

View File

@@ -4,5 +4,5 @@ public interface IReporter : IBaseReporter
{
void ReportViolation(IRuleViolation violation);
void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string violationText);
void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, BaseRuleVisitor.ExtractedBlock? Snippet, params string[] param);
}

View File

@@ -13,5 +13,5 @@ public interface IRule
int DynamicSqlStartLine { get; set; }
IEnumerable<Violation> Analyze(TSqlFragment fragment);
void SetParents(Dictionary<TSqlFragment, TSqlFragment?>? parents);
}

View File

@@ -13,4 +13,5 @@ public interface IRuleViolation
RuleViolationSeverity Severity { get; }
string Text { get; }
BaseRuleVisitor.ExtractedBlock? Snippet { get; }
}

View File

@@ -1,4 +1,4 @@
namespace SQLLinter.Common
{
public record Violation(string RuleName, string Message, int Line, int Column);
public record Violation(string RuleName, string Template, int Line, int Column, BaseRuleVisitor.ExtractedBlock? Snippet, string[] Params);
}

View File

@@ -20,5 +20,10 @@ namespace SQLLinter.Core.Interfaces
/// Список сторонних плагинов.
/// </summary>
List<string> Plugins { get; set; }
/// <summary>
/// Генерировать деталировку ошибки.
/// </summary>
bool GenerateDetails { get; set; }
}
}

View File

@@ -4,5 +4,5 @@ namespace SQLLinter.Core.Interfaces;
public interface IRuleVisitor
{
void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream);
void VisitRules(string path, IEnumerable<IRuleException> igoredRules, Stream sqlFileStream, bool generateDetails);
}

View File

@@ -0,0 +1,9 @@
namespace SQLLinter.Core.Interfaces;
public interface ISqlDiagramProcessor
{
void ProcessList(List<string> filePaths);
void ProcessList(Dictionary<string, Stream> files);
void ProcessPath(string path);
}

41
SQLLinter/Diagramer.cs Normal file
View File

@@ -0,0 +1,41 @@
using SQLLinter.Common.Helpers;
using SQLLinter.Core.Interfaces;
using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Interfaces;
namespace SQLLinter;
public class Diagramer
{
private ISqlDiagramProcessor _diagramProcessor;
public Diagramer(BpmnDiagram bpmnDiagram
, IFragmentBuilder fragmentBuilder
, ISqlStreamReaderBuilder sqlStreamReaderBuilder
)
{
_diagramProcessor = new SqlDiagramProcessor(fragmentBuilder, bpmnDiagram, sqlStreamReaderBuilder);
}
public void Run(string filePath)
{
this.Run([filePath]);
}
public void Run(List<string> filePaths)
{
List<string> files = FileHelpers.FindFilesWithMask(filePaths);
_diagramProcessor.ProcessList(filePaths);
}
public void Run(string fileName, Stream fileReader)
{
Run(new Dictionary<string, Stream> { [fileName] = fileReader });
}
public void Run(Dictionary<string, Stream> files)
{
_diagramProcessor.ProcessList(files);
}
}

View File

@@ -8,5 +8,6 @@ namespace SQLLinter.Infrastructure.Configuration
public int CompatibilityLevel { get; set; }
public Dictionary<string, RuleViolationSeverity> Rules { get; set; } = new();
public List<string> Plugins { get; set; } = new();
public bool GenerateDetails { get; set; } = false;
}
}

View File

@@ -0,0 +1,979 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
using SQLLinter.Core;
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Билдер диаграммы BPMN из T-SQL AST
/// </summary>
public static class BpmnBuilder
{
/// <summary>
/// Сборка диаграммы BPMN из T-SQL AST
/// </summary>
/// <param name="fragment"></param>
/// <returns></returns>
public static BpmnDiagram Build(TSqlFragment fragment) => Build(fragment, new());
/// <summary>
/// Сборка диаграммы BPMN из T-SQL AST с использованием существующей диаграммы
/// </summary>
/// <param name="fragment"></param>
/// <param name="diagram"></param>
/// <returns></returns>
public static BpmnDiagram Build(TSqlFragment fragment, BpmnDiagram diagram)
{
var visitor = new BpmnVisitor(diagram);
fragment.Accept(visitor);
return visitor.Diagram;
}
private class BpmnVisitor : TSqlFragmentVisitor
{
public BpmnDiagram Diagram { get; } = new BpmnDiagram();
private Dictionary<string, string> _lastNodeByProcess = new();
private Stack<BpmnProcess> _processStack = new Stack<BpmnProcess>();
private int _nodeCounter = 0;
private const int MaxConditionLength = 50;
public BpmnVisitor() : this(new BpmnDiagram()) { }
public BpmnVisitor(BpmnDiagram diagram)
{
Diagram = diagram;
_nodeCounter = diagram.Processes.Sum(p => p.Nodes.Count()) + 1;
}
private string NewId() => "n" + (_nodeCounter++);
private void AddNode(BpmnProcess proc, BpmnNode node, string? edgeLabel = null)
{
if (proc == null) return;
if (string.IsNullOrEmpty(node.Id)) node.Id = NewId();
proc.Nodes.Add(node);
if (_lastNodeByProcess.TryGetValue(proc.Id, out var last) && !string.IsNullOrEmpty(last))
{
proc.Edges.Add(new BpmnEdge
{
From = last,
To = node.Id,
Label = edgeLabel ?? string.Empty
});
}
_lastNodeByProcess[proc.Id] = node.Id;
}
private void AddNodeToCurrent(string label, BpmnNodeType type, Dictionary<string, string>? properties = null)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var node = new BpmnNode
{
Id = NewId(),
Label = label,
Type = type
};
if (properties != null)
{
node.Properties = new Dictionary<string, string>(properties);
}
AddNode(currentProcess, node);
}
private void ProcessProcedureOrFunction(TSqlStatement node, string objectType)
{
// Извлечение имени объекта на основе AST
var name = FindSchemaObjectName(node) ?? ($"{objectType}" + Diagram.Processes.Count);
var pid = SanitizeId(name);
var proc = new BpmnProcess { Id = pid, Name = $"{name} ({objectType})" };
// создать начальный узел
var start = new BpmnNode { Id = pid + "_start", Label = "Начало", Type = BpmnNodeType.Start };
proc.Nodes.Add(start);
_lastNodeByProcess[proc.Id] = start.Id;
Diagram.Processes.Add(proc);
// Помещаем процесс в стек
_processStack.Push(proc);
// Используем специальный visitor для поиска StatementList
var statementCollector = new StatementListCollector();
node.Accept(statementCollector);
if (statementCollector.FoundStatementList != null)
{
statementCollector.FoundStatementList.Accept(this);
}
// после посещения тела добавляем конечный узел
var end = new BpmnNode { Id = pid + "_end", Label = "Конец", Type = BpmnNodeType.End };
AddNode(proc, end);
// Убираем процесс из стека
_processStack.Pop();
}
private class StatementListCollector : TSqlFragmentVisitor
{
public StatementList? FoundStatementList { get; private set; }
public override void Visit(StatementList node)
{
if (FoundStatementList == null)
{
FoundStatementList = node;
}
}
}
public override void ExplicitVisit(CreateProcedureStatement node) => ProcessProcedureOrFunction(node, "процедура");
public override void ExplicitVisit(CreateOrAlterProcedureStatement node) => ProcessProcedureOrFunction(node, "процедура");
public override void ExplicitVisit(AlterProcedureStatement node) => ProcessProcedureOrFunction(node, "процедура");
public override void ExplicitVisit(CreateFunctionStatement node) => ProcessProcedureOrFunction(node, "функция");
public override void ExplicitVisit(CreateOrAlterFunctionStatement node) => ProcessProcedureOrFunction(node, "функция");
public override void ExplicitVisit(AlterFunctionStatement node) => ProcessProcedureOrFunction(node, "функция");
public override void ExplicitVisit(CreateTriggerStatement node) => ProcessProcedureOrFunction(node, "триггер");
public override void ExplicitVisit(CreateOrAlterTriggerStatement node) => ProcessProcedureOrFunction(node, "триггер");
public override void ExplicitVisit(AlterTriggerStatement node) => ProcessProcedureOrFunction(node, "триггер");
public override void Visit(ExecuteStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
// Извлечение на основе AST для вызываемого процесса
var called = FindProcedureNameFromExecute(node);
var label = called != null ? $"EXEC {called}" : "EXEC";
// задача в текущем процессе
var task = new BpmnNode { Id = NewId(), Label = label!, Type = BpmnNodeType.Subprocess, SubprocessId = called is null ? string.Empty : SanitizeId(called), };
AddNode(currentProcess, task);
// Обходим параметры вызова
if (node.ExecuteSpecification != null)
{
node.ExecuteSpecification.Accept(this);
}
}
public override void Visit(FunctionCall node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
// Извлекаем имя функции
var functionName = node.FunctionName?.Value ?? "unknown";
var schemaPrefix = "";
// Проверяем, есть ли схема в имени через MultiPartIdentifier
if (node.CallTarget is MultiPartIdentifierCallTarget multiPartCallTarget)
{
schemaPrefix = GetMultiPartIdentifierName(multiPartCallTarget.MultiPartIdentifier);
}
var fullFunctionName = !string.IsNullOrEmpty(schemaPrefix)
? $"{schemaPrefix}.{functionName}"
: functionName;
// Проверяем, является ли это пользовательской функцией
var isUserFunction = IsUserDefinedFunction(fullFunctionName);
var label = isUserFunction ? $"FUNC {fullFunctionName}" : $"{functionName}()";
var nodeType = isUserFunction ? BpmnNodeType.Subprocess : BpmnNodeType.Task;
var funcNode = new BpmnNode
{
Id = NewId(),
Label = label,
Type = nodeType,
SubprocessId = isUserFunction ? SanitizeId(fullFunctionName) : string.Empty,
};
AddNode(currentProcess, funcNode);
}
private bool IsUserDefinedFunction(string functionName)
{
// Проверяем, есть ли такая функция в нашей диаграмме
var isInDiagram = Diagram.Processes.Any(p =>
p.Name.Contains(functionName, StringComparison.OrdinalIgnoreCase));
// Если функция есть в диаграмме, это пользовательская функция
if (isInDiagram) return true;
// Если функция не встроенная и имеет префикс схемы (например, dbo.), считаем ее пользовательской
if (functionName.Contains('.') && !Constants.SystemFunctions.Contains(functionName.Split('.')[1]))
{
return true;
}
return !Constants.SystemFunctions.Contains(functionName);
}
public override void Visit(IfStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
// Извлекаем условие IF
var conditionText = GetConditionText(node.Predicate);
var truncatedCondition = TruncateText(conditionText, MaxConditionLength);
var properties = new Dictionary<string, string>
{
["condition"] = conditionText,
["condition_display"] = truncatedCondition
};
// Создаем узел шлюза для IF с условием
var gid = new BpmnNode
{
Id = NewId(),
Label = $"IF ({truncatedCondition})",
Type = BpmnNodeType.Gateway,
Properties = properties
};
AddNode(currentProcess, gid);
// Создаем узел ENDIF для объединения веток
var endid = new BpmnNode { Id = NewId(), Label = "ENDIF", Type = BpmnNodeType.Gateway };
currentProcess.Nodes.Add(endid);
// Обработка THEN ветки
if (node.ThenStatement != null)
{
_lastNodeByProcess[currentProcess.Id] = gid.Id;
var thenNode = new BpmnNode { Id = NewId(), Label = "THEN", Type = BpmnNodeType.Task };
AddNode(currentProcess, thenNode, edgeLabel: $"Да");
// Ручной обход THEN ветки
TraverseStatement(node.ThenStatement, currentProcess);
// После обработки THEN ветки добавляем край к ENDIF
if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var thenLastNode))
{
currentProcess.Edges.Add(new BpmnEdge
{
From = thenLastNode,
To = endid.Id
});
}
}
// Обработка ELSE ветки
if (node.ElseStatement != null)
{
_lastNodeByProcess[currentProcess.Id] = gid.Id;
var elseNode = new BpmnNode { Id = NewId(), Label = "ELSE", Type = BpmnNodeType.Task };
AddNode(currentProcess, elseNode, edgeLabel: $"Нет");
// Ручной обход ELSE ветки
TraverseStatement(node.ElseStatement, currentProcess);
// После обработки ELSE ветки добавляем край к ENDIF
if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var elseLastNode))
{
currentProcess.Edges.Add(new BpmnEdge
{
From = elseLastNode,
To = endid.Id
});
}
}
// Восстанавливаем последний узел после обработки IF
_lastNodeByProcess[currentProcess.Id] = endid.Id;
}
public override void Visit(MergeStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var target = node.MergeSpecification.Target != null ? FindTargetNameForDml(node.MergeSpecification.Target) : "?";
var label = $"MERGE -> {target}";
AddNodeToCurrent(label, BpmnNodeType.Task);
}
public override void Visit(SetVariableStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
// Получаем имя переменной
var variableName = node.Variable?.Name ?? "?";
// Получаем текст выражения
var expressionText = GetExpressionText(node.Expression);
var truncatedExpression = TruncateText(expressionText, MaxConditionLength);
// Определяем оператор присваивания на основе AssignmentKind
string assignmentOp = "=";
assignmentOp = node.AssignmentKind switch
{
AssignmentKind.Equals => "=",
AssignmentKind.AddEquals => "+=",
AssignmentKind.SubtractEquals => "-=",
AssignmentKind.MultiplyEquals => "*=",
AssignmentKind.DivideEquals => "/=",
AssignmentKind.ModEquals => "%=",
AssignmentKind.BitwiseAndEquals => "&=",
AssignmentKind.BitwiseOrEquals => "|=",
AssignmentKind.BitwiseXorEquals => "^=",
_ => "="
};
// Проверяем, содержит ли выражение вызов функции
var containsFunctionCall = false;
if (node.Expression != null)
{
var functionFinder = new FunctionCallFinder();
node.Expression.Accept(functionFinder);
containsFunctionCall = functionFinder.FunctionCalls.Any(fc => IsUserDefinedFunction(fc.FunctionName?.Value ?? ""));
}
var label = $"SET {variableName} {assignmentOp} {truncatedExpression}";
var properties = new Dictionary<string, string>
{
["variable"] = variableName,
["expression"] = expressionText,
["expression_display"] = truncatedExpression,
["assignment_kind"] = node.AssignmentKind.ToString(),
["contains_function_call"] = containsFunctionCall.ToString()
};
AddNodeToCurrent(label, BpmnNodeType.Task, properties);
// Обходим выражение для поиска вызовов функций
if (node.Expression != null)
{
node.Expression.Accept(this);
}
}
public override void Visit(SetCommandStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var commandText = GetCommandText(node);
var truncatedCommand = TruncateText(commandText, MaxConditionLength);
var label = $"SET {truncatedCommand}";
var properties = new Dictionary<string, string>
{
["command"] = commandText,
["command_display"] = truncatedCommand
};
AddNodeToCurrent(label, BpmnNodeType.Task, properties);
}
public override void Visit(GoToStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var label = node.LabelName?.Value ?? "?";
var gotoNode = new BpmnNode
{
Id = NewId(),
Label = $"GOTO {label}",
Type = BpmnNodeType.Task
};
AddNode(currentProcess, gotoNode);
}
public override void Visit(TryCatchStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var tryNode = new BpmnNode { Id = NewId(), Label = "TRY", Type = BpmnNodeType.Gateway };
var catchNode = new BpmnNode { Id = NewId(), Label = "CATCH", Type = BpmnNodeType.Gateway };
var endTryCatch = new BpmnNode { Id = NewId(), Label = "END TRY/CATCH", Type = BpmnNodeType.Gateway };
AddNode(currentProcess, tryNode);
currentProcess.Nodes.Add(catchNode);
currentProcess.Nodes.Add(endTryCatch);
// TRY блок
if (node.TryStatements != null)
{
_lastNodeByProcess[currentProcess.Id] = tryNode.Id;
foreach (var statement in node.TryStatements.Statements)
{
statement.Accept(this);
}
// Ребро от последнего узла TRY к endTryCatch
if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var tryLast))
{
currentProcess.Edges.Add(new BpmnEdge
{
From = tryLast,
To = endTryCatch.Id
});
}
}
// CATCH блок
if (node.CatchStatements != null)
{
_lastNodeByProcess[currentProcess.Id] = catchNode.Id;
currentProcess.Edges.Add(new BpmnEdge
{
From = tryNode.Id,
To = catchNode.Id,
Label = "Ошибка"
});
foreach (var statement in node.CatchStatements.Statements)
{
statement.Accept(this);
}
// Ребро от последнего узла CATCH к endTryCatch
if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var catchLast))
{
currentProcess.Edges.Add(new BpmnEdge
{
From = catchLast,
To = endTryCatch.Id
});
}
}
_lastNodeByProcess[currentProcess.Id] = endTryCatch.Id;
}
public override void Visit(WhileStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
// Извлекаем условие WHILE
var conditionText = GetConditionText(node.Predicate);
var truncatedCondition = TruncateText(conditionText, MaxConditionLength);
var properties = new Dictionary<string, string>
{
["condition"] = conditionText,
["condition_display"] = truncatedCondition,
["loop_type"] = "WHILE"
};
// Узел начала цикла
var loopStart = new BpmnNode
{
Id = NewId(),
Label = $"WHILE ({truncatedCondition})",
Type = BpmnNodeType.Hexagon,
Properties = properties
};
AddNode(currentProcess, loopStart);
// Сохраняем текущий последний узел
var savedLastNode = _lastNodeByProcess[currentProcess.Id];
// Обработка тела цикла
if (node.Statement != null)
{
// Добавляем ребро от gateway к первому узлу тела цикла
var bodyStartNode = new BpmnNode
{
Id = NewId(),
Label = "Тело цикла",
Type = BpmnNodeType.Task
};
AddNode(currentProcess, bodyStartNode, "Вход");
// Обход тела цикла
TraverseStatement(node.Statement, currentProcess);
// Добавляем ребро возврата в начало цикла от последнего узла тела
if (_lastNodeByProcess.TryGetValue(currentProcess.Id, out var loopBodyLast))
{
currentProcess.Edges.Add(new BpmnEdge
{
From = loopBodyLast,
To = loopStart.Id,
Label = "Повтор"
});
}
}
// Узел конца цикла (выход, когда условие ложно)
var loopEnd = new BpmnNode { Id = NewId(), Label = "END WHILE", Type = BpmnNodeType.Gateway };
currentProcess.Nodes.Add(loopEnd);
// Ребро выхода из цикла
currentProcess.Edges.Add(new BpmnEdge
{
From = loopStart.Id,
To = loopEnd.Id,
Label = "Выход"
});
// Восстанавливаем последний узел
_lastNodeByProcess[currentProcess.Id] = loopEnd.Id;
}
public override void Visit(BreakStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var breakNode = new BpmnNode
{
Id = NewId(),
Label = "BREAK",
Type = BpmnNodeType.Task
};
AddNode(currentProcess, breakNode);
}
public override void Visit(ContinueStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var continueNode = new BpmnNode
{
Id = NewId(),
Label = "CONTINUE",
Type = BpmnNodeType.Task
};
AddNode(currentProcess, continueNode);
}
public override void Visit(ReturnStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var label = "RETURN";
if (node.Expression != null)
{
var exprText = GetExpressionText(node.Expression);
var truncatedExpr = TruncateText(exprText, MaxConditionLength);
label = $"RETURN {truncatedExpr}";
}
var returnNode = new BpmnNode
{
Id = NewId(),
Label = label,
Type = BpmnNodeType.Task
};
AddNode(currentProcess, returnNode);
}
public override void Visit(DeclareVariableStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var declarations = new List<string>();
foreach (var declaration in node.Declarations)
{
var varName = declaration.VariableName?.Value ?? "?";
var dataType = declaration.DataType?.Name?.BaseIdentifier?.Value ?? "?";
declarations.Add($"{varName} {dataType}");
}
var label = "DECLARE";
if (declarations.Count > 0)
{
var declText = string.Join(", ", declarations);
var truncatedDecl = TruncateText(declText, MaxConditionLength);
label = $"DECLARE {truncatedDecl}";
}
AddNodeToCurrent(label, BpmnNodeType.Task);
}
public override void Visit(BeginEndBlockStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
// Просто обходим все операторы внутри блока
if (node.StatementList != null)
{
foreach (var statement in node.StatementList.Statements)
{
statement.Accept(this);
}
}
}
private void TraverseStatement(TSqlFragment statement, BpmnProcess process)
{
if (statement is StatementList statementList)
{
foreach (var stmt in statementList.Statements)
{
stmt.Accept(this);
}
}
else if (statement is BeginEndBlockStatement beginEnd)
{
// Для блоков BEGIN...END обходим внутренние statements
if (beginEnd.StatementList != null)
{
foreach (var stmt in beginEnd.StatementList.Statements)
{
stmt.Accept(this);
}
}
}
else
{
statement.Accept(this);
}
}
public override void Visit(SelectStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var label = "SELECT";
if (node.QueryExpression is QuerySpecification qs && qs.SelectElements != null)
{
label = $"SELECT ({qs.SelectElements.Count} полей)";
}
AddNodeToCurrent(label, BpmnNodeType.Task);
// Обходим все элементы SELECT для поиска вызовов функций
if (node.QueryExpression != null)
{
node.QueryExpression.Accept(this);
}
}
public override void Visit(InsertStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var target = FindTargetNameForDml(node);
var label = "INSERT" + (string.IsNullOrEmpty(target) ? string.Empty : $" -> {target}");
AddNodeToCurrent(label, BpmnNodeType.Task);
}
public override void Visit(UpdateStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var target = FindTargetNameForDml(node);
var label = "UPDATE" + (string.IsNullOrEmpty(target) ? string.Empty : $" -> {target}");
AddNodeToCurrent(label, BpmnNodeType.Task);
}
public override void Visit(DeleteStatement node)
{
if (_processStack.Count == 0) return;
var currentProcess = _processStack.Peek();
var target = FindTargetNameForDml(node);
var label = "DELETE" + (string.IsNullOrEmpty(target) ? string.Empty : $" -> {target}");
AddNodeToCurrent(label, BpmnNodeType.Task);
}
// Вспомогательные методы для извлечения текста
private string GetConditionText(BooleanExpression expression)
{
if (expression == null) return "";
try
{
// Пробуем получить через ScriptTokenStream
var tokens = expression.ScriptTokenStream;
if (tokens != null && expression.FirstTokenIndex >= 0 &&
expression.LastTokenIndex >= expression.FirstTokenIndex)
{
var text = string.Join("",
tokens.Skip(expression.FirstTokenIndex)
.Take(expression.LastTokenIndex - expression.FirstTokenIndex + 1)
.Select(t => t.Text));
return text.Trim();
}
// Fallback: используем ToString()
return expression.ToString()?.Trim() ?? "";
}
catch
{
return "";
}
}
private string GetExpressionText(ScalarExpression expression)
{
if (expression == null) return "";
try
{
var tokens = expression.ScriptTokenStream;
if (tokens != null && expression.FirstTokenIndex >= 0 && expression.LastTokenIndex >= expression.FirstTokenIndex)
{
var text = string.Join("",
tokens.Skip(expression.FirstTokenIndex)
.Take(expression.LastTokenIndex - expression.FirstTokenIndex + 1)
.Select(t => t.Text));
return text.Trim();
}
}
catch
{
// Игнорируем ошибки парсинга
}
return expression.ToString() ?? "";
}
private string GetCommandText(SetCommandStatement node)
{
if (node == null) return "";
try
{
var tokens = node.ScriptTokenStream;
if (tokens != null && node.FirstTokenIndex >= 0 && node.LastTokenIndex >= node.FirstTokenIndex)
{
var text = string.Join("",
tokens.Skip(node.FirstTokenIndex)
.Take(node.LastTokenIndex - node.FirstTokenIndex + 1)
.Select(t => t.Text));
return text.Trim();
}
}
catch
{
// Игнорируем ошибки парсинга
}
return node.ToString() ?? "";
}
private string TruncateText(string text, int maxLength)
{
if (string.IsNullOrEmpty(text)) return "";
if (text.Length <= maxLength) return text;
return text.Substring(0, maxLength - 3) + "...";
}
// AST helpers
private static string? FindSchemaObjectName(TSqlFragment fragment)
{
var finder = new SchemaNameFinder();
fragment.Accept(finder);
return finder.FoundName;
}
private static string? FindProcedureNameFromExecute(ExecuteStatement node)
{
// Prefer ProcedureReference inside the statement
var finder = new ProcedureReferenceFinder();
node.Accept(finder);
return finder.FoundName;
}
private static string? FindTargetNameForDml(TSqlFragment fragment)
{
var finder = new TargetTableFinder();
fragment.Accept(finder);
return finder.FoundName;
}
private class SchemaNameFinder : TSqlFragmentVisitor
{
public string? FoundName { get; private set; }
public override void Visit(SchemaObjectName node)
{
if (FoundName == null)
{
FoundName = GetSchemaObjectName(node);
}
base.Visit(node);
}
public override void Visit(ProcedureReferenceName node)
{
if (FoundName == null && node.ProcedureReference?.Name != null)
{
FoundName = GetSchemaObjectName(node.ProcedureReference.Name);
}
base.Visit(node);
}
private static string GetSchemaObjectName(SchemaObjectName node)
{
try
{
if (node.Identifiers != null && node.Identifiers.Count > 0)
{
return string.Join('.', node.Identifiers.Select(id => "[" + id.Value + "]"));
}
return "[" + (node.ToString() ?? "unknown") + "]";
}
catch
{
return "[unknown]";
}
}
}
private class ProcedureReferenceFinder : TSqlFragmentVisitor
{
public string? FoundName { get; private set; }
public override void Visit(ProcedureReference node)
{
if (FoundName == null)
{
try
{
if (node.Name != null)
{
if (node.Name.Identifiers != null && node.Name.Identifiers.Count > 0)
FoundName = string.Join('.', node.Name.Identifiers.Select(id => "[" + id.Value + "]"));
else
FoundName = "[" + node.Name.ToString() + "]";
}
}
catch
{
FoundName = "[" + node.Name.ToString() + "]";
}
}
base.Visit(node);
}
public override void Visit(ExecuteSpecification node)
{
base.Visit(node);
}
}
private class TargetTableFinder : TSqlFragmentVisitor
{
public string? FoundName { get; private set; }
private int _bestDepth = int.MaxValue;
private int _depth = 0;
public override void Visit(TSqlFragment node)
{
_depth++;
base.Visit(node);
_depth--;
}
public override void Visit(NamedTableReference node)
{
if (node == null) return;
int depth = _depth;
if (depth < _bestDepth)
{
_bestDepth = depth;
try
{
if (node.SchemaObject != null && node.SchemaObject.Identifiers != null && node.SchemaObject.Identifiers.Count > 0)
FoundName = string.Join('.', node.SchemaObject.Identifiers.Select(id => "[" + id.Value + "]"));
else
FoundName = "[" + node.SchemaObject?.ToString() ?? node.ToString() + "]";
if (node.Alias != null && !string.IsNullOrWhiteSpace(node.Alias.Value))
FoundName += " AS [" + node.Alias.Value + "]";
}
catch
{
FoundName = "[" + node.SchemaObject?.ToString() ?? node.ToString() + "]";
}
}
base.Visit(node);
}
public override void Visit(VariableTableReference node)
{
if (node == null) return;
int depth = _depth;
if (depth < _bestDepth)
{
_bestDepth = depth;
FoundName = node.Variable?.ToString();
}
base.Visit(node);
}
public override void Visit(QueryDerivedTable node)
{
if (node == null) return;
int depth = _depth;
if (depth < _bestDepth && node.Alias != null && !string.IsNullOrWhiteSpace(node.Alias.Value))
{
_bestDepth = depth;
FoundName = node.Alias.Value + " (derived)";
}
// still traverse inside
base.Visit(node);
}
public override void Visit(CommonTableExpression node)
{
// CTE definitions: prefer not to pick CTE as target unless no other table found
base.Visit(node);
}
}
private static string SanitizeId(string s)
{
if (string.IsNullOrEmpty(s)) return "id";
var sb = new System.Text.StringBuilder();
foreach (var ch in s)
{
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
else sb.Append('_');
}
return sb.ToString();
}
private class FunctionCallFinder : TSqlFragmentVisitor
{
public List<FunctionCall> FunctionCalls { get; } = new List<FunctionCall>();
public override void Visit(FunctionCall node)
{
FunctionCalls.Add(node);
base.Visit(node);
}
}
private string GetMultiPartIdentifierName(MultiPartIdentifier identifier)
{
if (identifier != null && identifier.Identifiers != null && identifier.Identifiers.Count > 0)
{
return string.Join(".", identifier.Identifiers.Select(id => id.Value));
}
return "";
}
}
}

View File

@@ -0,0 +1,95 @@
using System.Text;
namespace SQLLinter.Infrastructure.Diagram;
public static class MermaidRenderer
{
public static string ToMermaidContent(BpmnDiagram diagram)
{
var sb = new StringBuilder();
sb.AppendLine("flowchart TB");
// -----------------------------------------
// Рендер каждого процесса как subgraph
// -----------------------------------------
foreach (var proc in diagram.Processes)
{
var procId = SanitizeId(proc.Id);
sb.AppendLine($" subgraph {procId} [\"{Escape(proc.Name)}\"]");
sb.AppendLine(" direction TB");
foreach (var node in proc.Nodes)
{
var nid = SanitizeId(node.Id);
var label = Escape(node.Label);
label = node.Type switch
{
BpmnNodeType.Start => $@"((""{label}""))",
BpmnNodeType.Task => $@"[""{label}""]",
BpmnNodeType.Subprocess => $@"[[""{label}""]]",
BpmnNodeType.Gateway => $@"{{""{label}""}}",
BpmnNodeType.Hexagon => $@"{{{{""{label}""}}}}",
BpmnNodeType.End => $@"((""{label}""))",
_ => $@">""{label}""]"
};
sb.AppendLine($" {nid}{label}");
}
// Edges внутри процесса
foreach (var e in proc.Edges)
{
var from = SanitizeId(e.From);
var to = SanitizeId(e.To);
var arrow = e.ArrowType switch
{
BpmnArrowType.Dashed => "-.->",
_ => "-->",
};
var lbl = string.IsNullOrWhiteSpace(e.Label) ? string.Empty : $" |{Escape(e.Label)}|";
sb.AppendLine($" {from} {arrow}{lbl} {to}");
}
sb.AppendLine(" end");
sb.AppendLine();
}
// -----------------------------------------
// Рендер глобальных связей между процессами
// -----------------------------------------
foreach (var e in diagram.GlobalEdges)
{
var from = SanitizeId(e.From);
var to = SanitizeId(e.To);
var arrow = e.ArrowType switch
{
BpmnArrowType.Dashed => "-.->",
_ => "-->",
};
var lbl = string.IsNullOrWhiteSpace(e.Label) ? string.Empty : $" |{Escape(e.Label)}|";
sb.AppendLine($" {from} {arrow}{lbl} {to}");
}
return sb.ToString();
}
private static string Escape(string s)
{
if (s == null) return string.Empty;
return s.Replace("\"", "\\\"").Replace("\n", " ").Replace("\r", " ");
}
private static string SanitizeId(string s)
{
if (string.IsNullOrEmpty(s)) return "id";
var sb = new StringBuilder();
foreach (var ch in s)
{
if (char.IsLetterOrDigit(ch)) sb.Append(ch);
else sb.Append('_');
}
return sb.ToString();
}
}

View File

@@ -0,0 +1,12 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Типы связей между узлами BPMN
/// </summary>
public enum BpmnArrowType
{
/// <summary> Обычная связь </summary>
Default,
/// <summary> Пунктирная связь (например, для вызова подпроцесса) </summary>
Dashed,
}

View File

@@ -0,0 +1,13 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Диаграмма BPMN, объединяющая процессы
/// </summary>
public class BpmnDiagram
{
/// <summary> Процессы диаграммы </summary>
public List<BpmnProcess> Processes { get; set; } = new();
/// <summary> Глобальные связи между процессами </summary>
public List<BpmnEdge> GlobalEdges { get; set; } = new();
}

View File

@@ -0,0 +1,34 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Расширения для диаграммы BPMN
/// </summary>
public static class BpmnDiagramExtensions
{
/// <summary>
/// Добавления отсутсвующих связей между процессами и их подпроцессами
/// </summary>
/// <param name="diagram"></param>
public static void AddMissingProcessEdges(this BpmnDiagram diagram)
{
foreach (var subprocess in diagram.Processes
.SelectMany(p => p.Nodes.Select(n => new { node = n, procId = p.Id }))
.Where(n => n.node.Type == BpmnNodeType.Subprocess && !string.IsNullOrEmpty(n.node.SubprocessId) && n.node.SubprocessId != n.procId)
.Select(n => n.node)
)
{
if (diagram.Processes.Any(p => p.Id == subprocess.SubprocessId)
&& !diagram.GlobalEdges.Any(t => t.From == subprocess.Id && t.To == subprocess.SubprocessId)
)
{
// Добавить пунктирный край от узла подпроцесса к началу подпроцесса
diagram.GlobalEdges.Add(new BpmnEdge
{
From = subprocess.Id,
To = subprocess.SubprocessId + "_start",
ArrowType = BpmnArrowType.Dashed,
});
}
}
}
}

View File

@@ -0,0 +1,19 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Связь между узлами BPMN
/// </summary>
public class BpmnEdge
{
/// <summary> Идентификатор узла-источника </summary>
public string From { get; set; } = string.Empty;
/// <summary> Идентификатор узла-приемника </summary>
public string To { get; set; } = string.Empty;
/// <summary> Метка/название связи </summary>
public string Label { get; set; } = string.Empty;
/// <summary> Тип стрелки связи </summary>
public BpmnArrowType ArrowType { get; set; } = BpmnArrowType.Default;
}

View File

@@ -0,0 +1,22 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Узел BPMN
/// </summary>
public class BpmnNode
{
///<summary> Уникальный идентификатор узла </summary>
public string Id { get; set; } = string.Empty;
///<summary> Метка/название узла </summary>
public string Label { get; set; } = string.Empty;
///<summary> Тип узла </summary>
public BpmnNodeType Type { get; set; }
/// <summary> Идентификатор подпроцесса (если тип узла - Subprocess) </summary>
public string SubprocessId { get; set; } = string.Empty;
/// <summary> Дополнительные свойства узла </summary>
public Dictionary<string, string> Properties { get; set; } = new();
}

View File

@@ -0,0 +1,20 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Типы узлов BPMN
/// </summary>
public enum BpmnNodeType
{
/// <summary> Стартовый узел процесса </summary>
Start,
/// <summary> Конечный узел процесса </summary>
End,
/// <summary> Задача </summary>
Task,
/// <summary> Шлюз (разветвление/объединение) </summary>
Gateway,
/// <summary> Шлюз (разветвление/объединение) </summary>
Hexagon,
/// <summary> Подпроцесс (вызов другого процесса) </summary>
Subprocess,
}

View File

@@ -0,0 +1,19 @@
namespace SQLLinter.Infrastructure.Diagram;
/// <summary>
/// Процесс BPMN
/// </summary>
public class BpmnProcess
{
/// <summary> Уникальный идентификатор процесса </summary>
public string Id { get; set; } = string.Empty;
/// <summary> Название процесса </summary>
public string Name { get; set; } = string.Empty;
/// <summary> Узлы процесса </summary>
public List<BpmnNode> Nodes { get; set; } = new();
/// <summary> Связи процесса </summary>
public List<BpmnEdge> Edges { get; set; } = new();
}

View File

@@ -0,0 +1,73 @@
using SQLLinter.Core.Interfaces;
using SQLLinter.Infrastructure.Interfaces;
using SQLLinter.Infrastructure.Parser;
namespace SQLLinter.Infrastructure.Diagram;
public class SqlDiagramProcessor : ISqlDiagramProcessor
{
private readonly BpmnDiagram _bpmnDiagram;
private readonly IFragmentBuilder _fragmentBuilder;
private readonly ISqlStreamReaderBuilder _sqlStreamReaderBuilder;
public SqlDiagramProcessor(IFragmentBuilder fragmentBuilder, BpmnDiagram bpmnDiagram)
: this(fragmentBuilder, bpmnDiagram, new SqlStreamReaderBuilder()) { }
public SqlDiagramProcessor(IFragmentBuilder fragmentBuilder, BpmnDiagram bpmnDiagram, ISqlStreamReaderBuilder sqlStreamReaderBuilder)
{
_fragmentBuilder = fragmentBuilder;
_bpmnDiagram = bpmnDiagram;
_sqlStreamReaderBuilder = sqlStreamReaderBuilder;
}
public void ProcessList(List<string> filePaths)
{
foreach (var path in filePaths)
{
ProcessFile(path);
}
}
public void ProcessPath(string path)
{
ProcessFile(path);
}
private void ProcessFile(string filePath)
{
var fileStream = GetFileContents(filePath);
HandleProcessing(filePath, fileStream);
}
public void ProcessList(Dictionary<string, Stream> files)
{
foreach (var file in files)
{
HandleProcessing(file.Key, file.Value);
}
}
private void HandleProcessing(string filePath, Stream fileStream)
{
var fragment = _fragmentBuilder.GetFragment(filePath, GetSqlTextReader(fileStream), out var errors);
if (fragment == null || errors.Count > 0)
{
return;
}
BpmnBuilder.Build(fragment, _bpmnDiagram);
_bpmnDiagram.AddMissingProcessEdges();
}
private Stream GetFileContents(string filePath)
{
return File.OpenRead(filePath);
}
private StreamReader GetSqlTextReader(Stream sqlFileStream)
{
return _sqlStreamReaderBuilder.CreateReader(sqlFileStream);
}
}

View File

@@ -55,7 +55,7 @@ public class FragmentBuilder : IFragmentBuilder
{
foreach (var err in errors)
{
_reporter.ReportViolation(path, err.Line, err.Column, RuleViolationSeverity.Critical, "parse", err.Message);
_reporter.ReportViolation(path, err.Line, err.Column, RuleViolationSeverity.Critical, "parse", err.Message, null);
}
}

View File

@@ -7,21 +7,17 @@ namespace SQLLinter.Infrastructure.Parser;
public class SqlFileProcessor : ISqlFileProcessor
{
private readonly IRuleVisitor ruleVisitor;
private readonly IReporter reporter;
private readonly IPluginHandler pluginHandler;
private readonly IConfig _config;
private readonly IRuleExceptionFinder ruleExceptionFinder;
public SqlFileProcessor(
public SqlFileProcessor(IConfig config,
IRuleVisitor ruleVisitor,
IPluginHandler pluginHandler,
IReporter reporter)
IPluginHandler pluginHandler
)
{
this._config = config;
this.ruleVisitor = ruleVisitor;
this.pluginHandler = pluginHandler;
this.reporter = reporter;
ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames);
}
@@ -99,7 +95,7 @@ public class SqlFileProcessor : ISqlFileProcessor
private void ProcessRules(Stream fileStream, IEnumerable<IRuleException> ignoredRules, string filePath)
{
ruleVisitor.VisitRules(filePath, ignoredRules, fileStream);
ruleVisitor.VisitRules(filePath, ignoredRules, fileStream, this._config.GenerateDetails);
}
private Stream GetFileContents(string filePath)

View File

@@ -3,6 +3,7 @@ using SQLLinter.Common;
using SQLLinter.Core.Interfaces;
using SQLLinter.Infrastructure.Configuration.Overrides;
using SQLLinter.Infrastructure.Interfaces;
using SQLLinter.Infrastructure.Rules;
using SQLLinter.Infrastructure.Rules.RuleExceptions;
using SQLLinter.Infrastructure.Rules.RuleViolations;
using System.Data;
@@ -29,7 +30,7 @@ public class SqlRuleVisitor : IRuleVisitor
this._sqlStreamReaderBuilder = sqlStreamReaderBuilder;
}
public void VisitRules(string sqlPath, IEnumerable<IRuleException> ignoredRules, Stream sqlFileStream)
public void VisitRules(string sqlPath, IEnumerable<IRuleException> ignoredRules, Stream sqlFileStream, bool generateDetails)
{
var overrides = _overrideFinder.GetOverrideList(sqlFileStream);
var overrideArray = overrides as IOverride[] ?? overrides.ToArray();
@@ -38,6 +39,8 @@ public class SqlRuleVisitor : IRuleVisitor
if (sqlFragment == null) return;
Dictionary<TSqlFragment, TSqlFragment?>? parentMap = generateDetails ? ParentMapBuilder.Build(sqlFragment) : null;
var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray();
if (errors.Any())
{
@@ -47,6 +50,7 @@ public class SqlRuleVisitor : IRuleVisitor
var rules = _pluginHandler.Rules;
foreach (var rule in rules)
{
rule.SetParents(parentMap);
VisitFragment(sqlFragment, rule, overrideArray, sqlPath);
}
}
@@ -76,7 +80,7 @@ public class SqlRuleVisitor : IRuleVisitor
}
}
violations.ForEach(t => _reporter.ReportViolation(filePath, t.Line, t.Column, rule.Severity, t.RuleName, t.Message));
violations.ForEach(t => _reporter.ReportViolation(filePath, t.Line, t.Column, rule.Severity, t.RuleName, t.Template, t.Snippet, t.Params));
}
private static bool VisitorIsBlackListedForDynamicSql(IRule visitor)
@@ -109,7 +113,15 @@ public class SqlRuleVisitor : IRuleVisitor
if (!globalRulesOnLine.Any())
{
_reporter.ReportViolation(new RuleViolation(sqlPath, "invalid-syntax", error.Message, error.Line, error.Column, RuleViolationSeverity.Critical));
_reporter.ReportViolation(new RuleViolation()
{
FileName = sqlPath,
RuleName = "invalid-syntax",
Text = error.Message,
Line = error.Line,
Column = error.Column,
Severity = RuleViolationSeverity.Critical
});
if (updatedExitCode)
{
continue;

View File

@@ -1,14 +0,0 @@
namespace SQLLinter.Infrastructure.Reporters;
public class FileReporter : Reporter
{
public virtual void SaveReport(string path)
{
File.WriteAllText(path, GetContent());
}
public virtual string GetContent()
{
return string.Join(Environment.NewLine, this.Violations);
}
}

View File

@@ -1,13 +1,16 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v1;
public class HTMLReporter : FileReporter
public class HtmlReportFormatter : IReportFormatter
{
public string GetContent()
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);
public string Format(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var violations = Violations;
if (violations.Count == 0)
{
return "<p><em>Нет нарушений</em></p>";
@@ -34,14 +37,14 @@ public class HTMLReporter : FileReporter
sb.AppendLine("td.line, td.column { width: 60px; text-align: center; }");
sb.AppendLine("td.rule { width: 300px; text-align: left; }");
sb.AppendLine("td.index { width: 40px; text-align: center; }");
sb.AppendLine(".critical { border-left: 4px solid #d13438; padding: 10px; margin-bottom: 20px; background-color: #fde7e9; }");
sb.AppendLine(".critical { border-left: 4px solid #d13438; padding: 10px; margin-bottom: 20px; margin-top: 20px; background-color: #fde7e9; }");
sb.AppendLine(".warning { border-left: 4px solid #ffaa44; padding: 10px; margin-bottom: 20px; background-color: #fff4ce; }");
sb.AppendLine(".info { border-left: 4px solid #0078d4; padding: 10px; margin-bottom: 20px; background-color: #deecf9; }");
sb.AppendLine(".tabs { position: fixed; bottom: 0; left: 0; right: 0; background-color: #ffffff; border-top: 1px solid #ccc; padding: 10px; display: flex; overflow-x: auto; scrollbar-width: thin; justify-content: flex-start; box-shadow: 0 -2px 6px rgba(0,0,0,0.1); }");
sb.AppendLine(".tab { margin-right: 10px; padding: 8px 16px; border-radius: 4px; background-color: #f3f2f1; cursor: pointer; transition: background-color 0.2s; }");
sb.AppendLine(".tab:hover { background-color: #e1dfdd; }");
sb.AppendLine(".tab.active { background-color: #0078d4; color: white; }");
sb.AppendLine(".file-report { display: none; padding: 20px 0; }");
sb.AppendLine(".file-report { display: none; padding: 0 0; }");
sb.AppendLine(".file-report.active { display: block; }");
// Тёмная тема
@@ -140,6 +143,6 @@ public class HTMLReporter : FileReporter
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
return HtmlMinifier.MinifyHtml(sb.ToString());
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Rules.RuleViolations;
using System.Data;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using static SQLLinter.Common.BaseRuleVisitor;
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v2;
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);
public string Format(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
// Подготовка данных для передачи в JS
var reportData = PrepareReportData(violations, diagram);
var jsonData = JsonSerializer.Serialize(reportData, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
if (violations.Count == 0 && diagram == null)
{
// Случай без нарушений
sb.AppendLine("<div class=\"no-violations\">");
sb.AppendLine("<div class=\"no-violations-content\">");
sb.AppendLine("<div class=\"no-violations-icon\">✅</div>");
sb.AppendLine("<h3 class=\"no-violations-title\">Проверка завершена</h3>");
sb.AppendLine("<p class=\"no-violations-description\">Нарушений правил SQL не обнаружено.</p>");
sb.AppendLine("</div>");
sb.AppendLine("</div>");
GenerateEndingHtml(sb, false, HtmlMinifier.CompressJson(jsonData));
return sb.ToString();
}
// Основной контейнер для отчета
sb.AppendLine("""
<div id="reports-container"></div>
<div id="tabs-container" class="tabs-container">
<div class="tabs" id="tabs-list"></div>
</div>
""");
GenerateEndingHtml(sb, diagram != null, jsonData);
var html = HtmlMinifier.MinifyHtml(sb.ToString());
return html;
}
private ReportData PrepareReportData(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var reportData = new ReportData();
// Группировка по файлам
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key)
.ToList();
foreach (var fileGroup in groupedByFile)
{
var fileData = new FileReport
{
Name = fileGroup.Key,
};
// Группировка по severity
var severityGroups = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key)
.ToList();
foreach (var violation in fileGroup.Select(t => t).OrderBy(v => v.Line).ThenBy(v => v.Column))
{
int ruleId;
List<string> args = new();
if (violation is RuleTemplateViolation templateRule)
{
args = templateRule.Params.Select(p => EscapeHtml(p)).ToList();
if (reportData.Rules.Any(t => t.Value.Name == templateRule.RuleName))
{
ruleId = reportData.Rules.First(t => t.Value.Name == templateRule.RuleName).Key;
}
else
{
ruleId = reportData.Rules.Count + 1;
reportData.Rules.Add(ruleId, new Rule
{
Name = templateRule.RuleName,
Template = templateRule.RuleTemplate
});
}
}
else
{
ruleId = reportData.Rules.Count + 1;
reportData.Rules.Add(ruleId, new Rule
{
Name = violation.RuleName,
Template = violation.Text,
});
}
var v = new Violation()
{
RuleId = ruleId,
Args = args,
Column = violation.Column,
Line = violation.Line,
Details = violation.Snippet != null ? SnippetToHtml(violation.Snippet, violation.Severity) : null
};
if (violation.Severity == RuleViolationSeverity.Critical)
{
v.Index = fileData.Violations.Critical.Count + 1;
fileData.Violations.Critical.Add(v);
}
else if (violation.Severity == RuleViolationSeverity.Warning)
{
v.Index = fileData.Violations.Warning.Count + 1;
fileData.Violations.Warning.Add(v);
}
else if (violation.Severity == RuleViolationSeverity.Info)
{
v.Index = fileData.Violations.Info.Count + 1;
fileData.Violations.Info.Add(v);
}
}
reportData.Files.Add(fileData);
}
// Добавление диаграммы, если есть
if (diagram != null)
{
reportData.Diagram = new Diagram
{
Content = MermaidRenderer.ToMermaidContent(diagram),
HasDiagram = true
};
}
return reportData;
}
public string SnippetToHtml(ExtractedBlock snippet, RuleViolationSeverity severity)
{
if (snippet == null || snippet.Lines.Count == 0)
return string.Empty;
string errorSeverity = severity switch
{
RuleViolationSeverity.Critical => "critical",
RuleViolationSeverity.Warning => "warning",
RuleViolationSeverity.Info => "info",
_ => "info"
};
var sb = new StringBuilder();
sb.AppendLine("<div class=\"code-block\">");
sb.AppendLine("<div class=\"code-container\">");
for (int i = 0; i < snippet.Lines.Count; i++)
{
var (lineNumber, text) = snippet.Lines[i];
var relativeLineIndex = i + 1; // 1-based индекс в контексте блока
bool isErrorLine = relativeLineIndex >= snippet.ErrorStartLine &&
relativeLineIndex <= snippet.ErrorEndLine;
string processedText = text;
if (isErrorLine)
{
if (relativeLineIndex == snippet.ErrorStartLine && relativeLineIndex == snippet.ErrorEndLine)
{
// Ошибка в пределах одной строки
processedText = HighlightErrorInLine(
text,
snippet.ErrorStartColumn,
snippet.ErrorEndColumn,
errorSeverity);
}
else if (relativeLineIndex == snippet.ErrorStartLine)
{
// Первая строка многострочной ошибки
processedText = HighlightErrorInLine(
text,
snippet.ErrorStartColumn,
text.Length,
errorSeverity);
}
else if (relativeLineIndex == snippet.ErrorEndLine)
{
// Последняя строка многострочной ошибки
processedText = HighlightErrorInLine(
text,
1,
snippet.ErrorEndColumn,
errorSeverity);
}
else
{
// Промежуточная строка многострочной ошибки
processedText = $"<span class=\"{errorSeverity}\">{text}</span>";
}
}
var lineClass = "code-line";
if (isErrorLine)
{
lineClass += $" {errorSeverity}-line";
}
sb.Append($"<div class=\"{lineClass}\">");
sb.Append($"<span class=\"line-number\">{lineNumber}</span> ");
sb.Append(processedText);
sb.AppendLine("</div>");
}
sb.AppendLine("</div>");
sb.AppendLine("</div>");
return sb.ToString();
}
private string HighlightErrorInLine(string line, int startColumn, int endColumn, string errorSeverity)
{
if (string.IsNullOrEmpty(line) || startColumn > endColumn)
return line;
// Корректируем индексы для 0-based string
int startIdx = Math.Max(0, startColumn - 1);
int endIdx = Math.Min(line.Length, endColumn - 1);
if (startIdx >= line.Length || endIdx <= 0 || startIdx >= endIdx)
{
// Если позиции вне диапазона, подсвечиваем всю строку
return $"<span class=\"{errorSeverity}\">{line}</span>";
}
string before = line.Substring(0, startIdx);
string error = line.Substring(startIdx, endIdx - startIdx);
string after = line.Substring(endIdx);
return $"{before}<span class=\"{errorSeverity}\">{error}</span>{after}";
}
private void GenerateBeginningHtml(StringBuilder sb)
{
sb.AppendLine("""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт по SQLпроверкам</title>
<style>
""");
sb.AppendLine(LoadResource("HtmlFormatter_v2.css"));
sb.AppendLine("</style></head><body><main id=\"main-content\">");
}
private void GenerateEndingHtml(StringBuilder sb, bool hasDiagram, string jsonData)
{
// Вставка JSON данных
sb.AppendLine($"""
<script id="report-data" type="application/json">
{jsonData}
</script>
""");
sb.AppendLine("""
<script type="module">
""");
// Загружаем основной JS
sb.AppendLine(LoadResource("HtmlFormatter_v2.js"));
sb.AppendLine("""
</script>
</body>
</html>
""");
}
private static string LoadResource(string endsWith)
{
var assembly = Assembly.GetExecutingAssembly();
var name = assembly.GetManifestResourceNames()
.First(n => n.EndsWith(endsWith, StringComparison.OrdinalIgnoreCase));
using var stream = assembly.GetManifestResourceStream(name);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string EscapeHtml(string text)
{
return System.Net.WebUtility.HtmlEncode(text);
}
// Классы для сериализации
private class ReportData
{
[JsonPropertyName("f")] // files
public List<FileReport> Files { get; set; } = new();
[JsonPropertyName("r")] // rules
public Dictionary<int, Rule> Rules { get; set; } = new();
[JsonPropertyName("d")] // diagram
public Diagram Diagram { get; set; } = new();
}
private class FileReport
{
[JsonPropertyName("n")] // name
public string Name { get; set; } = string.Empty;
[JsonPropertyName("v")] // violations
public Violations Violations { get; set; } = new();
}
private class Violations
{
[JsonPropertyName("c")] // critical
public List<Violation> Critical { get; set; } = new();
[JsonPropertyName("w")] // warning
public List<Violation> Warning { get; set; } = new();
[JsonPropertyName("i")] // info
public List<Violation> Info { get; set; } = new();
}
private class Violation
{
[JsonPropertyName("i")] // index
public int Index { get; set; }
[JsonPropertyName("l")] // line
public int Line { get; set; }
[JsonPropertyName("c")] // column
public int Column { get; set; }
[JsonPropertyName("r")] // ruleId
public int RuleId { get; set; }
[JsonPropertyName("a")] // args (optional)
public List<string>? Args { get; set; }
[JsonPropertyName("d")] // details (optional)
public string? Details { get; set; }
}
private class Rule
{
[JsonPropertyName("n")] // name
public string Name { get; set; } = string.Empty;
[JsonPropertyName("t")] // template
public string Template { get; set; } = string.Empty;
}
private class Diagram
{
[JsonPropertyName("c")] // content
public string Content { get; set; } = string.Empty;
[JsonPropertyName("h")] // hasDiagram
public bool HasDiagram { get; set; }
}
}

View File

@@ -0,0 +1,542 @@
/* ---------------------------------------------------------
FLUENT UI 2 — BASE VARIABLES
--------------------------------------------------------- */
:root {
--color-primary: #0f6cbd;
--color-primary-hover: #115ea3;
--color-primary-light: #d0e7ff;
--color-bg: #ffffff;
--color-bg-alt: #f5f5f5;
--color-bg-neutral: #f3f2f1;
--color-border: #d1d1d1;
--color-border-strong: #b5b5b5;
--color-text: #1a1a1a;
--color-text-secondary: #5e5e5e;
--color-text-tertiary: #8a8a8a;
--color-critical: #d13438;
--color-critical-bg: #f8d7da;
--color-warning: #ffaa44;
--color-warning-bg: #fff4ce;
--color-info: #0f6cbd;
--color-info-bg: #d0e7ff;
--radius-s: 4px;
--radius-m: 6px;
--radius-l: 8px;
--space-xs: 4px;
--space-s: 8px;
--space-m: 12px;
--space-l: 16px;
--space-xl: 20px;
--space-xxl: 28px;
--font-s: 12px;
--font-m: 14px;
--font-l: 16px;
--font-xl: 18px;
--z-tabs: 1000;
--z-header: 900;
}
/* ---------------------------------------------------------
RESET
--------------------------------------------------------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", sans-serif;
background: var(--color-bg-neutral);
color: var(--color-text);
line-height: 1.5;
}
/* ---------------------------------------------------------
FILE REPORT WRAPPER
--------------------------------------------------------- */
.file-report {
display: none;
padding-bottom: 120px;
}
.file-report.active {
display: block;
}
/* ---------------------------------------------------------
FILE TITLE (STICKY)
--------------------------------------------------------- */
.file-title-container {
position: sticky;
top: 0;
z-index: var(--z-header);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
padding: var(--space-l) var(--space-xl);
}
.file-title {
font-size: var(--font-xl);
font-weight: 600;
color: var(--color-text);
}
/* ---------------------------------------------------------
SEVERITY SECTIONS (FLUENT UI 2 STYLE)
--------------------------------------------------------- */
.severity-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-l);
padding: var(--space-xl);
margin: var(--space-xl) var(--space-xl);
}
.severity-section.critical {
border-left: 4px solid var(--color-critical);
}
.severity-section.warning {
border-left: 4px solid var(--color-warning);
}
.severity-section.info {
border-left: 4px solid var(--color-info);
}
.severity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-l);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--color-border);
}
.severity-title h2 {
font-size: var(--font-l);
font-weight: 600;
}
.severity-count {
background: var(--color-bg-alt);
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: var(--font-s);
color: var(--color-text-secondary);
}
/* ---------------------------------------------------------
GRID TABLE (REPLACES <table>)
--------------------------------------------------------- */
.grid-table {
display: grid;
gap: var(--space-xs);
}
.grid-header,
.grid-row {
display: grid;
grid-template-columns: 40px 60px 60px 250px 1fr;
gap: var(--space-xs);
padding: var(--space-s);
border-radius: var(--radius-s);
}
.grid-header {
background: var(--color-bg-alt);
font-weight: 600;
color: var(--color-text-secondary);
font-size: var(--font-s);
}
.grid-row {
background: var(--color-bg);
border: 1px solid var(--color-border);
font-size: var(--font-m);
transition: background-color 0.15s ease;
}
.grid-row:hover {
background: var(--color-bg-alt);
}
/* ---------------------------------------------------------
SUMMARY SECTION
--------------------------------------------------------- */
.summary-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-l);
padding: var(--space-xl);
margin: var(--space-xl);
}
.summary-header h2 {
font-size: var(--font-l);
margin-bottom: var(--space-m);
}
.summary-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-m);
margin-bottom: var(--space-l);
}
.stat-card {
background: var(--color-bg-alt);
border-radius: var(--radius-m);
padding: var(--space-m);
text-align: center;
}
.stat-value {
font-size: var(--font-xl);
font-weight: 600;
}
.stat-label {
font-size: var(--font-s);
color: var(--color-text-secondary);
}
/* Progress bar */
.progress-bar {
display: flex;
height: 8px;
border-radius: var(--radius-s);
overflow: hidden;
background: var(--color-border);
}
.progress-fill.critical {
background: var(--color-critical);
}
.progress-fill.warning {
background: var(--color-warning);
}
.progress-fill.info {
background: var(--color-info);
}
/* ---------------------------------------------------------
FILE CARDS IN SUMMARY
--------------------------------------------------------- */
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-m);
}
.file-card {
background: var(--color-bg-alt);
border-radius: var(--radius-m);
padding: var(--space-m);
transition: background-color 0.2s ease, transform 0.2s ease;
}
.file-card:hover {
transform: translateY(-2px);
background: var(--color-bg-neutral);
}
.file-name-small {
font-weight: 600;
font-size: var(--font-m);
}
.file-card-bottom {
display: flex;
justify-content: space-between;
margin-top: var(--space-s);
}
.file-violations {
display: flex;
gap: var(--space-xs);
}
.violation-badge {
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: var(--font-s);
color: var(--color-text);
}
.violation-badge.critical {
background: var(--color-critical-bg);
color: var(--color-critical);
}
.violation-badge.warning {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.violation-badge.info {
background: var(--color-info-bg);
color: var(--color-info);
}
/* ---------------------------------------------------------
TABS CONTAINER (STICKY BOTTOM)
--------------------------------------------------------- */
.tabs-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg);
border-top: 1px solid var(--color-border);
padding: var(--space-s) var(--space-l);
z-index: var(--z-tabs);
}
/* ---------------------------------------------------------
TABS — HORIZONTAL SCROLL + VISIBLE SCROLLBAR
--------------------------------------------------------- */
.tabs {
display: flex;
flex-wrap: nowrap;
gap: var(--space-s);
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scroll-behavior: smooth;
padding-bottom: 4px;
/* Firefox scrollbar */
scrollbar-width: thin;
scrollbar-color: var(--color-border-strong) var(--color-bg-alt);
}
/* Chrome / Edge / Safari scrollbar */
.tabs::-webkit-scrollbar {
height: 8px;
}
.tabs::-webkit-scrollbar-track {
background: var(--color-bg-alt);
border-radius: 4px;
}
.tabs::-webkit-scrollbar-thumb {
background: var(--color-border-strong);
border-radius: 4px;
}
.tabs::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* ---------------------------------------------------------
TAB BASE STYLES (COMPACT, AUTO WIDTH, MAX 2 LINES)
--------------------------------------------------------- */
.tab {
flex: 0 0 auto; /* растягивается под контент, не ломая одну линию */
min-width: 0;
padding: 6px 12px;
line-height: 1.25;
display: flex;
align-items: flex-start;
background: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-m);
cursor: pointer;
font-size: var(--font-m);
font-weight: 500;
color: var(--color-text-secondary);
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.tab:hover {
background: var(--color-bg-neutral);
transform: translateY(-1px);
}
.tab.active {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
transform: translateY(-2px);
}
/* Внутренний layout таба */
.tab-inner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
width: 100%;
}
/* Текст таба — максимум 2 строки, с переносами */
.tab-text {
font-size: 13px;
line-height: 1.25;
white-space: normal;
word-break: break-word;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
max-height: calc(1.25em * 2);
overflow: hidden;
}
/* Счётчики — компактные */
.tab-counters {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.tab-counter {
font-size: 10px;
min-width: 14px;
height: 14px;
padding: 0 3px;
line-height: 14px;
border-radius: var(--radius-s);
text-align: center;
}
.tab-counter.critical {
background: var(--color-critical-bg);
color: var(--color-critical);
}
.tab-counter.warning {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.tab-counter.info {
background: var(--color-info-bg);
color: var(--color-info);
}
.tab-counter.empty {
opacity: 0.3;
}
/* Мобильная компактность */
@media (max-width: 480px) {
.tab {
padding: 4px 8px;
}
.tab-text {
font-size: 12px;
max-height: calc(1.2em * 2);
-webkit-line-clamp: 2;
}
.tab-counter {
min-width: 12px;
height: 12px;
font-size: 9px;
padding: 0 2px;
}
}
/* ---------------------------------------------------------
FLUENT UI 2 — DARK THEME (PREFERS-COLOR-SCHEME)
--------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1f1f1f;
--color-bg-alt: #2a2a2a;
--color-bg-neutral: #2d2d2d;
--color-text: #f3f3f3;
--color-text-secondary: #c8c8c8;
--color-text-tertiary: #9a9a9a;
--color-border: #3a3a3a;
--color-border-strong: #4a4a4a;
--color-primary: #3aa0f3;
--color-primary-hover: #2899f5;
--color-primary-light: #0f2b4d;
--color-critical-bg: #4c191b;
--color-warning-bg: #4c3b1a;
--color-info-bg: #0f2b4d;
}
.file-title-container {
background: var(--color-bg);
}
.severity-section {
background: var(--color-bg);
border-color: var(--color-border);
}
.grid-row {
background: var(--color-bg-alt);
border-color: var(--color-border);
}
.grid-row:hover {
background: var(--color-bg-neutral);
}
.summary-section {
background: var(--color-bg);
border-color: var(--color-border);
}
.file-card {
background: var(--color-bg-alt);
}
.tabs-container {
background: var(--color-bg);
border-color: var(--color-border);
}
.tab {
background: var(--color-bg-alt);
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.tab.active {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Dark scrollbar colors */
.tabs {
scrollbar-color: var(--color-border-strong) var(--color-bg);
}
.tabs::-webkit-scrollbar-track {
background: var(--color-bg);
}
.tabs::-webkit-scrollbar-thumb {
background: var(--color-border-strong);
}
.tabs::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary);
}
}
/* ---------------------------------------------------------
TAB TRANSITIONS & FILE REPORT ANIMATION
--------------------------------------------------------- */
.file-report {
opacity: 0;
transform: translateY(6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.file-report.active {
opacity: 1;
transform: translateY(0);
}

View File

@@ -0,0 +1,486 @@
document.addEventListener("DOMContentLoaded", function () {
const dataEl = document.getElementById("report-data");
if (!dataEl) return console.error("report-data not found");
const data = JSON.parse(dataEl.textContent || "{}");
const files = data.files || data.f || [];
const rules = data.rules || data.r || {};
const tabsList = document.getElementById("tabs-list");
const reportsContainer = document.getElementById("reports-container");
if (!tabsList || !reportsContainer) {
console.error("Missing tabs-list or reports-container");
return;
}
/* ---------------------------------------------------------
UTILS
--------------------------------------------------------- */
function escapeHtml(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function countViolations(v) {
return {
c: v?.c?.length || 0,
w: v?.w?.length || 0,
i: v?.i?.length || 0
};
}
const rendered = new Set(); // ленивый рендер
/* ---------------------------------------------------------
RENDER TABS
--------------------------------------------------------- */
function renderTabs() {
const frag = document.createDocumentFragment();
files.forEach((file, index) => {
const v = countViolations(file.v);
const btn = document.createElement("button");
btn.className = "tab";
btn.dataset.target = `file_${index}`;
btn.dataset.index = index;
const inner = document.createElement("div");
inner.className = "tab-inner";
const text = document.createElement("span");
text.className = "tab-text";
text.title = file.n;
text.textContent = file.n;
const counters = document.createElement("div");
counters.className = "tab-counters";
const c1 = document.createElement("span");
c1.className = "tab-counter critical" + (v.c ? "" : " empty");
c1.textContent = v.c || "";
const c2 = document.createElement("span");
c2.className = "tab-counter warning" + (v.w ? "" : " empty");
c2.textContent = v.w || "";
const c3 = document.createElement("span");
c3.className = "tab-counter info" + (v.i ? "" : " empty");
c3.textContent = v.i || "";
counters.append(c1, c2, c3);
inner.append(text, counters);
btn.append(inner);
frag.append(btn);
});
// Summary tab
const summaryBtn = document.createElement("button");
summaryBtn.className = "tab";
summaryBtn.dataset.target = "summary_report";
const inner = document.createElement("div");
inner.className = "tab-inner";
const text = document.createElement("span");
text.className = "tab-text";
text.textContent = "Summary";
inner.append(text);
summaryBtn.append(inner);
frag.append(summaryBtn);
tabsList.innerHTML = "";
tabsList.append(frag);
}
/* ---------------------------------------------------------
GRID ROW CREATOR (VIOLATIONS)
--------------------------------------------------------- */
function createGridRow(v, rule) {
const row = document.createElement("div");
row.className = "grid-row";
const cIndex = document.createElement("div");
cIndex.textContent = v.i;
const cLine = document.createElement("div");
cLine.textContent = v.l;
const cCol = document.createElement("div");
cCol.textContent = v.c;
const cRule = document.createElement("div");
cRule.textContent = rule.n;
let text = rule.t || "";
const args = v.a || v.args;
if (Array.isArray(args)) {
for (let i = 0; i < args.length; i++) {
text = text.replace(`{${i}}`, args[i]);
}
}
const cDesc = document.createElement("div");
cDesc.textContent = text;
row.append(cIndex, cLine, cCol, cRule, cDesc);
return row;
}
/* ---------------------------------------------------------
FILE REPORT
--------------------------------------------------------- */
function renderFileReport(index) {
const file = files[index];
const v = file.v || {};
const root = document.createElement("div");
// Title
const titleWrap = document.createElement("div");
titleWrap.className = "file-title-container";
const title = document.createElement("div");
title.className = "file-title";
const name = document.createElement("span");
name.className = "file-name";
name.textContent = file.n;
title.append(name);
titleWrap.append(title);
root.append(titleWrap);
// Sections
function addSection(label, list, cls) {
if (!Array.isArray(list) || list.length === 0) return;
const section = document.createElement("div");
section.className = `severity-section ${cls}`;
const header = document.createElement("div");
header.className = "severity-header";
const hTitle = document.createElement("div");
hTitle.className = "severity-title";
const h2 = document.createElement("h2");
h2.textContent = label;
const count = document.createElement("span");
count.className = "severity-count";
count.textContent = list.length;
hTitle.append(h2, count);
header.append(hTitle);
section.append(header);
const grid = document.createElement("div");
grid.className = "grid-table";
// header row
const headerRow = document.createElement("div");
headerRow.className = "grid-header";
["#", "Строка", "Колонка", "Правило", "Описание"].forEach(t => {
const cell = document.createElement("div");
cell.textContent = t;
headerRow.append(cell);
});
grid.append(headerRow);
// rows
for (let i = 0; i < list.length; i++) {
const vItem = list[i];
const rule = rules[vItem.r] || { n: "Unknown", t: "Описание отсутствует" };
grid.append(createGridRow(vItem, rule));
}
section.append(grid);
root.append(section);
}
addSection("Critical", v.c, "critical");
addSection("Warning", v.w, "warning");
addSection("Info", v.i, "info");
return root;
}
/* ---------------------------------------------------------
SUMMARY
--------------------------------------------------------- */
function renderSummary() {
let totalC = 0, totalW = 0, totalI = 0;
for (let i = 0; i < files.length; i++) {
const v = countViolations(files[i].v);
totalC += v.c;
totalW += v.w;
totalI += v.i;
}
const total = totalC + totalW + totalI || 1;
const root = document.createElement("div");
// Title
const titleWrap = document.createElement("div");
titleWrap.className = "file-title-container";
const title = document.createElement("div");
title.className = "file-title";
const name = document.createElement("span");
name.className = "file-name";
name.textContent = "Сводный отчет";
title.append(name);
titleWrap.append(title);
root.append(titleWrap);
// Stats
const section = document.createElement("div");
section.className = "summary-section";
const header = document.createElement("div");
header.className = "summary-header";
const hTitle = document.createElement("div");
hTitle.className = "summary-title";
const h2 = document.createElement("h2");
h2.textContent = "Общая статистика";
hTitle.append(h2);
header.append(hTitle);
section.append(header);
const grid = document.createElement("div");
grid.className = "summary-stats-grid";
function statCard(cls, value, label) {
const card = document.createElement("div");
card.className = `stat-card ${cls}`;
const v = document.createElement("div");
v.className = "stat-value";
v.textContent = value;
const l = document.createElement("div");
l.className = "stat-label";
l.textContent = label;
card.append(v, l);
return card;
}
grid.append(
statCard("success", files.length, "Всего файлов"),
statCard("critical", totalC, "Critical нарушений"),
statCard("warning", totalW, "Warning нарушений"),
statCard("info", totalI, "Info нарушений")
);
section.append(grid);
// Progress
const dist = document.createElement("div");
dist.className = "violation-distribution";
const bar = document.createElement("div");
bar.className = "progress-bar";
const pc = (totalC / total) * 100;
const pw = (totalW / total) * 100;
const pi = (totalI / total) * 100;
const f1 = document.createElement("div");
f1.className = "progress-fill critical";
f1.style.width = pc + "%";
const f2 = document.createElement("div");
f2.className = "progress-fill warning";
f2.style.width = pw + "%";
const f3 = document.createElement("div");
f3.className = "progress-fill info";
f3.style.width = pi + "%";
bar.append(f1, f2, f3);
dist.append(bar);
section.append(dist);
root.append(section);
// Files overview
const section2 = document.createElement("div");
section2.className = "summary-section files-overview";
const header2 = document.createElement("div");
header2.className = "summary-header";
const hTitle2 = document.createElement("div");
hTitle2.className = "summary-title";
const h22 = document.createElement("h2");
h22.textContent = "Статистика файлов";
const count = document.createElement("span");
count.className = "severity-count";
count.textContent = files.length;
hTitle2.append(h22, count);
header2.append(hTitle2);
section2.append(header2);
const filesGrid = document.createElement("div");
filesGrid.className = "files-grid";
for (let i = 0; i < files.length; i++) {
const f = files[i];
const v = countViolations(f.v);
const t = v.c + v.w + v.i || 1;
const card = document.createElement("div");
card.className = "file-card";
const header = document.createElement("div");
header.className = "file-card-header";
const name = document.createElement("span");
name.className = "file-name-small";
name.textContent = f.n;
header.append(name);
card.append(header);
const bar = document.createElement("div");
bar.className = "progress-bar small";
const fc = document.createElement("div");
fc.className = "progress-fill critical";
fc.style.width = (v.c / t * 100) + "%";
const fw = document.createElement("div");
fw.className = "progress-fill warning";
fw.style.width = (v.w / t * 100) + "%";
const fi = document.createElement("div");
fi.className = "progress-fill info";
fi.style.width = (v.i / t * 100) + "%";
bar.append(fc, fw, fi);
card.append(bar);
const bottom = document.createElement("div");
bottom.className = "file-card-bottom";
const total = document.createElement("span");
total.className = "file-total";
total.textContent = "Total: " + (v.c + v.w + v.i);
const badges = document.createElement("div");
badges.className = "file-violations";
function badge(cls, val) {
const b = document.createElement("span");
b.className = `violation-badge ${cls}`;
b.textContent = val;
return b;
}
badges.append(
badge("critical", v.c),
badge("warning", v.w),
badge("info", v.i)
);
bottom.append(total, badges);
card.append(bottom);
filesGrid.append(card);
}
section2.append(filesGrid);
root.append(section2);
return root;
}
/* ---------------------------------------------------------
TAB HANDLER
--------------------------------------------------------- */
tabsList.addEventListener("click", function (e) {
const tab = e.target.closest(".tab");
if (!tab) return;
const targetId = tab.dataset.target;
// activate tab
tabsList.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
// hide all reports
reportsContainer.innerHTML = "";
// lazy render
let content;
if (!rendered.has(targetId)) {
if (targetId === "summary_report") {
content = renderSummary();
} else {
const index = Number(tab.dataset.index);
content = renderFileReport(index);
}
rendered.add(targetId);
} else {
// already rendered → but we removed DOM → re-render
if (targetId === "summary_report") {
content = renderSummary();
} else {
const index = Number(tab.dataset.index);
content = renderFileReport(index);
}
}
reportsContainer.append(content);
window.scrollTo({ top: 0, behavior: "smooth" });
});
/* ---------------------------------------------------------
INIT
--------------------------------------------------------- */
renderTabs();
const first = tabsList.querySelector(".tab");
if (first) first.click();
const tabs = document.querySelector('.tabs');
tabs.addEventListener('wheel', (e) => {
if (e.deltaY !== 0) {
e.preventDefault();
tabs.scrollBy({
left: e.deltaY * 4,
behavior: "smooth"
});
}
}, { passive: false });
});

View File

@@ -0,0 +1,291 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Rules.RuleViolations;
using System.Data;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v3;
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);
public string Format(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var sb = new StringBuilder();
GenerateBeginningHtml(sb);
// Подготовка данных для передачи в JS
var reportData = PrepareReportData(violations, diagram);
var jsonData = JsonSerializer.Serialize(reportData, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
if (violations.Count == 0 && diagram == null)
{
// Случай без нарушений
sb.AppendLine("<div class=\"no-violations\">");
sb.AppendLine("<div class=\"no-violations-content\">");
sb.AppendLine("<div class=\"no-violations-icon\">✅</div>");
sb.AppendLine("<h3 class=\"no-violations-title\">Проверка завершена</h3>");
sb.AppendLine("<p class=\"no-violations-description\">Нарушений правил SQL не обнаружено.</p>");
sb.AppendLine("</div>");
sb.AppendLine("</div>");
GenerateEndingHtml(sb, false, HtmlMinifier.CompressJson(jsonData));
return sb.ToString();
}
// Основной контейнер для отчета
sb.AppendLine("""
<!-- Основной контейнер отчёта -->
<main id="main-content">
<!-- Контейнер для всех отчётов -->
<div id="reports-container"></div>
</main>
<!-- Sticky Tabs -->
<div class="tabs-container">
<div class="tabs" id="tabs-list"></div>
</div>
""");
GenerateEndingHtml(sb, diagram != null, jsonData);
var html = HtmlMinifier.MinifyHtml(sb.ToString());
return html;
}
private ReportData PrepareReportData(List<IRuleViolation> violations, BpmnDiagram? diagram)
{
var reportData = new ReportData();
// Группировка по файлам
var groupedByFile = violations
.GroupBy(v => v.FileName)
.OrderBy(g => g.Key)
.ToList();
foreach (var fileGroup in groupedByFile)
{
var fileData = new FileReport
{
Name = fileGroup.Key,
};
// Группировка по severity
var severityGroups = fileGroup
.GroupBy(v => v.Severity)
.OrderByDescending(g => g.Key)
.ToList();
foreach (var violation in fileGroup.Select(t => t).OrderBy(v => v.Line).ThenBy(v => v.Column))
{
int ruleId;
List<string> args = new();
if (violation is RuleTemplateViolation templateRule)
{
args = templateRule.Params.Select(p => EscapeHtml(p)).ToList();
if (reportData.Rules.Any(t => t.Value.Name == templateRule.RuleName))
{
ruleId = reportData.Rules.First(t => t.Value.Name == templateRule.RuleName).Key;
}
else
{
ruleId = reportData.Rules.Count + 1;
reportData.Rules.Add(ruleId, new Rule
{
Name = templateRule.RuleName,
Template = templateRule.RuleTemplate
});
}
}
else
{
ruleId = reportData.Rules.Count + 1;
reportData.Rules.Add(ruleId, new Rule
{
Name = violation.RuleName,
Template = violation.Text,
});
}
var v = new Violation()
{
RuleId = ruleId,
Args = args,
Column = violation.Column,
Line = violation.Line,
};
if (violation.Severity == RuleViolationSeverity.Critical)
{
v.Index = fileData.Violations.Critical.Count + 1;
fileData.Violations.Critical.Add(v);
}
else if (violation.Severity == RuleViolationSeverity.Warning)
{
v.Index = fileData.Violations.Warning.Count + 1;
fileData.Violations.Warning.Add(v);
}
else if (violation.Severity == RuleViolationSeverity.Info)
{
v.Index = fileData.Violations.Info.Count + 1;
fileData.Violations.Info.Add(v);
}
}
reportData.Files.Add(fileData);
}
// Добавление диаграммы, если есть
if (diagram != null)
{
reportData.Diagram = new Diagram
{
Content = MermaidRenderer.ToMermaidContent(diagram),
HasDiagram = true
};
}
return reportData;
}
private void GenerateBeginningHtml(StringBuilder sb)
{
sb.AppendLine("""
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отчёт по SQLпроверкам</title>
<style>
""");
sb.AppendLine(LoadResource("HtmlFormatter_v3.css"));
sb.AppendLine("</style></head><body><main id=\"main-content\">");
}
private void GenerateEndingHtml(StringBuilder sb, bool hasDiagram, string jsonData)
{
// Вставка JSON данных
sb.AppendLine($"""
<script id="report-data" type="application/json">
{jsonData}
</script>
""");
sb.AppendLine("""
<script type="module">
""");
// Загружаем основной JS
sb.AppendLine(LoadResource("HtmlFormatter_v3.js"));
sb.AppendLine("""
</script>
</body>
</html>
""");
}
private static string LoadResource(string endsWith)
{
var assembly = Assembly.GetExecutingAssembly();
var name = assembly.GetManifestResourceNames()
.First(n => n.EndsWith(endsWith, StringComparison.OrdinalIgnoreCase));
using var stream = assembly.GetManifestResourceStream(name);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static string EscapeHtml(string text)
{
return System.Net.WebUtility.HtmlEncode(text);
}
// Классы для сериализации
private class ReportData
{
[JsonPropertyName("f")] // files
public List<FileReport> Files { get; set; } = new();
[JsonPropertyName("r")] // rules
public Dictionary<int, Rule> Rules { get; set; } = new();
[JsonPropertyName("d")] // diagram
public Diagram Diagram { get; set; } = new();
}
private class FileReport
{
[JsonPropertyName("n")] // name
public string Name { get; set; } = string.Empty;
[JsonPropertyName("v")] // violations
public Violations Violations { get; set; } = new();
}
private class Violations
{
[JsonPropertyName("c")] // critical
public List<Violation> Critical { get; set; } = new();
[JsonPropertyName("w")] // warning
public List<Violation> Warning { get; set; } = new();
[JsonPropertyName("i")] // info
public List<Violation> Info { get; set; } = new();
}
private class Violation
{
[JsonPropertyName("i")] // index
public int Index { get; set; }
[JsonPropertyName("l")] // line
public int Line { get; set; }
[JsonPropertyName("c")] // column
public int Column { get; set; }
[JsonPropertyName("r")] // ruleId
public int RuleId { get; set; }
[JsonPropertyName("a")] // args (optional)
public List<string>? Args { get; set; }
}
private class Rule
{
[JsonPropertyName("n")] // name
public string Name { get; set; } = string.Empty;
[JsonPropertyName("t")] // template
public string Template { get; set; } = string.Empty;
}
private class Diagram
{
[JsonPropertyName("c")] // content
public string Content { get; set; } = string.Empty;
[JsonPropertyName("h")] // hasDiagram
public bool HasDiagram { get; set; }
}
}

View File

@@ -0,0 +1,330 @@
using System.Text;
using System.Text.RegularExpressions;
public static class HtmlMinifier
{
public static string MinifyHtml(string html)
{
var htmlMinifier = new WebMarkupMin.Core.HtmlMinifier();
var result = htmlMinifier.Minify(html, generateStatistics: true);
return result.MinifiedContent;
if (string.IsNullOrEmpty(html))
return html;
// Вырезаем чувствительные теги
var placeholders = new Dictionary<string, string>();
html = ExtractTag(html, "pre", placeholders);
html = ExtractTag(html, "code", placeholders);
html = ExtractTag(html, "textarea", placeholders);
// Минификация CSS и JS
html = MinifyCssInHtml(html);
html = MinifyJavaScriptInHtml(html);
// Удаление HTML комментариев
html = Regex.Replace(html,
@"<!--(?!\[if|\s*\[endif).*?-->",
"",
RegexOptions.Singleline | RegexOptions.Compiled);
// Удаление лишних пробелов между тегами
html = Regex.Replace(html, @">\s+<", "><");
// Collapse whitespace
html = Regex.Replace(html, @"\s{2,}", " ");
// Возвращаем чувствительные блоки
foreach (var kv in placeholders)
html = html.Replace(kv.Key, kv.Value);
return html.Trim();
}
private static string ExtractTag(string html, string tag, Dictionary<string, string> dict)
{
return Regex.Replace(html,
$@"<{tag}[^>]*>[\s\S]*?<\/{tag}>",
m =>
{
var key = $"__PLACEHOLDER_{dict.Count}__";
dict[key] = m.Value;
return key;
},
RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
private static string MinifyCssInHtml(string html)
{
// Находим все теги <style>
var styleRegex = new Regex(@"<style[^>]*>([\s\S]*?)</style>",
RegexOptions.Compiled);
return styleRegex.Replace(html, match =>
{
var css = match.Groups[1].Value;
var minifiedCss = MinifyCss(css);
return $"<style>{minifiedCss}</style>";
});
}
public static string MinifyCss(string css)
{
if (string.IsNullOrEmpty(css))
return css;
// 1. Удаление комментариев
css = Regex.Replace(css, @"/\*[\s\S]*?\*/", "",
RegexOptions.Compiled);
// 2. Удаление лишних пробелов и переносов
css = Regex.Replace(css, @"\s+", " ",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*{\s*", "{",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*}\s*", "}",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*:\s*", ":",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*;\s*", ";",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*,\s*", ",",
RegexOptions.Compiled);
// 3. Удаление последней точки с запятой перед }
css = Regex.Replace(css, @";}", "}",
RegexOptions.Compiled);
// 4. Удаление пробелов вокруг селекторов
css = Regex.Replace(css, @"\s*>\s*", ">",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*\+\s*", "+",
RegexOptions.Compiled);
css = Regex.Replace(css, @"\s*~\s*", "~",
RegexOptions.Compiled);
// 5. Удаление пробелов в значениях
css = Regex.Replace(css, @"(\d)\s+(px|em|rem|%|pt|pc|in|cm|mm|ex|ch|vw|vh|vmin|vmax)",
"$1$2", RegexOptions.Compiled);
// 6. Удаление ведущих нулей
css = Regex.Replace(css, @"(?<=[ :\(,])0?\.(\d+)", ".$1",
RegexOptions.Compiled);
css = Regex.Replace(css, @"url\(\s*(.*?)\s*\)", "url($1)");
return css.Trim();
}
private static string MinifyJavaScriptInHtml(string html)
{
// Находим все теги <script>
var scriptRegex = new Regex(@"<script[^>]*>([\s\S]*?)</script>",
RegexOptions.Compiled);
return scriptRegex.Replace(html, match =>
{
var scriptContent = match.Groups[1].Value;
// Пропускаем script с type="application/json" или src
var tag = match.Value;
if (tag.Contains("type=\"application/json\"") ||
tag.Contains("src=") ||
scriptContent.Trim().Length == 0)
return match.Value;
var minifiedJs = MinifyJavaScript(scriptContent);
if (tag.Contains("type=\"module\""))
return $"<script type=\"module\">{minifiedJs}</script>";
else
return $"<script>{minifiedJs}</script>";
});
}
public static string MinifyJavaScript(string js)
{
if (string.IsNullOrEmpty(js))
return js;
var sb = new StringBuilder(js.Length);
int i = 0;
int len = js.Length;
bool inSingle = false;
bool inDouble = false;
bool inTemplate = false;
bool inLineComment = false;
bool inBlockComment = false;
while (i < len)
{
char c = js[i];
char next = i + 1 < len ? js[i + 1] : '\0';
// -----------------------------
// LINE COMMENT //
// -----------------------------
if (inLineComment)
{
if (c == '\n' || c == '\r')
{
inLineComment = false;
sb.Append(' ');
}
i++;
continue;
}
// -----------------------------
// BLOCK COMMENT /* ... */ //
// -----------------------------
if (inBlockComment)
{
if (c == '*' && next == '/')
{
inBlockComment = false;
i += 2;
}
else
{
i++;
}
continue;
}
// -----------------------------
// STRING: '...' //
// -----------------------------
if (inSingle)
{
sb.Append(c);
if (c == '\\')
{
if (i + 1 < len) sb.Append(js[i + 1]);
i += 2;
continue;
}
if (c == '\'') inSingle = false;
i++;
continue;
}
// -----------------------------
// STRING: "..." //
// -----------------------------
if (inDouble)
{
sb.Append(c);
if (c == '\\')
{
if (i + 1 < len) sb.Append(js[i + 1]);
i += 2;
continue;
}
if (c == '"') inDouble = false;
i++;
continue;
}
// -----------------------------
// TEMPLATE: `...` //
// -----------------------------
if (inTemplate)
{
sb.Append(c);
if (c == '\\')
{
if (i + 1 < len) sb.Append(js[i + 1]);
i += 2;
continue;
}
if (c == '`') inTemplate = false;
i++;
continue;
}
// -----------------------------
// NORMAL CODE //
// -----------------------------
// Start of line comment
if (c == '/' && next == '/')
{
inLineComment = true;
i += 2;
continue;
}
// Start of block comment
if (c == '/' && next == '*')
{
inBlockComment = true;
i += 2;
continue;
}
// Start of strings
if (c == '\'')
{
inSingle = true;
sb.Append(c);
i++;
continue;
}
if (c == '"')
{
inDouble = true;
sb.Append(c);
i++;
continue;
}
if (c == '`')
{
inTemplate = true;
sb.Append(c);
i++;
continue;
}
// Collapse whitespace in code
if (char.IsWhiteSpace(c))
{
sb.Append(' ');
i++;
continue;
}
sb.Append(c);
i++;
}
// -----------------------------
// FINAL WHITESPACE MINIFICATION
// -----------------------------
var result = sb.ToString();
// Удаляем повторяющиеся пробелы
result = Regex.Replace(result, @"\s+", " ");
// Убираем пробелы вокруг операторов
result = Regex.Replace(result, @"\s*([=+\-*/%&|^<>!?:,;{}()\[\]])\s*", "$1");
return result.Trim();
}
public static string CompressJson(string json)
{
if (string.IsNullOrEmpty(json))
return json;
// Удаление пробелов и переносов из JSON
json = Regex.Replace(json, @"(""[^""\\]*(?:\\.[^""\\]*)*"")|\s+",
match => match.Groups[1].Success ? match.Groups[1].Value : "",
RegexOptions.Compiled);
return json;
}
}

View File

@@ -0,0 +1,10 @@
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
namespace SQLLinter.Infrastructure.Reporters;
public interface IReportFormatter
{
string Format(List<IRuleViolation> violations);
string Format(List<IRuleViolation> violations, BpmnDiagram? diagram);
}

View File

@@ -1,12 +1,13 @@
using System.Text;
using SQLLinter.Common;
using SQLLinter.Infrastructure.Diagram;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
public class MarkdownFileReporter : FileReporter
public class MarkdownReportFormatter : IReportFormatter
{
public override string GetContent()
public string Format(List<IRuleViolation> violations)
{
var violations = Violations;
if (violations.Count == 0)
{
return "_Нет нарушений_";
@@ -50,4 +51,9 @@ public class MarkdownFileReporter : FileReporter
return sb.ToString();
}
public string Format(List<IRuleViolation> violations, BpmnDiagram diagram)
{
throw new NotImplementedException();
}
}

View File

@@ -7,15 +7,22 @@ namespace SQLLinter.Infrastructure.Reporters;
public class Reporter : IReporter
{
private readonly List<string> _log = new();
private readonly bool _useLogging;
public int? FixedCount { get; set; }
private readonly ConcurrentBag<IRuleViolation> ruleViolations = new();
public List<IRuleViolation> Violations => ruleViolations.ToList();
public Reporter(bool useLogging = false)
{
_useLogging = useLogging;
}
public virtual void Report(string message)
{
_log.Add(message);
if (_useLogging) _log.Add(message);
}
public List<string> GetLog() => _log;
@@ -34,8 +41,18 @@ public class Reporter : IReporter
Report(violation.ToString());
}
public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string violationText)
public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, BaseRuleVisitor.ExtractedBlock? snippet, params string[] param)
{
ReportViolation(new RuleViolation(fileName, ruleName, violationText, line, column, severity));
ReportViolation(new RuleTemplateViolation()
{
FileName = fileName,
RuleName = ruleName,
RuleTemplate = template,
Line = line,
Column = column,
Severity = severity,
Params = param.ToList(),
Snippet = snippet,
});
}
}

View File

@@ -0,0 +1,533 @@
/* ---------------------------------------------------------
FLUENT UI 2 — BASE VARIABLES
--------------------------------------------------------- */
:root {
--color-primary: #0f6cbd;
--color-primary-hover: #115ea3;
--color-primary-light: #d0e7ff;
--color-bg: #ffffff;
--color-bg-alt: #f5f5f5;
--color-bg-neutral: #f3f2f1;
--color-border: #d1d1d1;
--color-border-strong: #b5b5b5;
--color-text: #1a1a1a;
--color-text-secondary: #5e5e5e;
--color-text-tertiary: #8a8a8a;
--color-critical: #d13438;
--color-critical-bg: #f8d7da;
--color-warning: #ffaa44;
--color-warning-bg: #fff4ce;
--color-info: #0f6cbd;
--color-info-bg: #d0e7ff;
--radius-s: 4px;
--radius-m: 6px;
--radius-l: 8px;
--space-xs: 4px;
--space-s: 8px;
--space-m: 12px;
--space-l: 16px;
--space-xl: 20px;
--space-xxl: 28px;
--font-s: 12px;
--font-m: 14px;
--font-l: 16px;
--font-xl: 18px;
--z-tabs: 1000;
--z-header: 900;
}
/* ---------------------------------------------------------
RESET
--------------------------------------------------------- */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", sans-serif;
background: var(--color-bg-neutral);
color: var(--color-text);
line-height: 1.5;
}
/* ---------------------------------------------------------
FILE REPORT WRAPPER
--------------------------------------------------------- */
.file-report {
display: none;
padding-bottom: 120px;
}
.file-report.active {
display: block;
}
/* ---------------------------------------------------------
FILE TITLE (STICKY)
--------------------------------------------------------- */
.file-title-container {
position: sticky;
top: 0;
z-index: var(--z-header);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
padding: var(--space-l) var(--space-xl);
}
.file-title {
font-size: var(--font-xl);
font-weight: 600;
color: var(--color-text);
}
/* ---------------------------------------------------------
SEVERITY SECTIONS (FLUENT UI 2 STYLE)
--------------------------------------------------------- */
.severity-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-l);
padding: var(--space-xl);
margin: var(--space-xl) var(--space-xl);
}
.severity-section.critical {
border-left: 4px solid var(--color-critical);
}
.severity-section.warning {
border-left: 4px solid var(--color-warning);
}
.severity-section.info {
border-left: 4px solid var(--color-info);
}
.severity-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-l);
padding-bottom: var(--space-s);
border-bottom: 1px solid var(--color-border);
}
.severity-title h2 {
font-size: var(--font-l);
font-weight: 600;
}
.severity-count {
background: var(--color-bg-alt);
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: var(--font-s);
color: var(--color-text-secondary);
}
/* ---------------------------------------------------------
GRID TABLE (REPLACES <table>)
--------------------------------------------------------- */
.grid-table {
display: grid;
gap: var(--space-xs);
}
.grid-header,
.grid-row {
display: grid;
grid-template-columns: 40px 60px 60px 250px 1fr;
gap: var(--space-xs);
padding: var(--space-s);
border-radius: var(--radius-s);
}
.grid-header {
background: var(--color-bg-alt);
font-weight: 600;
color: var(--color-text-secondary);
font-size: var(--font-s);
}
.grid-row {
background: var(--color-bg);
border: 1px solid var(--color-border);
font-size: var(--font-m);
}
.grid-row:hover {
background: var(--color-bg-alt);
}
/* ---------------------------------------------------------
SUMMARY SECTION
--------------------------------------------------------- */
.summary-section {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-l);
padding: var(--space-xl);
margin: var(--space-xl);
}
.summary-header h2 {
font-size: var(--font-l);
margin-bottom: var(--space-m);
}
.summary-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-m);
margin-bottom: var(--space-l);
}
.stat-card {
background: var(--color-bg-alt);
border-radius: var(--radius-m);
padding: var(--space-m);
text-align: center;
}
.stat-value {
font-size: var(--font-xl);
font-weight: 600;
}
.stat-label {
font-size: var(--font-s);
color: var(--color-text-secondary);
}
/* Progress bar */
.progress-bar {
display: flex;
height: 8px;
border-radius: var(--radius-s);
overflow: hidden;
background: var(--color-border);
}
.progress-fill.critical {
background: var(--color-critical);
}
.progress-fill.warning {
background: var(--color-warning);
}
.progress-fill.info {
background: var(--color-info);
}
/* ---------------------------------------------------------
FILE CARDS IN SUMMARY
--------------------------------------------------------- */
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: var(--space-m);
}
.file-card {
background: var(--color-bg-alt);
border-radius: var(--radius-m);
padding: var(--space-m);
}
.file-name-small {
font-weight: 600;
font-size: var(--font-m);
}
.file-card-bottom {
display: flex;
justify-content: space-between;
margin-top: var(--space-s);
}
.file-violations {
display: flex;
gap: var(--space-xs);
}
.violation-badge {
padding: var(--space-xs) var(--space-s);
border-radius: var(--radius-s);
font-size: var(--font-s);
color: var(--color-text);
}
.violation-badge.critical {
background: var(--color-critical-bg);
color: var(--color-critical);
}
.violation-badge.warning {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.violation-badge.info {
background: var(--color-info-bg);
color: var(--color-info);
}
/* ---------------------------------------------------------
TABS (STICKY BOTTOM)
--------------------------------------------------------- */
.tabs-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg);
border-top: 1px solid var(--color-border);
padding: var(--space-s) var(--space-l);
z-index: var(--z-tabs);
}
.tabs {
display: flex;
gap: var(--space-s);
overflow-x: auto;
padding-bottom: var(--space-xs);
}
.tab {
background: var(--color-bg-alt);
border: 1px solid var(--color-border);
border-radius: var(--radius-m);
padding: var(--space-s) var(--space-m);
cursor: pointer;
font-size: var(--font-m);
font-weight: 500;
color: var(--color-text-secondary);
min-width: 140px;
text-align: left;
}
.tab:hover {
background: var(--color-bg-neutral);
}
.tab.active {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
.tab-inner {
display: flex;
justify-content: space-between;
gap: var(--space-s);
}
.tab-counters {
display: flex;
flex-direction: column;
gap: 2px;
}
.tab-counter {
font-size: var(--font-s);
padding: 2px 4px;
border-radius: var(--radius-s);
text-align: center;
}
.tab-counter.critical {
background: var(--color-critical-bg);
color: var(--color-critical);
}
.tab-counter.warning {
background: var(--color-warning-bg);
color: var(--color-warning);
}
.tab-counter.info {
background: var(--color-info-bg);
color: var(--color-info);
}
.tab-counter.empty {
opacity: 0.3;
}
/* ---------------------------------------------------------
FLUENT UI 2 — DARK THEME
--------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1f1f1f;
--color-bg-alt: #2a2a2a;
--color-bg-neutral: #2d2d2d;
--color-text: #f3f3f3;
--color-text-secondary: #c8c8c8;
--color-text-tertiary: #9a9a9a;
--color-border: #3a3a3a;
--color-border-strong: #4a4a4a;
--color-primary: #3aa0f3;
--color-primary-hover: #2899f5;
--color-primary-light: #0f2b4d;
--color-critical-bg: #4c191b;
--color-warning-bg: #4c3b1a;
--color-info-bg: #0f2b4d;
}
.file-title-container {
background: var(--color-bg);
}
.severity-section {
background: var(--color-bg);
border-color: var(--color-border);
}
.grid-row {
background: var(--color-bg-alt);
border-color: var(--color-border);
}
.grid-row:hover {
background: var(--color-bg-neutral);
}
.summary-section {
background: var(--color-bg);
border-color: var(--color-border);
}
.file-card {
background: var(--color-bg-alt);
}
.tabs-container {
background: var(--color-bg);
border-color: var(--color-border);
}
.tab {
background: var(--color-bg-alt);
border-color: var(--color-border);
color: var(--color-text-secondary);
}
.tab.active {
background: var(--color-primary-light);
border-color: var(--color-primary);
color: var(--color-primary);
}
}
/* ---------------------------------------------------------
TAB TRANSITIONS
--------------------------------------------------------- */
.file-report {
opacity: 0;
transform: translateY(6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.file-report.active {
opacity: 1;
transform: translateY(0);
}
/* ---------------------------------------------------------
TAB HOVER & ACTIVE ANIMATION
--------------------------------------------------------- */
.tab {
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.tab:hover {
transform: translateY(-1px);
}
.tab.active {
transform: translateY(-2px);
}
.grid-row {
transition: background-color 0.15s ease;
}
.file-card {
transition: background-color 0.2s ease, transform 0.2s ease;
}
.file-card:hover {
transform: translateY(-2px);
background: var(--color-bg-neutral);
}
/* ---------------------------------------------------------
FIX TAB HEIGHT — MAX 2 LINES
--------------------------------------------------------- */
/* Уменьшаем вертикальные отступы */
.tab {
padding: 6px 10px;
min-height: unset;
height: auto;
line-height: 1.25;
}
/* Контейнер таба — выравниваем по верхнему краю */
.tab-inner {
align-items: flex-start;
gap: 6px;
}
/* Текст таба — максимум 2 строки */
.tab-text {
font-size: 13px;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2; /* максимум 2 строки */
-webkit-box-orient: vertical;
overflow: hidden;
max-height: calc(1.25em * 2); /* ровно 2 строки */
white-space: normal;
}
/* Счётчики — компактнее */
.tab-counters {
gap: 2px;
min-height: auto;
}
/* Сами счётчики — меньше высота */
.tab-counter {
min-width: 14px;
height: 14px;
font-size: 10px;
padding: 0 3px;
line-height: 14px;
}
/* На мобильных — ещё компактнее */
@media (max-width: 480px) {
.tab {
padding: 4px 8px;
}
.tab-text {
font-size: 12px;
max-height: calc(1.2em * 2);
-webkit-line-clamp: 2;
}
.tab-counter {
min-width: 12px;
height: 12px;
font-size: 9px;
padding: 0 2px;
}
}

View File

@@ -0,0 +1,472 @@
document.addEventListener("DOMContentLoaded", function () {
const dataEl = document.getElementById("report-data");
if (!dataEl) return console.error("report-data not found");
const data = JSON.parse(dataEl.textContent || "{}");
const files = data.files || data.f || [];
const rules = data.rules || data.r || {};
const tabsList = document.getElementById("tabs-list");
const reportsContainer = document.getElementById("reports-container");
if (!tabsList || !reportsContainer) {
console.error("Missing tabs-list or reports-container");
return;
}
/* ---------------------------------------------------------
UTILS
--------------------------------------------------------- */
function escapeHtml(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function countViolations(v) {
return {
c: v?.c?.length || 0,
w: v?.w?.length || 0,
i: v?.i?.length || 0
};
}
const rendered = new Set(); // ленивый рендер
/* ---------------------------------------------------------
RENDER TABS
--------------------------------------------------------- */
function renderTabs() {
const frag = document.createDocumentFragment();
files.forEach((file, index) => {
const v = countViolations(file.v);
const btn = document.createElement("button");
btn.className = "tab";
btn.dataset.target = `file_${index}`;
btn.dataset.index = index;
const inner = document.createElement("div");
inner.className = "tab-inner";
const text = document.createElement("span");
text.className = "tab-text";
text.title = file.n;
text.textContent = file.n;
const counters = document.createElement("div");
counters.className = "tab-counters";
const c1 = document.createElement("span");
c1.className = "tab-counter critical" + (v.c ? "" : " empty");
c1.textContent = v.c || "";
const c2 = document.createElement("span");
c2.className = "tab-counter warning" + (v.w ? "" : " empty");
c2.textContent = v.w || "";
const c3 = document.createElement("span");
c3.className = "tab-counter info" + (v.i ? "" : " empty");
c3.textContent = v.i || "";
counters.append(c1, c2, c3);
inner.append(text, counters);
btn.append(inner);
frag.append(btn);
});
// Summary tab
const summaryBtn = document.createElement("button");
summaryBtn.className = "tab";
summaryBtn.dataset.target = "summary_report";
const inner = document.createElement("div");
inner.className = "tab-inner";
const text = document.createElement("span");
text.className = "tab-text";
text.textContent = "Summary";
inner.append(text);
summaryBtn.append(inner);
frag.append(summaryBtn);
tabsList.innerHTML = "";
tabsList.append(frag);
}
/* ---------------------------------------------------------
GRID ROW CREATOR (VIOLATIONS)
--------------------------------------------------------- */
function createGridRow(v, rule) {
const row = document.createElement("div");
row.className = "grid-row";
const cIndex = document.createElement("div");
cIndex.textContent = v.i;
const cLine = document.createElement("div");
cLine.textContent = v.l;
const cCol = document.createElement("div");
cCol.textContent = v.c;
const cRule = document.createElement("div");
cRule.textContent = rule.n;
let text = rule.t || "";
const args = v.a || v.args;
if (Array.isArray(args)) {
for (let i = 0; i < args.length; i++) {
text = text.replace(`{${i}}`, args[i]);
}
}
const cDesc = document.createElement("div");
cDesc.textContent = text;
row.append(cIndex, cLine, cCol, cRule, cDesc);
return row;
}
/* ---------------------------------------------------------
FILE REPORT
--------------------------------------------------------- */
function renderFileReport(index) {
const file = files[index];
const v = file.v || {};
const root = document.createElement("div");
// Title
const titleWrap = document.createElement("div");
titleWrap.className = "file-title-container";
const title = document.createElement("div");
title.className = "file-title";
const name = document.createElement("span");
name.className = "file-name";
name.textContent = file.n;
title.append(name);
titleWrap.append(title);
root.append(titleWrap);
// Sections
function addSection(label, list, cls) {
if (!Array.isArray(list) || list.length === 0) return;
const section = document.createElement("div");
section.className = `severity-section ${cls}`;
const header = document.createElement("div");
header.className = "severity-header";
const hTitle = document.createElement("div");
hTitle.className = "severity-title";
const h2 = document.createElement("h2");
h2.textContent = label;
const count = document.createElement("span");
count.className = "severity-count";
count.textContent = list.length;
hTitle.append(h2, count);
header.append(hTitle);
section.append(header);
const grid = document.createElement("div");
grid.className = "grid-table";
// header row
const headerRow = document.createElement("div");
headerRow.className = "grid-header";
["#", "Строка", "Колонка", "Правило", "Описание"].forEach(t => {
const cell = document.createElement("div");
cell.textContent = t;
headerRow.append(cell);
});
grid.append(headerRow);
// rows
for (let i = 0; i < list.length; i++) {
const vItem = list[i];
const rule = rules[vItem.r] || { n: "Unknown", t: "Описание отсутствует" };
grid.append(createGridRow(vItem, rule));
}
section.append(grid);
root.append(section);
}
addSection("Critical", v.c, "critical");
addSection("Warning", v.w, "warning");
addSection("Info", v.i, "info");
return root;
}
/* ---------------------------------------------------------
SUMMARY
--------------------------------------------------------- */
function renderSummary() {
let totalC = 0, totalW = 0, totalI = 0;
for (let i = 0; i < files.length; i++) {
const v = countViolations(files[i].v);
totalC += v.c;
totalW += v.w;
totalI += v.i;
}
const total = totalC + totalW + totalI || 1;
const root = document.createElement("div");
// Title
const titleWrap = document.createElement("div");
titleWrap.className = "file-title-container";
const title = document.createElement("div");
title.className = "file-title";
const name = document.createElement("span");
name.className = "file-name";
name.textContent = "Сводный отчет";
title.append(name);
titleWrap.append(title);
root.append(titleWrap);
// Stats
const section = document.createElement("div");
section.className = "summary-section";
const header = document.createElement("div");
header.className = "summary-header";
const hTitle = document.createElement("div");
hTitle.className = "summary-title";
const h2 = document.createElement("h2");
h2.textContent = "Общая статистика";
hTitle.append(h2);
header.append(hTitle);
section.append(header);
const grid = document.createElement("div");
grid.className = "summary-stats-grid";
function statCard(cls, value, label) {
const card = document.createElement("div");
card.className = `stat-card ${cls}`;
const v = document.createElement("div");
v.className = "stat-value";
v.textContent = value;
const l = document.createElement("div");
l.className = "stat-label";
l.textContent = label;
card.append(v, l);
return card;
}
grid.append(
statCard("success", files.length, "Всего файлов"),
statCard("critical", totalC, "Critical нарушений"),
statCard("warning", totalW, "Warning нарушений"),
statCard("info", totalI, "Info нарушений")
);
section.append(grid);
// Progress
const dist = document.createElement("div");
dist.className = "violation-distribution";
const bar = document.createElement("div");
bar.className = "progress-bar";
const pc = (totalC / total) * 100;
const pw = (totalW / total) * 100;
const pi = (totalI / total) * 100;
const f1 = document.createElement("div");
f1.className = "progress-fill critical";
f1.style.width = pc + "%";
const f2 = document.createElement("div");
f2.className = "progress-fill warning";
f2.style.width = pw + "%";
const f3 = document.createElement("div");
f3.className = "progress-fill info";
f3.style.width = pi + "%";
bar.append(f1, f2, f3);
dist.append(bar);
section.append(dist);
root.append(section);
// Files overview
const section2 = document.createElement("div");
section2.className = "summary-section files-overview";
const header2 = document.createElement("div");
header2.className = "summary-header";
const hTitle2 = document.createElement("div");
hTitle2.className = "summary-title";
const h22 = document.createElement("h2");
h22.textContent = "Статистика файлов";
const count = document.createElement("span");
count.className = "severity-count";
count.textContent = files.length;
hTitle2.append(h22, count);
header2.append(hTitle2);
section2.append(header2);
const filesGrid = document.createElement("div");
filesGrid.className = "files-grid";
for (let i = 0; i < files.length; i++) {
const f = files[i];
const v = countViolations(f.v);
const t = v.c + v.w + v.i || 1;
const card = document.createElement("div");
card.className = "file-card";
const header = document.createElement("div");
header.className = "file-card-header";
const name = document.createElement("span");
name.className = "file-name-small";
name.textContent = f.n;
header.append(name);
card.append(header);
const bar = document.createElement("div");
bar.className = "progress-bar small";
const fc = document.createElement("div");
fc.className = "progress-fill critical";
fc.style.width = (v.c / t * 100) + "%";
const fw = document.createElement("div");
fw.className = "progress-fill warning";
fw.style.width = (v.w / t * 100) + "%";
const fi = document.createElement("div");
fi.className = "progress-fill info";
fi.style.width = (v.i / t * 100) + "%";
bar.append(fc, fw, fi);
card.append(bar);
const bottom = document.createElement("div");
bottom.className = "file-card-bottom";
const total = document.createElement("span");
total.className = "file-total";
total.textContent = "Total: " + (v.c + v.w + v.i);
const badges = document.createElement("div");
badges.className = "file-violations";
function badge(cls, val) {
const b = document.createElement("span");
b.className = `violation-badge ${cls}`;
b.textContent = val;
return b;
}
badges.append(
badge("critical", v.c),
badge("warning", v.w),
badge("info", v.i)
);
bottom.append(total, badges);
card.append(bottom);
filesGrid.append(card);
}
section2.append(filesGrid);
root.append(section2);
return root;
}
/* ---------------------------------------------------------
TAB HANDLER
--------------------------------------------------------- */
tabsList.addEventListener("click", function (e) {
const tab = e.target.closest(".tab");
if (!tab) return;
const targetId = tab.dataset.target;
// activate tab
tabsList.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
// hide all reports
reportsContainer.innerHTML = "";
// lazy render
let content;
if (!rendered.has(targetId)) {
if (targetId === "summary_report") {
content = renderSummary();
} else {
const index = Number(tab.dataset.index);
content = renderFileReport(index);
}
rendered.add(targetId);
} else {
// already rendered → but we removed DOM → re-render
if (targetId === "summary_report") {
content = renderSummary();
} else {
const index = Number(tab.dataset.index);
content = renderFileReport(index);
}
}
reportsContainer.append(content);
window.scrollTo({ top: 0, behavior: "smooth" });
});
/* ---------------------------------------------------------
INIT
--------------------------------------------------------- */
renderTabs();
const first = tabsList.querySelector(".tab");
if (first) first.click();
});

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,7 @@ public class ConditionalBeginEndRule : BaseRuleVisitor, IRule
{
if (node.ThenStatement is not BeginEndBlockStatement)
{
AddViolation(Name, Text, GetLineNumber(node), GetColumnNumber(node));
AddViolation(node);
}
if (node.ElseStatement != null && node.ElseStatement is not BeginEndBlockStatement && node.ElseStatement is not IfStatement)

View File

@@ -11,7 +11,7 @@ public class IndexHintRule : BaseRuleVisitor
{
if (node.HintKind == TableHintKind.Index)
{
AddViolation(Name, Text, GetLineNumber(node), GetColumnNumber(node));
AddViolation(node);
}
}
}

View File

@@ -18,7 +18,7 @@ public class InsertValuesInsteadOfSelectRule : BaseRuleVisitor
// Если в SELECT нет таблиц (т.е. просто SELECT 1,2,3)
if (query.FromClause == null || query.FromClause.TableReferences.Count == 0)
{
AddViolation(Name, Text, GetLineNumber(node), GetColumnNumber(node));
AddViolation(node);
}
}
}

View File

@@ -33,7 +33,7 @@ public class KeywordCapitalizationRule : BaseRuleVisitor, IRule
var tabsOnLine = ColumnNumberCalculator.CountTabsBeforeToken(token.Line, index, node.ScriptTokenStream);
var column = ColumnNumberCalculator.GetColumnNumberBeforeToken(tabsOnLine, token);
AddViolation(Name, GetText(token.Text), GetLineNumber(token), column + dynamicSQLAdjustment);
AddViolation(Name, Text, GetLineNumber(token), column + dynamicSQLAdjustment, token.Text);
}
}

View File

@@ -34,7 +34,7 @@ public class MultiTableAliasRule : BaseRuleVisitor, IRule
tableName = SQLHelpers.ObjectGetFullName(namedTable.SchemaObject);
}
AddViolation(Name, GetText(tableName), GetLineNumber(childNode), column + dynamicSqlAdjustment);
AddViolation(Name, Text, GetLineNumber(childNode), column + dynamicSqlAdjustment, tableName);
}
var childTableJoinVisitor = new ChildTableJoinVisitor();

View File

@@ -5,7 +5,7 @@ namespace SQLLinter.Infrastructure.Rules;
public class NestedSubqueryDepthRule : BaseRuleVisitor
{
public override string Text => "Слишком глубокие подзапросы (>" + _maxDepth + "} уровней): {0}";
public override string Text => "Слишком глубокие подзапросы (>" + _maxDepth + " уровней): {0}";
private int _depth = 0;
private const int _maxDepth = 3;

View File

@@ -0,0 +1,71 @@
using Microsoft.SqlServer.TransactSql.ScriptDom;
namespace SQLLinter.Infrastructure.Rules;
public static class ParentMapBuilder
{
public static Dictionary<TSqlFragment, TSqlFragment?> Build(TSqlFragment root)
{
var map = new Dictionary<TSqlFragment, TSqlFragment?>();
Traverse(root, null, map);
return map;
}
private static void Traverse(
TSqlFragment node,
TSqlFragment? parent,
Dictionary<TSqlFragment, TSqlFragment?> map)
{
if (!map.ContainsKey(node))
map[node] = parent;
foreach (var child in node.GetChildren())
{
Traverse(child, node, map);
}
}
}
public static class ScriptDomExtensions
{
public static IEnumerable<TSqlFragment> GetChildren(this TSqlFragment node)
{
var collector = new DirectChildrenCollector(node);
node.Accept(collector);
return collector.Children;
}
private class DirectChildrenCollector : TSqlFragmentVisitor
{
private readonly TSqlFragment _root;
private bool _isRootVisited = false;
public List<TSqlFragment> Children { get; } = new();
public DirectChildrenCollector(TSqlFragment root)
{
_root = root;
}
public override void Visit(TSqlFragment fragment)
{
if (!_isRootVisited)
{
// Первый вызов — это сам root
_isRootVisited = true;
}
else
{
// Все остальные вызовы — это прямые дети root
Children.Add(fragment);
// ВАЖНО: не спускаемся глубже
return;
}
// Продолжаем обход только для root
base.Visit(fragment);
}
}
}

View File

@@ -79,7 +79,7 @@ public class ProcedureLoggingReturnRule : BaseRuleVisitor
if ((hasDebugLog || hasLabelFinish) || hasReturn)
{
returnPositions.ForEach(t => AddViolation(Name, GetText(name), t.Line, t.Column));
returnPositions.ForEach(t => AddViolation(Name, Text, t.Line, t.Column, name));
}
}

View File

@@ -4,46 +4,26 @@ namespace SQLLinter.Infrastructure.Rules.RuleViolations
{
public class RuleViolation : IRuleViolation
{
public RuleViolation(string fileName, string ruleName, string text, int startLine, int startColumn, RuleViolationSeverity severity)
{
FileName = fileName;
RuleName = ruleName;
Text = text;
Line = startLine;
Column = startColumn;
Severity = severity;
}
required public string FileName { get; init; }
public RuleViolation(string fileName, string ruleName, int startLine, int startColumn)
{
FileName = fileName;
RuleName = ruleName;
Line = startLine;
Column = startColumn;
}
required public int Column { get; set; }
public RuleViolation(string ruleName, int startLine, int startColumn)
{
RuleName = ruleName;
Line = startLine;
Column = startColumn;
}
required public int Line { get; set; }
public int Column { get; set; }
required public string RuleName { get; init; }
public string FileName { get; set; }
required public RuleViolationSeverity Severity { get; init; }
public int Line { get; set; }
virtual public string Text { get; set; }
public BaseRuleVisitor.ExtractedBlock? Snippet { get; set; }
}
public string RuleName { get; set; }
public class RuleTemplateViolation : RuleViolation
{
override public string Text => string.Format(RuleTemplate, Params.ToArray());
public RuleViolationSeverity Severity { get; set; }
required public string RuleTemplate { get; init; }
public string Text { get; set; }
public override string ToString()
{
return $@"{Severity.ToString().ToUpper()}: L{Line} C{Column} {FileName} ""{Text}""";
}
public List<string> Params { get; set; } = new();
}
}

View File

@@ -1,6 +1,7 @@
using SQLLinter.Common;
using SQLLinter.Common.Helpers;
using SQLLinter.Core.Interfaces;
using SQLLinter.Infrastructure.Interfaces;
using SQLLinter.Infrastructure.Parser;
using SQLLinter.Infrastructure.Plugins;
@@ -15,20 +16,25 @@ public class Linter
private ISqlFileProcessor _fileProcessor;
public Linter(IConfig config, IReporter reporter)
: this(config
, reporter
, new FragmentBuilder(reporter, config.CompatibilityLevel)
, new SqlStreamReaderBuilder()
)
{ }
public Linter(IConfig config, IReporter reporter, IFragmentBuilder fragmentBuilder, ISqlStreamReaderBuilder sqlStreamReaderBuilder)
{
this._config = config;
this._reporter = reporter;
this._pluginHandler = new PluginHandler(reporter, config);
_reporter.Report($"Загрузка SQL Linter...");
var fragmentBuilder = new FragmentBuilder(reporter, _config.CompatibilityLevel);
_pluginHandler = new PluginHandler(_reporter, _config);
var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter);
var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter, sqlStreamReaderBuilder);
_fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler, reporter);
_fileProcessor = new SqlFileProcessor(config, ruleVisitor, _pluginHandler);
_reporter.Report($"SQL Linter загружен...");
}

View File

@@ -19,7 +19,23 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="170.128.0" />
<None Remove="Infrastructure\Reporters\Static\HtmlFormatter.css" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v2\HtmlFormatter_v2.js" />
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v2\HtmlFormatter_v2.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v3\HtmlFormatter_v3.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Formatters\Html\v3\HtmlFormatter_v3.js" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter - Копировать.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatterOld.js" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.css" />
<EmbeddedResource Include="Infrastructure\Reporters\Static\HtmlFormatter.js" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="170.147.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
</ItemGroup>
</Project>