| | | 1 | | using System.Text.RegularExpressions; |
| | | 2 | | |
| | | 3 | | namespace AspxLint.Core.Rules; |
| | | 4 | | |
| | | 5 | | public sealed class Tag003UnbalancedTags : IRule |
| | | 6 | | { |
| | 211 | 7 | | public string Id => "TAG-003"; |
| | 32 | 8 | | public string Name => "Balises non equilibrees"; |
| | 29 | 9 | | public Severity Severity => Severity.Error; |
| | | 10 | | public string Description => |
| | 25 | 11 | | "Les balises ouvertes doivent etre fermees (sauf balises vides). Une pile non vide en fin de fichier signale une |
| | 48 | 12 | | public bool HasFix => false; |
| | | 13 | | |
| | 5 | 14 | | private static readonly HashSet<string> VoidTags = new() |
| | 5 | 15 | | { |
| | 5 | 16 | | "br","hr","img","input","meta","link","area","base","col","embed","source","track","wbr" |
| | 5 | 17 | | }; |
| | | 18 | | |
| | 5 | 19 | | private static readonly Regex TagRegex = new( |
| | 5 | 20 | | @"<\/?([a-zA-Z][a-zA-Z0-9:_\-]*)\b[^>]*?(\/?)>", |
| | 5 | 21 | | RegexOptions.Compiled); |
| | | 22 | | |
| | | 23 | | // On masque code serveur, commentaires, scripts, styles avant tokenisation |
| | | 24 | | // (meme strategie que la version JS). |
| | 5 | 25 | | private static readonly Regex AspBlock = new(@"<%[\s\S]*?%>", RegexOptions.Compiled); |
| | 5 | 26 | | private static readonly Regex HtmlComment = new(@"<!--[\s\S]*?-->", RegexOptions.Compiled); |
| | 5 | 27 | | private static readonly Regex ScriptBlock = new( |
| | 5 | 28 | | @"<script\b[^>]*>[\s\S]*?<\/script>", RegexOptions.IgnoreCase | RegexOptions.Compiled); |
| | 5 | 29 | | private static readonly Regex StyleBlock = new( |
| | 5 | 30 | | @"<style\b[^>]*>[\s\S]*?<\/style>", RegexOptions.IgnoreCase | RegexOptions.Compiled); |
| | | 31 | | |
| | | 32 | | public IEnumerable<Issue> Detect(string content, string[] lines, RuleContext ctx) |
| | | 33 | | { |
| | | 34 | | // Pre-calcul : offset de debut de chaque ligne, pour mapper match.Index -> ligne 1-indexee. |
| | 107 | 35 | | var lineStarts = new int[lines.Length]; |
| | 107 | 36 | | int pos = 0; |
| | 728 | 37 | | for (int i = 0; i < lines.Length; i++) |
| | | 38 | | { |
| | 257 | 39 | | lineStarts[i] = pos; |
| | 257 | 40 | | pos += lines[i].Length + 1; // +1 pour le \n |
| | | 41 | | } |
| | | 42 | | |
| | | 43 | | // Masque les zones a ignorer en gardant la longueur (on remplace par des espaces). |
| | 107 | 44 | | var cleaned = AspBlock.Replace(content, m => new string(' ', m.Length)); |
| | 107 | 45 | | cleaned = HtmlComment.Replace(cleaned, m => new string(' ', m.Length)); |
| | 107 | 46 | | cleaned = ScriptBlock.Replace(cleaned, m => new string(' ', m.Length)); |
| | 107 | 47 | | cleaned = StyleBlock.Replace(cleaned, m => new string(' ', m.Length)); |
| | | 48 | | |
| | 107 | 49 | | var issues = new List<Issue>(); |
| | 107 | 50 | | var stack = new Stack<(string tag, int line)>(); |
| | | 51 | | |
| | 478 | 52 | | foreach (Match m in TagRegex.Matches(cleaned)) |
| | | 53 | | { |
| | 132 | 54 | | var tag = m.Groups[1].Value.ToLowerInvariant(); |
| | 132 | 55 | | var isClose = m.Value.StartsWith("</"); |
| | 132 | 56 | | var isSelfClose = m.Groups[2].Value == "/" || VoidTags.Contains(tag); |
| | 132 | 57 | | if (isSelfClose && !isClose) continue; |
| | | 58 | | |
| | 104 | 59 | | if (isClose) |
| | | 60 | | { |
| | 51 | 61 | | if (stack.Count == 0) |
| | | 62 | | { |
| | 0 | 63 | | issues.Add(new Issue(Id, Name, Severity, |
| | 0 | 64 | | LineFromOffset(m.Index, lineStarts), 1, m.Value, |
| | 0 | 65 | | $"Balise fermante </{tag}> sans ouverture correspondante.")); |
| | | 66 | | } |
| | | 67 | | else |
| | | 68 | | { |
| | 51 | 69 | | var top = stack.Peek(); |
| | 51 | 70 | | if (top.tag == tag) |
| | | 71 | | { |
| | 47 | 72 | | stack.Pop(); |
| | | 73 | | } |
| | | 74 | | else |
| | | 75 | | { |
| | 4 | 76 | | issues.Add(new Issue(Id, Name, Severity, |
| | 4 | 77 | | LineFromOffset(m.Index, lineStarts), 1, m.Value, |
| | 4 | 78 | | $"Imbrication incorrecte : </{tag}> ferme alors que <{top.tag}> est encore ouvert (ligne {to |
| | 4 | 79 | | stack.Pop(); |
| | | 80 | | } |
| | | 81 | | } |
| | | 82 | | } |
| | | 83 | | else |
| | | 84 | | { |
| | 53 | 85 | | stack.Push((tag, LineFromOffset(m.Index, lineStarts))); |
| | | 86 | | } |
| | | 87 | | } |
| | | 88 | | |
| | | 89 | | // Ce qui reste dans la pile = jamais ferme. |
| | 218 | 90 | | foreach (var s in stack) |
| | | 91 | | { |
| | 2 | 92 | | issues.Add(new Issue(Id, Name, Severity, |
| | 2 | 93 | | s.line, 1, $"<{s.tag}>", |
| | 2 | 94 | | $"Balise <{s.tag}> jamais fermee.")); |
| | | 95 | | } |
| | | 96 | | |
| | 107 | 97 | | return issues; |
| | | 98 | | } |
| | | 99 | | |
| | 4 | 100 | | public string? Fix(string content, RuleContext ctx) => null; |
| | | 101 | | |
| | | 102 | | private static int LineFromOffset(int offset, int[] lineStarts) |
| | | 103 | | { |
| | 114 | 104 | | int lo = 0, hi = lineStarts.Length - 1; |
| | 160 | 105 | | while (lo < hi) |
| | | 106 | | { |
| | 103 | 107 | | int mid = (lo + hi + 1) / 2; |
| | 153 | 108 | | if (lineStarts[mid] <= offset) lo = mid; |
| | 53 | 109 | | else hi = mid - 1; |
| | | 110 | | } |
| | 57 | 111 | | return lo + 1; |
| | | 112 | | } |
| | | 113 | | } |