Добавлено формирование детальной ошибки

This commit is contained in:
FrigaT
2025-12-28 14:18:54 +03:00
parent e4acae11f0
commit cc7809871e
14 changed files with 1090 additions and 14 deletions

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 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>();
}
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

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

View File

@@ -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);
} }

View File

@@ -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);
} }
} }

View File

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

View File

@@ -1681,3 +1681,454 @@ svg .flowchart-link {
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);
}
}

View File

@@ -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');

View File

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

View File

@@ -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,
}); });
} }
} }

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; } 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