Добавлено формирование детальной ошибки
This commit is contained in:
8
SQLLinter.CLI/Properties/launchSettings.json
Normal file
8
SQLLinter.CLI/Properties/launchSettings.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"SQLLinter.CLI": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"hotReloadEnabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
using Microsoft.SqlServer.TransactSql.ScriptDom;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace SQLLinter.Common;
|
namespace SQLLinter.Common;
|
||||||
|
|
||||||
@@ -6,6 +7,16 @@ public abstract class BaseRuleVisitor : TSqlFragmentVisitor, IRule
|
|||||||
{
|
{
|
||||||
protected readonly List<Violation> _violations = new();
|
protected readonly List<Violation> _violations = new();
|
||||||
|
|
||||||
|
protected Dictionary<TSqlFragment, TSqlFragment?> _parents
|
||||||
|
= new Dictionary<TSqlFragment, TSqlFragment?>();
|
||||||
|
|
||||||
|
public void SetParents(Dictionary<TSqlFragment, TSqlFragment?> parents)
|
||||||
|
=> _parents = parents;
|
||||||
|
|
||||||
|
protected TSqlFragment? GetParent(TSqlFragment node)
|
||||||
|
=> _parents.TryGetValue(node, out var parent) ? parent : null;
|
||||||
|
|
||||||
|
|
||||||
public int DynamicSqlStartColumn { get; set; }
|
public int DynamicSqlStartColumn { get; set; }
|
||||||
public int DynamicSqlStartLine { get; set; }
|
public int DynamicSqlStartLine { get; set; }
|
||||||
public virtual string Name { get => GetDefaultRuleName(GetType().Name); }
|
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)
|
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)
|
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)
|
protected string GetText(params string[] param)
|
||||||
{
|
{
|
||||||
return string.Format(this.Text, 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,5 +4,5 @@ public interface IReporter : IBaseReporter
|
|||||||
{
|
{
|
||||||
void ReportViolation(IRuleViolation violation);
|
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);
|
||||||
}
|
}
|
||||||
@@ -13,5 +13,5 @@ public interface IRule
|
|||||||
int DynamicSqlStartLine { get; set; }
|
int DynamicSqlStartLine { get; set; }
|
||||||
|
|
||||||
IEnumerable<Violation> Analyze(TSqlFragment fragment);
|
IEnumerable<Violation> Analyze(TSqlFragment fragment);
|
||||||
|
void SetParents(Dictionary<TSqlFragment, TSqlFragment?> parents);
|
||||||
}
|
}
|
||||||
@@ -13,4 +13,5 @@ public interface IRuleViolation
|
|||||||
RuleViolationSeverity Severity { get; }
|
RuleViolationSeverity Severity { get; }
|
||||||
|
|
||||||
string Text { get; }
|
string Text { get; }
|
||||||
|
BaseRuleVisitor.ExtractedBlock? Snippet { get; }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace SQLLinter.Common
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ public class FragmentBuilder : IFragmentBuilder
|
|||||||
{
|
{
|
||||||
foreach (var err in errors)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using SQLLinter.Common;
|
|||||||
using SQLLinter.Core.Interfaces;
|
using SQLLinter.Core.Interfaces;
|
||||||
using SQLLinter.Infrastructure.Configuration.Overrides;
|
using SQLLinter.Infrastructure.Configuration.Overrides;
|
||||||
using SQLLinter.Infrastructure.Interfaces;
|
using SQLLinter.Infrastructure.Interfaces;
|
||||||
|
using SQLLinter.Infrastructure.Rules;
|
||||||
using SQLLinter.Infrastructure.Rules.RuleExceptions;
|
using SQLLinter.Infrastructure.Rules.RuleExceptions;
|
||||||
using SQLLinter.Infrastructure.Rules.RuleViolations;
|
using SQLLinter.Infrastructure.Rules.RuleViolations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
@@ -38,6 +39,8 @@ public class SqlRuleVisitor : IRuleVisitor
|
|||||||
|
|
||||||
if (sqlFragment == null) return;
|
if (sqlFragment == null) return;
|
||||||
|
|
||||||
|
var parentMap = ParentMapBuilder.Build(sqlFragment);
|
||||||
|
|
||||||
var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray();
|
var ruleExceptions = ignoredRules as IRuleException[] ?? ignoredRules.ToArray();
|
||||||
if (errors.Any())
|
if (errors.Any())
|
||||||
{
|
{
|
||||||
@@ -47,6 +50,7 @@ public class SqlRuleVisitor : IRuleVisitor
|
|||||||
var rules = _pluginHandler.Rules;
|
var rules = _pluginHandler.Rules;
|
||||||
foreach (var rule in rules)
|
foreach (var rule in rules)
|
||||||
{
|
{
|
||||||
|
rule.SetParents(parentMap);
|
||||||
VisitFragment(sqlFragment, rule, overrideArray, sqlPath);
|
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)
|
private static bool VisitorIsBlackListedForDynamicSql(IRule visitor)
|
||||||
|
|||||||
@@ -1680,4 +1680,455 @@ svg .flowchart-link {
|
|||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: var(--font-size-xxl);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -291,29 +291,62 @@ class ReportRenderer {
|
|||||||
const section = document.createElement('div');
|
const section = document.createElement('div');
|
||||||
section.className = `severity-section ${severity}`;
|
section.className = `severity-section ${severity}`;
|
||||||
|
|
||||||
|
// Проверяем, есть ли хотя бы одно нарушение с деталями
|
||||||
|
const hasDetails = violations.some(v => v.d || v.details);
|
||||||
|
|
||||||
const tableRows = violations.map(violation => {
|
const tableRows = violations.map(violation => {
|
||||||
// Получаем правило по ID
|
// Получаем правило по ID
|
||||||
const ruleId = violation.r || violation.ruleId;
|
const ruleId = violation.r || violation.ruleId;
|
||||||
const rule = this.rules[ruleId] || { n: 'Unknown', t: 'Описание отсутствует' };
|
const rule = this.rules[ruleId] || { n: 'Unknown', t: 'Описание отсутствует' };
|
||||||
|
|
||||||
// Формируем текст с подстановкой параметров
|
// Получаем детальное описание
|
||||||
|
const details = violation.d || violation.details || '';
|
||||||
|
|
||||||
|
// Формируем основной текст с подстановкой параметров
|
||||||
let text = rule.t || '';
|
let text = rule.t || '';
|
||||||
const args = violation.a || violation.args || [];
|
const args = violation.a || violation.args || [];
|
||||||
|
|
||||||
if (args.length > 0 && text.includes('{')) {
|
if (args.length > 0 && text.includes('{')) {
|
||||||
args.forEach((arg, index) => {
|
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 `
|
return `
|
||||||
<tr>
|
<tr class="violation-main-row" data-detail-id="${detailId}">
|
||||||
<td class="index">${violation.i || violation.index}</td>
|
<td class="index">${violation.i || violation.index}</td>
|
||||||
<td class="line">${violation.l || violation.line}</td>
|
<td class="line">${violation.l || violation.line}</td>
|
||||||
<td class="column">${violation.c || violation.column}</td>
|
<td class="column">${violation.c || violation.column}</td>
|
||||||
<td class="rule">${this.escapeHtml(rule.n || rule.name)}</td>
|
<td class="rule">${this.escapeHtml(rule.n || rule.name)}</td>
|
||||||
<td class="description">${this.escapeHtml(text)}</td>
|
<td class="description">
|
||||||
|
<div class="description-content">
|
||||||
|
${this.escapeHtml(text)}
|
||||||
|
${details ?
|
||||||
|
`<button class="detail-toggle-btn" type="button" data-detail-id="${detailId}">
|
||||||
|
<svg class="detail-toggle-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19 9L12 16L5 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="detail-toggle-text">Детали</span>
|
||||||
|
</button>` :
|
||||||
|
''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
${details ? `
|
||||||
|
<tr class="violation-detail-row" id="${detailId}" style="display: none;">
|
||||||
|
<td colspan="5" class="detail-cell">
|
||||||
|
<div class="code-block">
|
||||||
|
<div class="code-container">
|
||||||
|
${details} <!-- HTML вставляется как есть, предполагается доверенный источник -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
` : ''}
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -325,7 +358,7 @@ class ReportRenderer {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table class="${hasDetails ? 'has-details' : ''}">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="index">#</th>
|
<th class="index">#</th>
|
||||||
@@ -342,9 +375,97 @@ class ReportRenderer {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Добавляем обработчики событий после создания секции
|
||||||
|
this.addDetailHandlers(section);
|
||||||
|
|
||||||
return 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() {
|
setupTabNavigation() {
|
||||||
const tabs = this.tabsList.querySelectorAll('.tab');
|
const tabs = this.tabsList.querySelectorAll('.tab');
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Reflection;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using static SQLLinter.Common.BaseRuleVisitor;
|
||||||
|
|
||||||
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v2;
|
namespace SQLLinter.Infrastructure.Reporters.Formatters.Html.v2;
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ public class HtmlReportFormatter : IReportFormatter
|
|||||||
Args = args,
|
Args = args,
|
||||||
Column = violation.Column,
|
Column = violation.Column,
|
||||||
Line = violation.Line,
|
Line = violation.Line,
|
||||||
|
Details = violation.Snippet != null ? SnippetToHtml(violation.Snippet, violation.Severity) : null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (violation.Severity == RuleViolationSeverity.Critical)
|
if (violation.Severity == RuleViolationSeverity.Critical)
|
||||||
@@ -156,6 +158,110 @@ public class HtmlReportFormatter : IReportFormatter
|
|||||||
return reportData;
|
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 += " error-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)
|
private void GenerateBeginningHtml(StringBuilder sb)
|
||||||
{
|
{
|
||||||
sb.AppendLine("""
|
sb.AppendLine("""
|
||||||
@@ -260,6 +366,9 @@ public class HtmlReportFormatter : IReportFormatter
|
|||||||
|
|
||||||
[JsonPropertyName("a")] // args (optional)
|
[JsonPropertyName("a")] // args (optional)
|
||||||
public List<string>? Args { get; set; }
|
public List<string>? Args { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("d")] // details (optional)
|
||||||
|
public string? Details { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Rule
|
private class Rule
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public class Reporter : IReporter
|
|||||||
Report(violation.ToString());
|
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()
|
ReportViolation(new RuleTemplateViolation()
|
||||||
{
|
{
|
||||||
@@ -52,6 +52,7 @@ public class Reporter : IReporter
|
|||||||
Column = column,
|
Column = column,
|
||||||
Severity = severity,
|
Severity = severity,
|
||||||
Params = param.ToList(),
|
Params = param.ToList(),
|
||||||
|
Snippet = snippet,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs
Normal file
71
SQLLinter/Infrastructure/Rules/ParentMapVisitor.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ namespace SQLLinter.Infrastructure.Rules.RuleViolations
|
|||||||
required public RuleViolationSeverity Severity { get; init; }
|
required public RuleViolationSeverity Severity { get; init; }
|
||||||
|
|
||||||
virtual public string Text { get; set; }
|
virtual public string Text { get; set; }
|
||||||
|
public BaseRuleVisitor.ExtractedBlock? Snippet { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RuleTemplateViolation : RuleViolation
|
public class RuleTemplateViolation : RuleViolation
|
||||||
|
|||||||
Reference in New Issue
Block a user