From 4945ad004d5d6e1872bbce32c520eaa6b6680443 Mon Sep 17 00:00:00 2001 From: David Bond Date: Tue, 10 Nov 2026 11:25:24 +0000 Subject: [PATCH] fix: ignore headings, list items and tables quoted in {noformat} and {code} blocks Fixes #9. Section, criteria-heading and panel parsing read a masked copy of the text in which the first visible character of each line inside a literal block is blanked; SectionBody still returns the original text. --- .../Text/IssueTextTests.cs | 36 ++++++++++ .../Text/IssueText.LiteralBlocks.cs | 67 +++++++++++++++++++ .../Text/IssueText.Panels.cs | 1 + .../Text/IssueText.Patterns.cs | 1 + .../Text/IssueText.cs | 12 ++-- 5 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 PanoramicData.IssueAssessment/Text/IssueText.LiteralBlocks.cs diff --git a/PanoramicData.IssueAssessment.Tests/Text/IssueTextTests.cs b/PanoramicData.IssueAssessment.Tests/Text/IssueTextTests.cs index c5883f6..4740bdb 100644 --- a/PanoramicData.IssueAssessment.Tests/Text/IssueTextTests.cs +++ b/PanoramicData.IssueAssessment.Tests/Text/IssueTextTests.cs @@ -161,4 +161,40 @@ public void HeadingLevel_MapsBothDialectsToOneScale(string? line, int expected) { IssueText.HeadingLevel(line).Should().Be(expected); } + + // Issue #9 (MS-26773): a sample ticket quoted in a literal block brought its own headings with it. + public static TheoryData QuotedSamples => new() + { + { "{noformat}", "{noformat}" }, + { "{code}", "{code}" }, + { "{code:none}", "{code}" } + }; + + private static string WithQuotedSample(string open, string close) + => "h2. Summary\r\nAdds a sample.\r\n\r\nh2. Acceptance Criteria\r\n# MUST: the sample renders.\r\n\r\nh2. Notes\r\n" + + open + "\r\nh2. Acceptance Criteria\r\n# MUST: quoted.\r\nh2. Rollback plan\r\nRestore the backup.\r\nh2. Owner\r\nOps.\r\n" + close + "\r\n"; + + [Theory] + [MemberData(nameof(QuotedSamples))] + public void CriteriaHeadings_IgnoresHeadingsInsideLiteralBlocks(string open, string close) + => IssueText.CriteriaHeadings(WithQuotedSample(open, close)).Should().Equal("Acceptance Criteria"); + + [Theory] + [MemberData(nameof(QuotedSamples))] + public void SectionBody_IgnoresHeadingsInsideLiteralBlocks(string open, string close) + => IssueText.SectionBody(WithQuotedSample(open, close), "rollback").Should().BeNull(); + + [Theory] + [MemberData(nameof(QuotedSamples))] + public void SectionBody_KeepsTheLiteralBlockTextUnchanged(string open, string close) + => IssueText.SectionBody(WithQuotedSample(open, close), "notes").Should().Contain("h2. Rollback plan\r\nRestore the backup."); + + [Theory] + [MemberData(nameof(QuotedSamples))] + public void PanelLayoutOf_IgnoresHeadingsInsideLiteralBlocks(string open, string close) + { + var layout = IssueText.PanelLayoutOf(WithQuotedSample(open, close)); + + layout.Unpanelled.Concat(layout.Panelable).Should().NotContain("Rollback plan"); + } } diff --git a/PanoramicData.IssueAssessment/Text/IssueText.LiteralBlocks.cs b/PanoramicData.IssueAssessment/Text/IssueText.LiteralBlocks.cs new file mode 100644 index 0000000..57bdf5a --- /dev/null +++ b/PanoramicData.IssueAssessment/Text/IssueText.LiteralBlocks.cs @@ -0,0 +1,67 @@ +using System.Text.RegularExpressions; + +namespace PanoramicData.IssueAssessment.Text; + +/// +/// Keeps text quoted in {noformat} and {code} blocks from being read as the ticket's own structure. +/// +public static partial class IssueText +{ + /// + /// Blanks the first visible character of every line inside a literal block, so headings, list items and table rows + /// quoted there are not parsed. Length and positions are unchanged, so indexes still map onto the original text. + /// + private static string MaskLiteralBlocks(string text) + { + if (!LiteralBlockToken.IsMatch(text)) + { + return text; + } + + char[] masked = text.ToCharArray(); + string? openKind = null; + int lineStart = 0; + while (lineStart < text.Length) + { + int lineEnd = text.IndexOf('\n', lineStart); + if (lineEnd < 0) + { + lineEnd = text.Length; + } + + string line = text[lineStart..lineEnd]; + int firstVisible = line.Length - line.TrimStart().Length; + if (openKind is not null && firstVisible < line.Length && line[firstVisible] != '{') + { + masked[lineStart + firstVisible] = ' '; + } + + openKind = LiteralStateAfter(line, openKind); + lineStart = lineEnd + 1; + } + + return new string(masked); + } + + /// + /// The open block kind after : {noformat} closes only {noformat}, and inside + /// a {code} block only {code} closes it. + /// + private static string? LiteralStateAfter(string line, string? openKind) + { + foreach (Match token in LiteralBlockToken.Matches(line)) + { + string kind = token.Groups[1].Value.ToLowerInvariant(); + if (openKind is null) + { + openKind = kind; + } + else if (openKind == kind && !token.Value.Contains(':', StringComparison.Ordinal)) + { + openKind = null; + } + } + + return openKind; + } +} diff --git a/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs b/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs index 4087017..08c930b 100644 --- a/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs +++ b/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs @@ -24,6 +24,7 @@ public static PanelLayout PanelLayoutOf(string? text) return PanelLayout.Empty; } + text = MaskLiteralBlocks(text); List panelable = []; List unpanelled = []; List titled = []; diff --git a/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs b/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs index 05a65ca..f409663 100644 --- a/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs +++ b/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs @@ -35,6 +35,7 @@ public static partial class IssueText private static readonly Regex PanelSectionHeading = new(@"^[ \t]*h([12])\.(.*)$", Options, Timeout); private static readonly Regex PanelOpening = new(@"^[ \t]*\{panel(?::([^}]*))?\}", Options | RegexOptions.IgnoreCase, Timeout); private static readonly Regex PanelBlocker = new(@"^[ \t]*\||\{(?:code|noformat)\b", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex LiteralBlockToken = new(@"\{(noformat|code)(?::[^}\r\n]*)?\}", Options | RegexOptions.IgnoreCase, Timeout); private static readonly Regex PlaceholderListMarker = new(@"(?m)^[ \t]*(?:[-*#>]+|\d+\.)[ \t]*", Options, Timeout); private static readonly Regex PlaceholderPunctuation = new(@"[\*_`{}\[\]\(\)\|:;,\.\-–—/\\]", Options, Timeout); diff --git a/PanoramicData.IssueAssessment/Text/IssueText.cs b/PanoramicData.IssueAssessment/Text/IssueText.cs index 810d2ce..db6d790 100644 --- a/PanoramicData.IssueAssessment/Text/IssueText.cs +++ b/PanoramicData.IssueAssessment/Text/IssueText.cs @@ -47,19 +47,22 @@ public static partial class IssueText Regex heading = RegexCache.GetOrAdd(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t]|\*{1,2}(?=[^\r\n]*\*{1,2}[ \t]*\r?$))[^\r\n]*" + headingKeywordPattern + @"[^\r\n]*"); Regex label = RegexCache.GetOrAdd(@"(?im)^[ \t]*(?![hH][1-6]\.)(?=[A-Za-z])[^\r\n:#*]{0,60}?" + headingKeywordPattern + @"[^\r\n:]{0,60}:[ \t]*\r?$"); - IEnumerable openings = heading.Matches(text).Concat(label.Matches(text)); + // Sections are found in the masked text; bodies are cut from the original, which has the same positions. + string scan = MaskLiteralBlocks(text); + IEnumerable openings = heading.Matches(scan).Concat(label.Matches(scan)); // In wiki text a "#" line is a list item, so it cannot open a section; only a "##"-or-deeper line that does not // continue a "#" list may. - if (WikiHeadingAnywhere.IsMatch(text)) + if (WikiHeadingAnywhere.IsMatch(scan)) { - openings = openings.Where(opening => IsWikiTextOpening(text, opening)); + openings = openings.Where(opening => IsWikiTextOpening(scan, opening)); } string? best = null; foreach (Match opening in openings) { - string body = SectionAfter(opening.Value, text[(opening.Index + opening.Length)..]); + int start = opening.Index + opening.Length; + string body = text.Substring(start, SectionAfter(opening.Value, scan[start..]).Length); if (best is null || body.Length > best.Length) { best = body; @@ -86,6 +89,7 @@ public static IReadOnlyList CriteriaHeadings(string? text) return []; } + text = MaskLiteralBlocks(text); string[] lines = LineBreak.Split(text); List<(int Index, int Level, string Text)> headings = Headings(lines, WikiHeadingAnywhere.IsMatch(text)); List found = [];