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),
- _ => [],
- };
}