From cc7809871e400f9d0ebf77055ee254490963a8d0 Mon Sep 17 00:00:00 2001 From: FrigaT Date: Sun, 28 Dec 2025 14:18:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=84=D0=BE=D1=80=D0=BC=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SQLLinter.CLI/Properties/launchSettings.json | 8 + SQLLinter/Common/BaseRuleVisitor.cs | 315 +++++++++++- SQLLinter/Common/IReporter.cs | 2 +- SQLLinter/Common/IRule.cs | 2 +- SQLLinter/Common/IRuleViolation.cs | 1 + SQLLinter/Common/Violation.cs | 2 +- .../Infrastructure/Parser/FragmentBuilder.cs | 2 +- .../Infrastructure/Parser/SqlRuleVisitor.cs | 6 +- .../Formatters/Html/v2/HtmlFormatter_v2.css | 451 ++++++++++++++++++ .../Formatters/Html/v2/HtmlFormatter_v2.js | 131 ++++- .../Html/v2/HtmlReportFormatter_v2.cs | 109 +++++ .../Infrastructure/Reporters/Reporter.cs | 3 +- .../Infrastructure/Rules/ParentMapVisitor.cs | 71 +++ .../Rules/RuleViolations/RuleViolation.cs | 1 + 14 files changed, 1090 insertions(+), 14 deletions(-) create mode 100644 SQLLinter.CLI/Properties/launchSettings.json create mode 100644 SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs diff --git a/SQLLinter.CLI/Properties/launchSettings.json b/SQLLinter.CLI/Properties/launchSettings.json new file mode 100644 index 0000000..5587ef2 --- /dev/null +++ b/SQLLinter.CLI/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "SQLLinter.CLI": { + "commandName": "Project", + "hotReloadEnabled": false + } + } +} \ No newline at end of file diff --git a/SQLLinter/Common/BaseRuleVisitor.cs b/SQLLinter/Common/BaseRuleVisitor.cs index 892fc97..80ffa50 100644 --- a/SQLLinter/Common/BaseRuleVisitor.cs +++ b/SQLLinter/Common/BaseRuleVisitor.cs @@ -1,4 +1,5 @@ using Microsoft.SqlServer.TransactSql.ScriptDom; +using System.Text; namespace SQLLinter.Common; @@ -6,6 +7,16 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule { protected readonly List _violations = new(); + protected Dictionary _parents + = new Dictionary(); + + public void SetParents(Dictionary parents) + => _parents = parents; + + protected TSqlFragment? GetParent(TSqlFragment node) + => _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 +68,314 @@ 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; + + // ---- Новые блоки для 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; + + // 1. Получаем токены для блока + var tokens = node.ScriptTokenStream + .Where(t => t.Line >= node.StartLine && + t.Offset < node.StartOffset + node.FragmentLength) + .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 + }; + } + + /// + /// Строит список строк из токенов + /// + private List<(int LineNumber, string Text)> BuildLinesFromTokens(List 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; + } + + /// + /// Вычисляет минимальный отступ среди непустых строк + /// + 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; + } + + /// + /// Нормализует строки, убирая минимальный отступ + /// + 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; + } + + /// + /// Вычисляет позиции ошибки (упрощенный вариант) + /// + 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); + } + + /// + /// Корректирует столбец после удаления отступов + /// + private int CalculateAdjustedColumn(int originalColumn, int minIndent) + { + return Math.Max(originalColumn - minIndent, 1); + } + + /// + /// Получает последнюю строку ошибки + /// + 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; + } + + /// + /// Получает токены ошибки + /// + private List GetErrorTokens(TSqlFragment errorNode) + { + return errorNode.ScriptTokenStream? + .Where(t => t.Offset >= errorNode.StartOffset && + t.Offset < errorNode.StartOffset + errorNode.FragmentLength) + .ToList() ?? new List(); + } + +} \ No newline at end of file diff --git a/SQLLinter/Common/IReporter.cs b/SQLLinter/Common/IReporter.cs index efc65bb..ee3f19a 100644 --- a/SQLLinter/Common/IReporter.cs +++ b/SQLLinter/Common/IReporter.cs @@ -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); } \ No newline at end of file diff --git a/SQLLinter/Common/IRule.cs b/SQLLinter/Common/IRule.cs index df60ff0..82e805f 100644 --- a/SQLLinter/Common/IRule.cs +++ b/SQLLinter/Common/IRule.cs @@ -13,5 +13,5 @@ public interface IRule int DynamicSqlStartLine { get; set; } IEnumerable Analyze(TSqlFragment fragment); - + void SetParents(Dictionary parents); } \ No newline at end of file diff --git a/SQLLinter/Common/IRuleViolation.cs b/SQLLinter/Common/IRuleViolation.cs index 41b5fd1..15271ea 100644 --- a/SQLLinter/Common/IRuleViolation.cs +++ b/SQLLinter/Common/IRuleViolation.cs @@ -13,4 +13,5 @@ public interface IRuleViolation RuleViolationSeverity Severity { get; } string Text { get; } + BaseRuleVisitor.ExtractedBlock? Snippet { get; } } \ No newline at end of file diff --git a/SQLLinter/Common/Violation.cs b/SQLLinter/Common/Violation.cs index 4b797ce..819cfe8 100644 --- a/SQLLinter/Common/Violation.cs +++ b/SQLLinter/Common/Violation.cs @@ -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); } diff --git a/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs b/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs index 1e0536c..ffda393 100644 --- a/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs +++ b/SQLLinter/Infrastructure/Parser/FragmentBuilder.cs @@ -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); } } diff --git a/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs b/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs index dcdaa1b..2380083 100644 --- a/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs +++ b/SQLLinter/Infrastructure/Parser/SqlRuleVisitor.cs @@ -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; @@ -38,6 +39,8 @@ public class SqlRuleVisitor : IRuleVisitor if (sqlFragment == null) return; + var parentMap = ParentMapBuilder.Build(sqlFragment); + 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) diff --git a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css index f8dde88..99518a7 100644 --- a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css +++ b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.css @@ -1680,4 +1680,455 @@ svg .flowchart-link { .stat-value { font-size: var(--font-size-xxl); } +} + +/* Стили для детальных описаний нарушений */ +.violation-main-row { + transition: background-color 0.2s ease; +} + + .violation-main-row:hover { + background-color: rgba(0, 0, 0, 0.02); + } + +.description-content { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-m); +} + +.detail-toggle-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-s); + background: var(--surface-neutral); + border: 1px solid var(--border-default); + border-radius: var(--border-radius-small); + color: var(--text-secondary); + font-size: var(--font-size-xs); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + flex-shrink: 0; +} + + .detail-toggle-btn:hover { + background: var(--surface-neutral-hover); + border-color: var(--border-strong); + color: var(--text-primary); + } + + .detail-toggle-btn.active { + background: var(--primary-light); + border-color: var(--primary-color); + color: var(--primary-color); + } + +.detail-toggle-icon { + transition: transform 0.3s ease; +} + +.detail-toggle-btn.active .detail-toggle-icon { + transform: rotate(180deg); +} + +.violation-detail-row { + background: var(--surface-subtle); + border-bottom: 2px solid var(--border-default); +} + +.detail-cell { + padding: 0 !important; +} + +.detail-container { + padding: var(--spacing-l); + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--border-radius-medium); + margin: var(--spacing-s) var(--spacing-m); + box-shadow: var(--shadow-4); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-l); + padding-bottom: var(--spacing-s); + border-bottom: 1px solid var(--border-default); +} + + .detail-header h4 { + margin: 0; + color: var(--text-primary); + font-size: var(--font-size-m); + font-weight: 600; + } + +.detail-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: var(--surface-neutral); + border: 1px solid var(--border-default); + border-radius: var(--border-radius-small); + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s ease; +} + + .detail-close-btn:hover { + background: var(--surface-neutral-hover); + border-color: var(--border-strong); + color: var(--text-primary); + } + +.detail-content { + margin-bottom: var(--spacing-l); + line-height: 1.6; + color: var(--text-primary); +} + + .detail-content :first-child { + margin-top: 0; + } + + .detail-content :last-child { + margin-bottom: 0; + } + + .detail-content p { + margin: var(--spacing-s) 0; + } + + .detail-content ul, + .detail-content ol { + margin: var(--spacing-s) 0; + padding-left: var(--spacing-l); + } + + .detail-content li { + margin: var(--spacing-xs) 0; + } + + .detail-content code { + background: var(--surface-neutral); + padding: 2px 6px; + border-radius: var(--border-radius-small); + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.9em; + } + + .detail-content pre { + background: var(--surface-neutral); + padding: var(--spacing-m); + border-radius: var(--border-radius-medium); + overflow-x: auto; + margin: var(--spacing-m) 0; + } + + .detail-content pre code { + background: transparent; + padding: 0; + } + +.detail-footer { + border-top: 1px solid var(--border-subtle); + padding-top: var(--spacing-s); +} + +.detail-metadata { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--font-size-xs); + color: var(--text-secondary); +} + +.detail-severity { + padding: 2px 8px; + border-radius: var(--border-radius-small); + font-weight: 500; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.5px; +} + + .detail-severity.critical { + background: var(--critical-bg); + color: var(--critical-fg); + } + + .detail-severity.warning { + background: var(--warning-bg); + color: var(--warning-fg); + } + + .detail-severity.info { + background: var(--info-bg); + color: var(--info-fg); + } + +.detail-location { + font-family: 'Consolas', 'Monaco', monospace; +} + +/* Анимация появления деталей */ +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.violation-detail-row[style*="table-row"] .detail-container { + animation: slideDown 0.3s ease; +} + +/* Адаптивность для мобильных устройств */ +@media (max-width: 768px) { + .detail-container { + margin: var(--spacing-xs); + padding: var(--spacing-m); + } + + .detail-header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-s); + } + + .detail-metadata { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-xs); + } + + .description-content { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-s); + } + + .detail-toggle-btn { + align-self: flex-start; + } +} + +/* Контейнер для блока кода */ +.code-block { + position: relative; + background: var(--surface-default); + border: 1px solid var(--border-default); + border-radius: var(--border-radius-medium); + margin: var(--spacing-m) 0; + overflow: hidden; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.5; +} + +/* Контейнер для блока кода */ +.code-block { + position: relative; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-medium); + margin: var(--spacing-md) 0; + overflow: hidden; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: var(--font-size-sm); + line-height: 1.5; +} + +/* Контейнер для строк кода */ +.code-container { + overflow-x: auto; + padding: var(--spacing-sm) 0; +} + +/* Строка кода */ +.code-line { + display: flex; + min-height: 24px; + padding: 0 var(--spacing-sm); + transition: background-color var(--transition-fast); +} + + .code-line:hover { + background: var(--color-background-neutral); + } + +/* Номер строки */ +.line-number { + display: inline-block; + min-width: 40px; + padding-right: var(--spacing-md); + text-align: right; + color: var(--color-text-tertiary); + user-select: none; + font-size: var(--font-size-xs); + border-right: 1px solid var(--color-border); + margin-right: var(--spacing-md); +} + +/* Подсветка ошибок */ +.code-line .critical { + position: relative; + color: var(--color-critical); + background: var(--color-critical-bg); + padding: 0 var(--spacing-xs); + border-radius: var(--border-radius-small); + border: 1px solid var(--color-critical); +} + + .code-line .critical::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent 0%, var(--color-critical) 50%, transparent 100%); + } + + .code-line .critical:hover { + background: rgba(209, 52, 56, 0.2); + } + +/* Подсветка предупреждений */ +.code-line .warning { + position: relative; + color: var(--color-warning); + background: var(--color-warning-bg); + padding: 0 var(--spacing-xs); + border-radius: var(--border-radius-small); + border: 1px solid var(--color-warning); +} + + .code-line .warning:hover { + background: rgba(255, 170, 68, 0.2); + } + +/* Подсветка информационных сообщений */ +.code-line .info { + position: relative; + color: var(--color-info); + background: var(--color-info-bg); + padding: 0 var(--spacing-xs); + border-radius: var(--border-radius-small); + border: 1px solid var(--color-info); +} + + .code-line .info:hover { + background: rgba(0, 120, 212, 0.2); + } + +/* Подсветка успешных проверок */ +.code-line .success { + position: relative; + color: var(--color-success); + background: var(--color-success-bg); + padding: 0 var(--spacing-xs); + border-radius: var(--border-radius-small); + border: 1px solid var(--color-success); +} + + .code-line .success:hover { + background: rgba(16, 124, 16, 0.2); + } + +/* Строка с ошибкой */ +.code-line.error-line { + background: linear-gradient(90deg, rgba(209, 52, 56, 0.05) 0%, rgba(209, 52, 56, 0.1) 100%); + border-left: 3px solid var(--color-critical); +} + +/* Строка с предупреждением */ +.code-line.warning-line { + background: linear-gradient(90deg, rgba(255, 170, 68, 0.05) 0%, rgba(255, 170, 68, 0.1) 100%); + border-left: 3px solid var(--color-warning); +} + +/* Строка с информацией */ +.code-line.info-line { + background: linear-gradient(90deg, rgba(0, 120, 212, 0.05) 0%, rgba(0, 120, 212, 0.1) 100%); + border-left: 3px solid var(--color-info); +} + +/* Темная тема */ +@media (prefers-color-scheme: dark) { + .code-block { + background: var(--color-background-alt); + border-color: var(--color-border-strong); + } + + .code-line:hover { + background: var(--color-background-neutral-dark); + } + + .line-number { + color: var(--color-text-tertiary); + border-color: var(--color-border-strong); + } + + .code-line .critical { + background: var(--color-critical-bg); + border-color: var(--color-critical); + } + + .code-line .warning { + background: var(--color-warning-bg); + border-color: var(--color-warning); + } + + .code-line .info { + background: var(--color-info-bg); + border-color: var(--color-info); + } + + .code-line .success { + background: var(--color-success-bg); + border-color: var(--color-success); + } + + .code-line.error-line { + background: linear-gradient(90deg, rgba(209, 52, 56, 0.1) 0%, rgba(209, 52, 56, 0.15) 100%); + } + + .code-line.warning-line { + background: linear-gradient(90deg, rgba(255, 170, 68, 0.1) 0%, rgba(255, 170, 68, 0.15) 100%); + } + + .code-line.info-line { + background: linear-gradient(90deg, rgba(0, 120, 212, 0.1) 0%, rgba(0, 120, 212, 0.15) 100%); + } +} + +/* Адаптивность */ +@media (max-width: 768px) { + .code-block { + font-size: var(--font-size-xs); + } + + .line-number { + min-width: 30px; + padding-right: var(--spacing-sm); + margin-right: var(--spacing-sm); + } + + .code-line { + padding: 0 var(--spacing-xs); + } } \ No newline at end of file diff --git a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js index f96eafe..f01ad0a 100644 --- a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js +++ b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlFormatter_v2.js @@ -291,29 +291,62 @@ class ReportRenderer { const section = document.createElement('div'); section.className = `severity-section ${severity}`; + // Проверяем, есть ли хотя бы одно нарушение с деталями + const hasDetails = violations.some(v => v.d || v.details); + const tableRows = violations.map(violation => { // Получаем правило по ID const ruleId = violation.r || violation.ruleId; const rule = this.rules[ruleId] || { n: 'Unknown', t: 'Описание отсутствует' }; - // Формируем текст с подстановкой параметров + // Получаем детальное описание + const details = violation.d || violation.details || ''; + + // Формируем основной текст с подстановкой параметров let text = rule.t || ''; const args = violation.a || violation.args || []; if (args.length > 0 && text.includes('{')) { args.forEach((arg, index) => { - text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), arg); + text = text.replace(new RegExp(`\\{${index}\\}`, 'g'), this.escapeHtml(arg)); }); } + // Уникальный ID для раскрывающейся секции + const detailId = `detail_${severity}_${violation.i || violation.index}`; + return ` - + ${violation.i || violation.index} ${violation.l || violation.line} ${violation.c || violation.column} ${this.escapeHtml(rule.n || rule.name)} - ${this.escapeHtml(text)} + +
+ ${this.escapeHtml(text)} + ${details ? + `` : + '' + } +
+ + ${details ? ` + + +
+
+ ${details} +
+
+ + + ` : ''} `; }).join(''); @@ -325,7 +358,7 @@ class ReportRenderer {
- +
@@ -342,9 +375,97 @@ class ReportRenderer { `; + // Добавляем обработчики событий после создания секции + this.addDetailHandlers(section); + return section; } + addDetailHandlers(section) { + // Обработчик для кнопок раскрытия/закрытия деталей + section.addEventListener('click', (e) => { + const toggleBtn = e.target.closest('.detail-toggle-btn'); + const closeBtn = e.target.closest('.detail-close-btn'); + + if (toggleBtn) { + const detailId = toggleBtn.dataset.detailId; + this.toggleDetail(detailId, toggleBtn); + } + + if (closeBtn) { + const detailId = closeBtn.dataset.detailId; + this.hideDetail(detailId); + } + }); + + // Закрытие по нажатию Escape + section.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const openDetails = section.querySelectorAll('.violation-detail-row[style="display: table-row;"]'); + openDetails.forEach(detailRow => { + const detailId = detailRow.id; + this.hideDetail(detailId); + }); + } + }); + } + + toggleDetail(detailId, toggleBtn) { + const detailRow = document.getElementById(detailId); + const mainRow = toggleBtn.closest('.violation-main-row'); + + if (!detailRow || !mainRow) return; + + const isVisible = detailRow.style.display === 'table-row'; + + if (isVisible) { + this.hideDetail(detailId); + } else { + // Закрываем другие открытые детали в этой же таблице + const table = detailRow.closest('table'); + if (table) { + const otherDetails = table.querySelectorAll('.violation-detail-row[style="display: table-row;"]'); + otherDetails.forEach(row => { + if (row.id !== detailId) { + this.hideDetail(row.id); + } + }); + } + + // Показываем текущую деталь + detailRow.style.display = 'table-row'; + + // Обновляем состояние кнопки + const icon = toggleBtn.querySelector('.detail-toggle-icon'); + if (icon) { + icon.style.transform = 'rotate(180deg)'; + } + toggleBtn.classList.add('active'); + + // Прокручиваем к детали + setTimeout(() => { + detailRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 10); + } + } + + hideDetail(detailId) { + const detailRow = document.getElementById(detailId); + if (!detailRow) return; + + detailRow.style.display = 'none'; + + // Обновляем кнопку + const toggleBtn = document.querySelector(`.detail-toggle-btn[data-detail-id="${detailId}"]`); + if (toggleBtn) { + const icon = toggleBtn.querySelector('.detail-toggle-icon'); + if (icon) { + icon.style.transform = 'rotate(0deg)'; + } + toggleBtn.classList.remove('active'); + } + } + setupTabNavigation() { const tabs = this.tabsList.querySelectorAll('.tab'); diff --git a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs index 21aef11..6997ab1 100644 --- a/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs +++ b/SQLLinter/Infrastructure/Reporters/Formatters/Html/v2/HtmlReportFormatter_v2.cs @@ -6,6 +6,7 @@ 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; @@ -119,6 +120,7 @@ public class HtmlReportFormatter : IReportFormatter Args = args, Column = violation.Column, Line = violation.Line, + Details = violation.Snippet != null ? SnippetToHtml(violation.Snippet, violation.Severity) : null }; if (violation.Severity == RuleViolationSeverity.Critical) @@ -156,6 +158,110 @@ public class HtmlReportFormatter : IReportFormatter 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("
"); + sb.AppendLine("
"); + + 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 = $"{text}"; + } + } + + var lineClass = "code-line"; + if (isErrorLine) + { + lineClass += " error-line"; + } + + sb.Append($"
"); + sb.Append($"{lineNumber} "); + sb.Append(processedText); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + sb.AppendLine("
"); + + 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 $"{line}"; + } + + string before = line.Substring(0, startIdx); + string error = line.Substring(startIdx, endIdx - startIdx); + string after = line.Substring(endIdx); + + return $"{before}{error}{after}"; + } + private void GenerateBeginningHtml(StringBuilder sb) { sb.AppendLine(""" @@ -260,6 +366,9 @@ public class HtmlReportFormatter : IReportFormatter [JsonPropertyName("a")] // args (optional) public List? Args { get; set; } + + [JsonPropertyName("d")] // details (optional) + public string? Details { get; set; } } private class Rule diff --git a/SQLLinter/Infrastructure/Reporters/Reporter.cs b/SQLLinter/Infrastructure/Reporters/Reporter.cs index f3b9114..d1ffb1b 100644 --- a/SQLLinter/Infrastructure/Reporters/Reporter.cs +++ b/SQLLinter/Infrastructure/Reporters/Reporter.cs @@ -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, }); } } diff --git a/SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs b/SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs new file mode 100644 index 0000000..b6adad7 --- /dev/null +++ b/SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs @@ -0,0 +1,71 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace SQLLinter.Infrastructure.Rules; + +public static class ParentMapBuilder +{ + public static Dictionary Build(TSqlFragment root) + { + var map = new Dictionary(); + Traverse(root, null, map); + return map; + } + + private static void Traverse( + TSqlFragment node, + TSqlFragment? parent, + Dictionary 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 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 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); + } + } +} diff --git a/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs b/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs index cd7c420..2473f9c 100644 --- a/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs +++ b/SQLLinter/Infrastructure/Rules/RuleViolations/RuleViolation.cs @@ -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
#