diff --git a/PanoramicData.IssueAssessment/Mapping/AdfFlattener.cs b/PanoramicData.IssueAssessment/Mapping/AdfFlattener.cs new file mode 100644 index 0000000..ee246a5 --- /dev/null +++ b/PanoramicData.IssueAssessment/Mapping/AdfFlattener.cs @@ -0,0 +1,103 @@ +using System.Collections.Frozen; +using System.Text; +using static PanoramicData.IssueAssessment.Mapping.JsonReading; + +namespace PanoramicData.IssueAssessment.Mapping; + +/// +/// Flattens Atlassian Document Format (the description format of Jira REST API v3) to wiki-style text: headings become +/// h2. lines and list items * or # lines, so section rules read v2 and v3 descriptions the same way. +/// +internal static class AdfFlattener +{ + // ADF list types, and the wiki marker each nesting level adds. + private static readonly FrozenDictionary ListMarkers = new Dictionary(StringComparer.Ordinal) + { + ["bulletList"] = "*", + ["orderedList"] = "#", + }.ToFrozenDictionary(StringComparer.Ordinal); + + // ADF nodes written as one line of inline text. + private static readonly FrozenSet LineBlockTypes = FrozenSet.Create( + StringComparer.Ordinal, + "paragraph", "blockquote", "panel", "text", "mention", "inlineCard", "hardBreak"); + + public static string Flatten(JsonElement document) + { + StringBuilder text = new(); + AppendBlock(document, text, string.Empty); + return text.ToString().TrimEnd(); + } + + private static void AppendBlock(JsonElement node, StringBuilder text, string listPrefix) + { + string type = StringAt(node, "type") ?? string.Empty; + if (ListMarkers.TryGetValue(type, out string? listMarker)) + { + AppendList(node, text, listPrefix + listMarker); + } + else if (type == "heading") + { + text.Append(CultureInfo.InvariantCulture, $"h{AdfHeadingLevel(node)}. ").Append(Inline(node)).Append('\n'); + } + else if (type == "codeBlock") + { + text.Append("{code}\n").Append(Inline(node)).Append("\n{code}\n"); + } + else if (LineBlockTypes.Contains(type)) + { + text.Append(Inline(node)).Append('\n'); + } + else + { + foreach (JsonElement child in Children(node)) + { + AppendBlock(child, text, listPrefix); + } + } + } + + private static void AppendList(JsonElement list, StringBuilder text, string marker) + { + foreach (JsonElement item in Children(list)) + { + AppendListItem(item, text, marker); + } + } + + private static int AdfHeadingLevel(JsonElement heading) + => heading.TryGetProperty("attrs", out JsonElement attrs) && attrs.TryGetProperty("level", out JsonElement value) && value.TryGetInt32(out int level) + ? level + : 2; + + private static void AppendListItem(JsonElement item, StringBuilder text, string marker) + { + bool first = true; + foreach (JsonElement child in Children(item)) + { + if (StringAt(child, "type") is "bulletList" or "orderedList") + { + AppendBlock(child, text, marker); + } + else + { + text.Append(first ? marker + " " : string.Empty).Append(Inline(child)).Append('\n'); + first = false; + } + } + } + + private static string Inline(JsonElement node) => StringAt(node, "type") switch + { + "text" => StringAt(node, "text") ?? string.Empty, + "hardBreak" => "\n", + "mention" or "emoji" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "text") ?? string.Empty : string.Empty, + "inlineCard" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "url") ?? string.Empty : string.Empty, + _ => string.Concat(Children(node).Select(Inline)), + }; + + private static JsonElement[] Children(JsonElement node) + => node.ValueKind == JsonValueKind.Object && node.TryGetProperty("content", out JsonElement content) && content.ValueKind == JsonValueKind.Array + ? [.. content.EnumerateArray()] + : []; +} diff --git a/PanoramicData.IssueAssessment/Mapping/JiraIssueMapper.cs b/PanoramicData.IssueAssessment/Mapping/JiraIssueMapper.cs index aa928fd..9ba5fb3 100644 --- a/PanoramicData.IssueAssessment/Mapping/JiraIssueMapper.cs +++ b/PanoramicData.IssueAssessment/Mapping/JiraIssueMapper.cs @@ -1,5 +1,3 @@ -using System.Collections.Frozen; -using System.Text; using static PanoramicData.IssueAssessment.Mapping.JsonReading; namespace PanoramicData.IssueAssessment.Mapping; @@ -15,18 +13,6 @@ namespace PanoramicData.IssueAssessment.Mapping; /// public static class JiraIssueMapper { - // ADF list types, and the wiki marker each nesting level adds. - private static readonly FrozenDictionary ListMarkers = new Dictionary(StringComparer.Ordinal) - { - ["bulletList"] = "*", - ["orderedList"] = "#", - }.ToFrozenDictionary(StringComparer.Ordinal); - - // ADF nodes written as one line of inline text. - private static readonly FrozenSet LineBlockTypes = FrozenSet.Create( - StringComparer.Ordinal, - "paragraph", "blockquote", "panel", "text", "mention", "inlineCard", "hardBreak"); - /// /// Maps a Jira REST issue. /// @@ -92,9 +78,7 @@ private static int ArrayLength(JsonElement element, string name) /// The text. public static string FlattenAdf(JsonElement document) { - StringBuilder text = new(); - AppendBlock(document, text, string.Empty); - return text.ToString().TrimEnd(); + return AdfFlattener.Flatten(document); } private static string? Description(JsonElement fields) @@ -105,79 +89,6 @@ public static string FlattenAdf(JsonElement document) _ => null, } : null; - private static void AppendBlock(JsonElement node, StringBuilder text, string listPrefix) - { - string type = StringAt(node, "type") ?? string.Empty; - if (ListMarkers.TryGetValue(type, out string? listMarker)) - { - AppendList(node, text, listPrefix + listMarker); - } - else if (type == "heading") - { - text.Append(CultureInfo.InvariantCulture, $"h{AdfHeadingLevel(node)}. ").Append(Inline(node)).Append('\n'); - } - else if (type == "codeBlock") - { - text.Append("{code}\n").Append(Inline(node)).Append("\n{code}\n"); - } - else if (LineBlockTypes.Contains(type)) - { - text.Append(Inline(node)).Append('\n'); - } - else - { - foreach (JsonElement child in Children(node)) - { - AppendBlock(child, text, listPrefix); - } - } - } - - private static void AppendList(JsonElement list, StringBuilder text, string marker) - { - foreach (JsonElement item in Children(list)) - { - AppendListItem(item, text, marker); - } - } - - private static int AdfHeadingLevel(JsonElement heading) - => heading.TryGetProperty("attrs", out JsonElement attrs) && attrs.TryGetProperty("level", out JsonElement value) && value.TryGetInt32(out int level) - ? level - : 2; - - private static void AppendListItem(JsonElement item, StringBuilder text, string marker) - { - bool first = true; - foreach (JsonElement child in Children(item)) - { - if (StringAt(child, "type") is "bulletList" or "orderedList") - { - AppendBlock(child, text, marker); - } - else - { - text.Append(first ? marker + " " : string.Empty).Append(Inline(child)).Append('\n'); - first = false; - } - } - } - - private static string Inline(JsonElement node) => StringAt(node, "type") switch - { - "text" => StringAt(node, "text") ?? string.Empty, - "hardBreak" => "\n", - "mention" or "emoji" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "text") ?? string.Empty : string.Empty, - "inlineCard" => node.TryGetProperty("attrs", out JsonElement attrs) ? StringAt(attrs, "url") ?? string.Empty : string.Empty, - _ => string.Concat(Children(node).Select(Inline)), - }; - - private static JsonElement[] Children(JsonElement node) - => node.ValueKind == JsonValueKind.Object && node.TryGetProperty("content", out JsonElement content) && content.ValueKind == JsonValueKind.Array - ? [.. content.EnumerateArray()] - : []; - - private static ImmutableList Names(JsonElement fields, string property) => fields.TryGetProperty(property, out JsonElement values) && values.ValueKind == JsonValueKind.Array ? [.. values.EnumerateArray().Select(value => StringAt(value, "name")).OfType()] diff --git a/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs b/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs new file mode 100644 index 0000000..4087017 --- /dev/null +++ b/PanoramicData.IssueAssessment/Text/IssueText.Panels.cs @@ -0,0 +1,82 @@ +using System.Text.RegularExpressions; + +namespace PanoramicData.IssueAssessment.Text; + +/// +/// Reads how Jira wiki sections are laid out in panels. +/// +public static partial class IssueText +{ + /// + /// Reads how the h2. sections of Jira wiki text are laid out in panels. + /// + /// The ticket text. + /// The headings of the sections that could be panelled, are not, or carry a repeated panel title. + /// + /// A section is panelled when its first line is {panel}. It could be panelled when it has content and no table + /// or code block, since those do not sit well in a panel. A panel directly under a heading should carry no + /// title=, because the title bar only repeats the heading above it. + /// + public static PanelLayout PanelLayoutOf(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return PanelLayout.Empty; + } + + List panelable = []; + List unpanelled = []; + List titled = []; + string? heading = null; + List body = []; + foreach (string line in LineBreak.Split(text)) + { + Match opening = PanelSectionHeading.Match(line); + if (opening.Success) + { + Classify(heading, body, panelable, unpanelled, titled); + heading = opening.Groups[1].Value == "2" ? opening.Groups[2].Value.Trim() : null; + body = []; + } + else if (heading is not null) + { + body.Add(line); + } + } + + Classify(heading, body, panelable, unpanelled, titled); + return new PanelLayout(panelable, unpanelled, titled); + } + + private static void Classify(string? heading, List body, List panelable, List unpanelled, List titled) + { + if (heading is null) + { + return; + } + + string? first = body.Find(line => line.Trim().Length > 0); + Match panel = first is null ? Match.Empty : PanelOpening.Match(first); + if (panel.Success) + { + panelable.Add(heading); + AddIf(titled, heading, panel.Groups[1].Value.Contains("title=", StringComparison.OrdinalIgnoreCase)); + return; + } + + bool hasContent = first is not null && !IsPlaceholder(string.Join('\n', body)); + if (hasContent && !body.Exists(line => PanelBlocker.IsMatch(line))) + { + panelable.Add(heading); + unpanelled.Add(heading); + } + } + + private static void AddIf(List list, string item, bool condition) + { + if (condition) + { + list.Add(item); + } + } +} diff --git a/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs b/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs new file mode 100644 index 0000000..fe309ab --- /dev/null +++ b/PanoramicData.IssueAssessment/Text/IssueText.Patterns.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; + +namespace PanoramicData.IssueAssessment.Text; + +/// +/// The regular expressions the readers use. +/// +public static partial class IssueText +{ + private const RegexOptions Options = RegexOptions.CultureInvariant; + private static readonly TimeSpan Timeout = RegexCache.MatchTimeout; + + private static readonly Regex WikiHeadingAnywhere = new(@"(?im)^[ \t]*h[1-6]\.", Options, Timeout); + private static readonly Regex WikiHeadingLevel = new(@"(?im)^[ \t]*h([1-6])\.", Options, Timeout); + private static readonly Regex AtxHeadingLevel = new(@"(?im)^[ \t]*(#{1,6})[ \t]", Options, Timeout); + private static readonly Regex StartsWithHash = new(@"^[ \t]*#", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex StartsWithSingleHashItem = new(@"^[ \t]*#[ \t]", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex HashListLine = new(@"^[ \t]*#+[ \t]", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex StartsWithWiki = new(@"^[ \t]*h[1-6]\.", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex StartsWithDoubleAsterisk = new(@"^[ \t]*\*\*", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex StartsWithAsterisk = new(@"^[ \t]*\*", Options | RegexOptions.IgnoreCase, Timeout); + private static readonly Regex LineBreak = new(@"\r?\n", Options, Timeout); + + private static readonly Regex WikiNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{2,6}[ \t])", Options, Timeout); + private static readonly Regex AtxNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t])", Options, Timeout); + private static readonly Regex Bold2Next = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t]|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$)", Options, Timeout); + private static readonly Regex Bold1Next = new(@"(?im)^[ \t]*(?:h[1-6]\.|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$)", Options, Timeout); + private static readonly Regex LabelNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$|(?=[A-Za-z])[^\r\n:#*]{1,60}:[ \t]*\r?$)", Options, Timeout); + + private static readonly Regex CriteriaHeadingLine = new(@"^[ \t]*(?:h([1-6])\.|(#{1,6})[ \t]|\*{1,2}(?=[^*\r\n]+\*{1,2}[ \t]*:?[ \t]*$))(.*)$", Options, Timeout); + private static readonly Regex CriteriaHeadingText = new(@"(?i)criteria|definition\s+of\s+done|verification\s+evidence", Options, Timeout); + private static readonly Regex CriteriaMappingTable = new(@"(?im)^[ \t]*\|.*criteri.*\|.*\btests?\b", Options, Timeout); + + 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 PlaceholderListMarker = new(@"(?m)^[ \t]*(?:[-*#>]+|\d+\.)[ \t]*", Options, Timeout); + private static readonly Regex PlaceholderPunctuation = new(@"[\*_`{}\[\]\(\)\|:;,\.\-–—/\\]", Options, Timeout); + private static readonly Regex PlaceholderWords = new(@"^\s*(tbd|to\s*be\s*(done|decided|determined|confirmed|advised)|n\s*/?\s*a|none|nil|todo|to\s*do|pending|unknown|see\s+above)\s*$", Options | RegexOptions.IgnoreCase, Timeout); + + private static readonly Regex Whitespace = new(@"\s+", Options, Timeout); + private static readonly Regex Alphanumeric = new("[A-Za-z0-9]", Options, Timeout); +} diff --git a/PanoramicData.IssueAssessment/Text/IssueText.Sections.cs b/PanoramicData.IssueAssessment/Text/IssueText.Sections.cs new file mode 100644 index 0000000..74fc209 --- /dev/null +++ b/PanoramicData.IssueAssessment/Text/IssueText.Sections.cs @@ -0,0 +1,109 @@ +using System.Text.RegularExpressions; + +namespace PanoramicData.IssueAssessment.Text; + +/// +/// Section and heading helpers behind and . +/// +public static partial class IssueText +{ + /// + /// Every heading line, with its index and level; in wiki text a single # line is a list item, not a heading. + /// + private static List<(int Index, int Level, string Text)> Headings(string[] lines, bool isWiki) + { + List<(int Index, int Level, string Text)> headings = []; + for (int index = 0; index < lines.Length; index++) + { + Match match = CriteriaHeadingLine.Match(lines[index]); + if (match.Success && !(isWiki && StartsWithSingleHashItem.IsMatch(lines[index]))) + { + headings.Add((index, HeadingLevel(match), match.Groups[3].Value.Trim().Trim('*').Trim().TrimEnd(':').Trim())); + } + } + + return headings; + } + + private static int HeadingLevel(Match criteriaHeading) + { + if (criteriaHeading.Groups[1].Success) + { + return int.Parse(criteriaHeading.Groups[1].Value, CultureInfo.InvariantCulture); + } + + return criteriaHeading.Groups[2].Success ? criteriaHeading.Groups[2].Value.Length : 7; + } + + private static bool IsWikiTextOpening(string text, Match opening) + { + if (!StartsWithHash.IsMatch(opening.Value)) + { + return true; + } + + return !StartsWithSingleHashItem.IsMatch(opening.Value) && !PreviousLineIsHashList(text[..opening.Index]); + } + + /// + /// Cuts at the line that closes the section opened. + /// + private static string SectionAfter(string opener, string rest) + { + bool openedByWiki = StartsWithWiki.IsMatch(opener); + if (openedByWiki || StartsWithHash.IsMatch(opener)) + { + return LevelledSectionAfter(opener, rest, openedByWiki); + } + + Regex closer = StartsWithDoubleAsterisk.IsMatch(opener) ? Bold2Next + : StartsWithAsterisk.IsMatch(opener) ? Bold1Next + : LabelNext; + Match next = closer.Match(rest); + return next.Success ? rest[..next.Index] : rest; + } + + /// + /// Cuts at the next heading of the same level as or stronger, never + /// at a weaker one. + /// + private static string LevelledSectionAfter(string opener, string rest, bool openedByWiki) + { + int openLevel = HeadingLevel(opener); + foreach (Match candidate in (openedByWiki ? WikiNext : AtxNext).Matches(rest)) + { + // A "## " line continuing a "#" list is a nested wiki list item, not a Markdown heading. + bool continuesWikiList = openedByWiki && StartsWithHash.IsMatch(candidate.Value) && PreviousLineIsHashList(rest[..candidate.Index]); + if (!continuesWikiList && HeadingLevel(candidate.Value) <= openLevel) + { + return rest[..candidate.Index]; + } + } + + return rest; + } + + private static bool PreviousLineIsHashList(string before) + { + string? previous = LineBreak.Split(before).LastOrDefault(line => line.Trim().Length > 0); + return previous is not null && HashListLine.IsMatch(previous); + } + + private static bool IsMappingTableSection(string[] lines, List<(int Index, int Level, string Text)> headings, int current) + { + (int index, int level, _) = headings[current]; + + // The section runs to the next heading of the same or a higher level. + int end = lines.Length; + for (int next = current + 1; next < headings.Count; next++) + { + if (headings[next].Level <= level) + { + end = headings[next].Index; + break; + } + } + + return end > index + 1 && CriteriaMappingTable.IsMatch(string.Join('\n', lines[(index + 1)..end])); + } +} diff --git a/PanoramicData.IssueAssessment/Text/IssueText.cs b/PanoramicData.IssueAssessment/Text/IssueText.cs index b22dfed..cf18abf 100644 --- a/PanoramicData.IssueAssessment/Text/IssueText.cs +++ b/PanoramicData.IssueAssessment/Text/IssueText.cs @@ -11,43 +11,8 @@ namespace PanoramicData.IssueAssessment.Text; /// library reads a ticket exactly as Yoda does. Jira returns descriptions with CRLF line endings, and every pattern /// here accepts both CRLF and LF. /// -public static class IssueText +public static partial class IssueText { - private const RegexOptions Options = RegexOptions.CultureInvariant; - private static readonly TimeSpan Timeout = RegexCache.MatchTimeout; - - private static readonly Regex WikiHeadingAnywhere = new(@"(?im)^[ \t]*h[1-6]\.", Options, Timeout); - private static readonly Regex WikiHeadingLevel = new(@"(?im)^[ \t]*h([1-6])\.", Options, Timeout); - private static readonly Regex AtxHeadingLevel = new(@"(?im)^[ \t]*(#{1,6})[ \t]", Options, Timeout); - private static readonly Regex StartsWithHash = new(@"^[ \t]*#", Options | RegexOptions.IgnoreCase, Timeout); - private static readonly Regex StartsWithSingleHashItem = new(@"^[ \t]*#[ \t]", Options | RegexOptions.IgnoreCase, Timeout); - private static readonly Regex HashListLine = new(@"^[ \t]*#+[ \t]", Options | RegexOptions.IgnoreCase, Timeout); - private static readonly Regex StartsWithWiki = new(@"^[ \t]*h[1-6]\.", Options | RegexOptions.IgnoreCase, Timeout); - private static readonly Regex StartsWithDoubleAsterisk = new(@"^[ \t]*\*\*", Options | RegexOptions.IgnoreCase, Timeout); - private static readonly Regex StartsWithAsterisk = new(@"^[ \t]*\*", Options | RegexOptions.IgnoreCase, Timeout); - private static readonly Regex LineBreak = new(@"\r?\n", Options, Timeout); - - private static readonly Regex WikiNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{2,6}[ \t])", Options, Timeout); - private static readonly Regex AtxNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t])", Options, Timeout); - private static readonly Regex Bold2Next = new(@"(?im)^[ \t]*(?:h[1-6]\.|#{1,6}[ \t]|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$)", Options, Timeout); - private static readonly Regex Bold1Next = new(@"(?im)^[ \t]*(?:h[1-6]\.|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$)", Options, Timeout); - private static readonly Regex LabelNext = new(@"(?im)^[ \t]*(?:h[1-6]\.|\*{1,2}[^\r\n*]+\*{1,2}[ \t]*\r?$|(?=[A-Za-z])[^\r\n:#*]{1,60}:[ \t]*\r?$)", Options, Timeout); - - private static readonly Regex CriteriaHeadingLine = new(@"^[ \t]*(?:h([1-6])\.|(#{1,6})[ \t]|\*{1,2}(?=[^*\r\n]+\*{1,2}[ \t]*:?[ \t]*$))(.*)$", Options, Timeout); - private static readonly Regex CriteriaHeadingText = new(@"(?i)criteria|definition\s+of\s+done|verification\s+evidence", Options, Timeout); - private static readonly Regex CriteriaMappingTable = new(@"(?im)^[ \t]*\|.*criteri.*\|.*\btests?\b", Options, Timeout); - - 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 PlaceholderListMarker = new(@"(?m)^[ \t]*(?:[-*#>]+|\d+\.)[ \t]*", Options, Timeout); - private static readonly Regex PlaceholderPunctuation = new(@"[\*_`{}\[\]\(\)\|:;,\.\-–—/\\]", Options, Timeout); - private static readonly Regex PlaceholderWords = new(@"^\s*(tbd|to\s*be\s*(done|decided|determined|confirmed|advised)|n\s*/?\s*a|none|nil|todo|to\s*do|pending|unknown|see\s+above)\s*$", Options | RegexOptions.IgnoreCase, Timeout); - - private static readonly Regex Whitespace = new(@"\s+", Options, Timeout); - private static readonly Regex Alphanumeric = new("[A-Za-z0-9]", Options, Timeout); - /// /// Returns the body text under the best heading whose text matches , or /// null when there is no such heading. @@ -136,79 +101,6 @@ public static IReadOnlyList CriteriaHeadings(string? text) return found; } - /// - /// Reads how the h2. sections of Jira wiki text are laid out in panels. - /// - /// The ticket text. - /// The headings of the sections that could be panelled, are not, or carry a repeated panel title. - /// - /// A section is panelled when its first line is {panel}. It could be panelled when it has content and no table - /// or code block, since those do not sit well in a panel. A panel directly under a heading should carry no - /// title=, because the title bar only repeats the heading above it. - /// - public static PanelLayout PanelLayoutOf(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - { - return PanelLayout.Empty; - } - - List panelable = []; - List unpanelled = []; - List titled = []; - string? heading = null; - List body = []; - foreach (string line in LineBreak.Split(text)) - { - Match opening = PanelSectionHeading.Match(line); - if (opening.Success) - { - Classify(heading, body, panelable, unpanelled, titled); - heading = opening.Groups[1].Value == "2" ? opening.Groups[2].Value.Trim() : null; - body = []; - } - else if (heading is not null) - { - body.Add(line); - } - } - - Classify(heading, body, panelable, unpanelled, titled); - return new PanelLayout(panelable, unpanelled, titled); - } - - private static void Classify(string? heading, List body, List panelable, List unpanelled, List titled) - { - if (heading is null) - { - return; - } - - string? first = body.Find(line => line.Trim().Length > 0); - Match panel = first is null ? Match.Empty : PanelOpening.Match(first); - if (panel.Success) - { - panelable.Add(heading); - AddIf(titled, heading, panel.Groups[1].Value.Contains("title=", StringComparison.OrdinalIgnoreCase)); - return; - } - - bool hasContent = first is not null && !IsPlaceholder(string.Join('\n', body)); - if (hasContent && !body.Exists(line => PanelBlocker.IsMatch(line))) - { - panelable.Add(heading); - unpanelled.Add(heading); - } - } - - private static void AddIf(List list, string item, bool condition) - { - if (condition) - { - list.Add(item); - } - } - /// /// True when a section body is a placeholder (TBD, N/A, none, to do, pending, ...) or blank, rather than content. /// @@ -260,104 +152,4 @@ public static int HeadingLevel(string? headingLine) Match atx = AtxHeadingLevel.Match(headingLine); return atx.Success ? atx.Groups[1].Value.Length : 7; } - - /// - /// Every heading line, with its index and level; in wiki text a single # line is a list item, not a heading. - /// - private static List<(int Index, int Level, string Text)> Headings(string[] lines, bool isWiki) - { - List<(int Index, int Level, string Text)> headings = []; - for (int index = 0; index < lines.Length; index++) - { - Match match = CriteriaHeadingLine.Match(lines[index]); - if (match.Success && !(isWiki && StartsWithSingleHashItem.IsMatch(lines[index]))) - { - headings.Add((index, HeadingLevel(match), match.Groups[3].Value.Trim().Trim('*').Trim().TrimEnd(':').Trim())); - } - } - - return headings; - } - - private static int HeadingLevel(Match criteriaHeading) - { - if (criteriaHeading.Groups[1].Success) - { - return int.Parse(criteriaHeading.Groups[1].Value, CultureInfo.InvariantCulture); - } - - return criteriaHeading.Groups[2].Success ? criteriaHeading.Groups[2].Value.Length : 7; - } - - private static bool IsWikiTextOpening(string text, Match opening) - { - if (!StartsWithHash.IsMatch(opening.Value)) - { - return true; - } - - return !StartsWithSingleHashItem.IsMatch(opening.Value) && !PreviousLineIsHashList(text[..opening.Index]); - } - - /// - /// Cuts at the line that closes the section opened. - /// - private static string SectionAfter(string opener, string rest) - { - bool openedByWiki = StartsWithWiki.IsMatch(opener); - if (openedByWiki || StartsWithHash.IsMatch(opener)) - { - return LevelledSectionAfter(opener, rest, openedByWiki); - } - - Regex closer = StartsWithDoubleAsterisk.IsMatch(opener) ? Bold2Next - : StartsWithAsterisk.IsMatch(opener) ? Bold1Next - : LabelNext; - Match next = closer.Match(rest); - return next.Success ? rest[..next.Index] : rest; - } - - /// - /// Cuts at the next heading of the same level as or stronger, never - /// at a weaker one. - /// - private static string LevelledSectionAfter(string opener, string rest, bool openedByWiki) - { - int openLevel = HeadingLevel(opener); - foreach (Match candidate in (openedByWiki ? WikiNext : AtxNext).Matches(rest)) - { - // A "## " line continuing a "#" list is a nested wiki list item, not a Markdown heading. - bool continuesWikiList = openedByWiki && StartsWithHash.IsMatch(candidate.Value) && PreviousLineIsHashList(rest[..candidate.Index]); - if (!continuesWikiList && HeadingLevel(candidate.Value) <= openLevel) - { - return rest[..candidate.Index]; - } - } - - return rest; - } - - private static bool PreviousLineIsHashList(string before) - { - string? previous = LineBreak.Split(before).LastOrDefault(line => line.Trim().Length > 0); - return previous is not null && HashListLine.IsMatch(previous); - } - - private static bool IsMappingTableSection(string[] lines, List<(int Index, int Level, string Text)> headings, int current) - { - (int index, int level, _) = headings[current]; - - // The section runs to the next heading of the same or a higher level. - int end = lines.Length; - for (int next = current + 1; next < headings.Count; next++) - { - if (headings[next].Level <= level) - { - end = headings[next].Index; - break; - } - } - - return end > index + 1 && CriteriaMappingTable.IsMatch(string.Join('\n', lines[(index + 1)..end])); - } } diff --git a/PanoramicData.IssueAssessment/Validation/RuleValidator.Cycles.cs b/PanoramicData.IssueAssessment/Validation/RuleValidator.Cycles.cs new file mode 100644 index 0000000..5564b70 --- /dev/null +++ b/PanoramicData.IssueAssessment/Validation/RuleValidator.Cycles.cs @@ -0,0 +1,55 @@ +namespace PanoramicData.IssueAssessment.Validation; + +/// +/// Finds named conditions that refer to themselves. +/// +internal static partial class RuleValidator +{ + /// + /// The first named condition that refers to itself, directly or through others. + /// + public static string? CycleError(IReadOnlyDictionary conditions) + { + foreach (string name in conditions.Keys) + { + if (ReachesItself(name, conditions)) + { + return $"Condition '{name}' refers to itself."; + } + } + + return null; + } + + private static bool ReachesItself(string start, IReadOnlyDictionary conditions) + { + Stack pending = new(References(conditions[start])); + HashSet seen = []; + while (pending.TryPop(out string? name)) + { + if (name == start) + { + return true; + } + + if (seen.Add(name) && conditions.TryGetValue(name, out ICondition? next)) + { + foreach (string reference in References(next)) + { + pending.Push(reference); + } + } + } + + return false; + } + + private static IEnumerable References(ICondition? condition) => condition switch + { + ConditionReference reference => [reference.Name], + AllCondition all => all.Conditions.SelectMany(References), + AnyCondition any => any.Conditions.SelectMany(References), + NotCondition not => References(not.Condition), + _ => [], + }; +} diff --git a/PanoramicData.IssueAssessment/Validation/RuleValidator.Measures.cs b/PanoramicData.IssueAssessment/Validation/RuleValidator.Measures.cs new file mode 100644 index 0000000..e545142 --- /dev/null +++ b/PanoramicData.IssueAssessment/Validation/RuleValidator.Measures.cs @@ -0,0 +1,38 @@ +namespace PanoramicData.IssueAssessment.Validation; + +/// +/// Validates measures and the text sources and pattern lists they read. +/// +internal static partial class RuleValidator +{ + public static string? MeasureError(IMeasure? measure) => measure switch + { + null => "has a missing measure.", + PatternMatchCountMeasure value => SourceError(value.Source) ?? PatternsError(value.Patterns), + OccurrenceCountMeasure value => SourceError(value.Source) ?? PatternError(value.Pattern), + WordCountMeasure value => SourceError(value.Source), + AlphanumericCountMeasure value => SourceError(value.Source), + LengthMeasure value => SourceError(value.Source), + CriteriaHeadingCountMeasure value => SourceError(value.Source), + SectionPanelCountMeasure value => SourceError(value.Source), + ItemCountMeasure value => PathError(value.Path), + NumberMeasure value => PathError(value.Path), + _ => null, + }; + + private static string? SourceError(TextSource? source) + { + if (source?.Paths is not { Count: > 0 }) + { + return "has a text source without paths."; + } + + return source.Paths.Select(PathError).FirstOrDefault(error => error is not null) + ?? (source.Section is null ? null : PatternError(source.Section)); + } + + private static string? PatternsError(ImmutableList? patterns) + => patterns is not { Count: > 0 } + ? "has no patterns." + : patterns.Select(PatternError).FirstOrDefault(error => error is not null); +} diff --git a/PanoramicData.IssueAssessment/Validation/RuleValidator.Syntax.cs b/PanoramicData.IssueAssessment/Validation/RuleValidator.Syntax.cs new file mode 100644 index 0000000..0b97d64 --- /dev/null +++ b/PanoramicData.IssueAssessment/Validation/RuleValidator.Syntax.cs @@ -0,0 +1,38 @@ +namespace PanoramicData.IssueAssessment.Validation; + +/// +/// Validates JSONPath and regex syntax. +/// +internal static partial class RuleValidator +{ + public static string? PathError(string? jsonPath) + { + try + { + JsonPathHelper.Validate(jsonPath!); + return null; + } + catch (InvalidJsonPathException) + { + return $"has invalid JSONPath '{jsonPath}'."; + } + } + + public static string? PatternError(string? pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + return "has an empty pattern."; + } + + try + { + _ = RegexCache.GetOrAdd(pattern); + return null; + } + catch (ArgumentException) + { + return $"has invalid regex pattern '{pattern}'."; + } + } +} diff --git a/PanoramicData.IssueAssessment/Validation/RuleValidator.cs b/PanoramicData.IssueAssessment/Validation/RuleValidator.cs index 7abc819..a54e8ee 100644 --- a/PanoramicData.IssueAssessment/Validation/RuleValidator.cs +++ b/PanoramicData.IssueAssessment/Validation/RuleValidator.cs @@ -16,7 +16,7 @@ internal sealed record RuleScope( /// Validates conditions, measures and text sources. Custom and /// implementations are not inspected. /// -internal static class RuleValidator +internal static partial class RuleValidator { public static string? ConditionError(ICondition? condition, RuleScope scope) => condition switch { @@ -32,68 +32,6 @@ internal static class RuleValidator _ => FieldConditionError(condition), }; - public static string? MeasureError(IMeasure? measure) => measure switch - { - null => "has a missing measure.", - PatternMatchCountMeasure value => SourceError(value.Source) ?? PatternsError(value.Patterns), - OccurrenceCountMeasure value => SourceError(value.Source) ?? PatternError(value.Pattern), - WordCountMeasure value => SourceError(value.Source), - AlphanumericCountMeasure value => SourceError(value.Source), - LengthMeasure value => SourceError(value.Source), - CriteriaHeadingCountMeasure value => SourceError(value.Source), - SectionPanelCountMeasure value => SourceError(value.Source), - ItemCountMeasure value => PathError(value.Path), - NumberMeasure value => PathError(value.Path), - _ => null, - }; - - /// - /// The first named condition that refers to itself, directly or through others. - /// - public static string? CycleError(IReadOnlyDictionary conditions) - { - foreach (string name in conditions.Keys) - { - if (ReachesItself(name, conditions)) - { - return $"Condition '{name}' refers to itself."; - } - } - - return null; - } - - public static string? PathError(string? jsonPath) - { - try - { - JsonPathHelper.Validate(jsonPath!); - return null; - } - catch (InvalidJsonPathException) - { - return $"has invalid JSONPath '{jsonPath}'."; - } - } - - public static string? PatternError(string? pattern) - { - if (string.IsNullOrWhiteSpace(pattern)) - { - return "has an empty pattern."; - } - - try - { - _ = RegexCache.GetOrAdd(pattern); - return null; - } - catch (ArgumentException) - { - return $"has invalid regex pattern '{pattern}'."; - } - } - private static string? FieldConditionError(ICondition condition) => condition switch { TextMatchesCondition value => SourceError(value.Source) ?? PatternsError(value.Patterns), @@ -131,52 +69,4 @@ internal static class RuleValidator return scope.AssessmentNames.Contains(condition.Assessment) ? null : $"refers to unknown assessment '{condition.Assessment}'."; } - - private static string? SourceError(TextSource? source) - { - if (source?.Paths is not { Count: > 0 }) - { - return "has a text source without paths."; - } - - return source.Paths.Select(PathError).FirstOrDefault(error => error is not null) - ?? (source.Section is null ? null : PatternError(source.Section)); - } - - private static string? PatternsError(ImmutableList? patterns) - => patterns is not { Count: > 0 } - ? "has no patterns." - : patterns.Select(PatternError).FirstOrDefault(error => error is not null); - - private static bool ReachesItself(string start, IReadOnlyDictionary conditions) - { - Stack pending = new(References(conditions[start])); - HashSet seen = []; - while (pending.TryPop(out string? name)) - { - if (name == start) - { - return true; - } - - if (seen.Add(name) && conditions.TryGetValue(name, out ICondition? next)) - { - foreach (string reference in References(next)) - { - pending.Push(reference); - } - } - } - - return false; - } - - private static IEnumerable References(ICondition? condition) => condition switch - { - ConditionReference reference => [reference.Name], - AllCondition all => all.Conditions.SelectMany(References), - AnyCondition any => any.Conditions.SelectMany(References), - NotCondition not => References(not.Condition), - _ => [], - }; }