9 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
33 changed files with 9346 additions and 2650 deletions

View File

@@ -2,6 +2,7 @@
using SQLLinter.Infrastructure.Diagram;
using SQLLinter.Infrastructure.Parser;
using SQLLinter.Infrastructure.Reporters;
using F = SQLLinter.Infrastructure.Reporters.Formatters.Html;
namespace SQLLinter.CLI
{
@@ -42,7 +43,8 @@ namespace SQLLinter.CLI
["UpdateWhere"] = Common.RuleViolationSeverity.Critical,
["UpperLower"] = Common.RuleViolationSeverity.Critical,
["SetVariable"] = Common.RuleViolationSeverity.Critical,
}
},
GenerateDetails = true,
};
//var linter = new Linter(con, rep);
@@ -56,16 +58,35 @@ namespace SQLLinter.CLI
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");
var formatter = new HtmlReportFormatter_v1();
IReportFormatter formatter = new F.v3.HtmlReportFormatter();
var content = formatter.Format(rep.Violations, bpmn);
File.WriteAllText(@"C:\Users\frost\Downloads\Telegram Desktop\test.html", content);
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); }
@@ -57,16 +67,324 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
protected void AddViolation(string RuleName, string Template, int Line, int Column, params string[] param)
{
_violations.Add(new(RuleName, Template, Line, Column, param));
_violations.Add(new(RuleName, Template, Line, Column, null, param));
}
protected void AddViolation(TSqlFragment node, params string[] param)
{
_violations.Add(new(this.Name, this.Text, GetLineNumber(node), GetColumnNumber(node), param));
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 template, params string[] param);
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 Template, int Line, int Column, string[] Params);
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

@@ -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

@@ -25,7 +25,6 @@ public static class BpmnBuilder
{
var visitor = new BpmnVisitor(diagram);
fragment.Accept(visitor);
visitor.Diagram.AddMissingProcessEdges();
return visitor.Diagram;
}

View File

@@ -58,6 +58,7 @@ public class SqlDiagramProcessor : ISqlDiagramProcessor
}
BpmnBuilder.Build(fragment, _bpmnDiagram);
_bpmnDiagram.AddMissingProcessEdges();
}
private Stream GetFileContents(string filePath)

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,14 +7,16 @@ namespace SQLLinter.Infrastructure.Parser;
public class SqlFileProcessor : ISqlFileProcessor
{
private readonly IRuleVisitor ruleVisitor;
private readonly IConfig _config;
private readonly IRuleExceptionFinder ruleExceptionFinder;
public SqlFileProcessor(
public SqlFileProcessor(IConfig config,
IRuleVisitor ruleVisitor,
IPluginHandler pluginHandler
)
{
this._config = config;
this.ruleVisitor = ruleVisitor;
ruleExceptionFinder = new RuleExceptionFinder(pluginHandler.RuleWithNames);
}
@@ -93,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.Template, t.Params));
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)

View File

@@ -2,9 +2,9 @@
using SQLLinter.Infrastructure.Diagram;
using System.Text;
namespace SQLLinter.Infrastructure.Reporters;
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v1;
public class HtmlReportFormatter_v1 : IReportFormatter
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);

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

@@ -7,9 +7,9 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SQLLinter.Infrastructure.Reporters;
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v3;
public class HtmlReportFormatter_v2 : IReportFormatter
public class HtmlReportFormatter : IReportFormatter
{
public string Format(List<IRuleViolation> violations)
=> Format(violations, null);
@@ -44,10 +44,19 @@ public class HtmlReportFormatter_v2 : IReportFormatter
// Основной контейнер для отчета
sb.AppendLine("""
<div id="reports-container"></div>
<div id="tabs-container" class="tabs-container">
<!-- Основной контейнер отчёта -->
<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);
@@ -168,7 +177,7 @@ public class HtmlReportFormatter_v2 : IReportFormatter
<style>
""");
sb.AppendLine(LoadResource("HtmlFormatter.css"));
sb.AppendLine(LoadResource("HtmlFormatter_v3.css"));
sb.AppendLine("</style></head><body><main id=\"main-content\">");
}
@@ -186,7 +195,7 @@ public class HtmlReportFormatter_v2 : IReportFormatter
""");
// Загружаем основной JS
sb.AppendLine(LoadResource("HtmlFormatter.js"));
sb.AppendLine(LoadResource("HtmlFormatter_v3.js"));
sb.AppendLine("""
</script>

View File

@@ -5,6 +5,11 @@ 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;

View File

@@ -6,5 +6,5 @@ namespace SQLLinter.Infrastructure.Reporters;
public interface IReportFormatter
{
string Format(List<IRuleViolation> violations);
string Format(List<IRuleViolation> violations, BpmnDiagram diagram);
string Format(List<IRuleViolation> violations, BpmnDiagram? diagram);
}

View File

@@ -41,7 +41,7 @@ public class Reporter : IReporter
Report(violation.ToString());
}
public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, params string[] param)
public void ReportViolation(string fileName, int line, int column, RuleViolationSeverity severity, string ruleName, string template, BaseRuleVisitor.ExtractedBlock? snippet, params string[] param)
{
ReportViolation(new RuleTemplateViolation()
{
@@ -52,6 +52,7 @@ public class Reporter : IReporter
Column = column,
Severity = severity,
Params = param.ToList(),
Snippet = snippet,
});
}
}

File diff suppressed because it is too large Load Diff

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,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

@@ -15,6 +15,7 @@ namespace SQLLinter.Infrastructure.Rules.RuleViolations
required public RuleViolationSeverity Severity { get; init; }
virtual public string Text { get; set; }
public BaseRuleVisitor.ExtractedBlock? Snippet { get; set; }
}
public class RuleTemplateViolation : RuleViolation

View File

@@ -34,7 +34,7 @@ public class Linter
var ruleVisitor = new SqlRuleVisitor(_pluginHandler, fragmentBuilder, _reporter, sqlStreamReaderBuilder);
_fileProcessor = new SqlFileProcessor(ruleVisitor, _pluginHandler);
_fileProcessor = new SqlFileProcessor(config, ruleVisitor, _pluginHandler);
_reporter.Report($"SQL Linter загружен...");
}

View File

@@ -23,12 +23,19 @@
</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.128.0" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="170.147.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
</ItemGroup>
</Project>