Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions PanoramicData.IssueAssessment.Tests/Text/IssueTextTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> 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");
}
}
67 changes: 67 additions & 0 deletions PanoramicData.IssueAssessment/Text/IssueText.LiteralBlocks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Text.RegularExpressions;

namespace PanoramicData.IssueAssessment.Text;

/// <summary>
/// Keeps text quoted in <c>{noformat}</c> and <c>{code}</c> blocks from being read as the ticket's own structure.
/// </summary>
public static partial class IssueText
{
/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// The open block kind after <paramref name="line"/>: <c>{noformat}</c> closes only <c>{noformat}</c>, and inside
/// a <c>{code}</c> block only <c>{code}</c> closes it.
/// </summary>
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;
}
}
1 change: 1 addition & 0 deletions PanoramicData.IssueAssessment/Text/IssueText.Panels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public static PanelLayout PanelLayoutOf(string? text)
return PanelLayout.Empty;
}

text = MaskLiteralBlocks(text);
List<string> panelable = [];
List<string> unpanelled = [];
List<string> titled = [];
Expand Down
1 change: 1 addition & 0 deletions PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 8 additions & 4 deletions PanoramicData.IssueAssessment/Text/IssueText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Match> 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<Match> 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;
Expand All @@ -86,6 +89,7 @@ public static IReadOnlyList<string> 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<string> found = [];
Expand Down
Loading