From 148fd08b1d5604177637307d82e6b83079642d63 Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 21 Feb 2026 19:06:30 +0100 Subject: [PATCH] Add span validation/update APIs and tests --- .../TestExtensionSpanCoverage.cs | 373 ++++++++++++++++++ .../TestParserAuthoringHelpers.cs | 42 ++ src/Markdig.Tests/TestSourcePosition.cs | 6 +- src/Markdig.Tests/TestSpanConsistency.cs | 93 +++++ .../DefinitionLists/DefinitionListParser.cs | 4 +- .../JiraLinks/JiraLinkInlineParser.cs | 15 +- src/Markdig/Parsers/InlineProcessor.cs | 23 ++ src/Markdig/Syntax/Block.cs | 39 ++ src/Markdig/Syntax/ContainerBlock.cs | 151 +++++++ src/Markdig/Syntax/Inlines/ContainerInline.cs | 109 +++++ 10 files changed, 844 insertions(+), 11 deletions(-) create mode 100644 src/Markdig.Tests/TestExtensionSpanCoverage.cs create mode 100644 src/Markdig.Tests/TestSpanConsistency.cs diff --git a/src/Markdig.Tests/TestExtensionSpanCoverage.cs b/src/Markdig.Tests/TestExtensionSpanCoverage.cs new file mode 100644 index 00000000..619b6fdd --- /dev/null +++ b/src/Markdig.Tests/TestExtensionSpanCoverage.cs @@ -0,0 +1,373 @@ +using Markdig.Extensions.Abbreviations; +using Markdig.Extensions.Alerts; +using Markdig.Extensions.DefinitionLists; +using Markdig.Extensions.Emoji; +using Markdig.Extensions.Figures; +using Markdig.Extensions.Footers; +using Markdig.Extensions.Footnotes; +using Markdig.Extensions.JiraLinks; +using Markdig.Extensions.Mathematics; +using Markdig.Extensions.SmartyPants; +using Markdig.Extensions.Tables; +using Markdig.Extensions.TaskLists; +using Markdig.Extensions.Yaml; +using Markdig.Renderers.Html; +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Markdig.Tests; + +[TestFixture] +public sealed class TestExtensionSpanCoverage +{ + public sealed class ExtensionSpanCase( + string name, + Action configurePipeline, + string markdown, + Action validate, + bool validateSpanTree = true) + { + public string Name { get; } = name; + public Action ConfigurePipeline { get; } = configurePipeline; + public string Markdown { get; } = markdown; + public Action Validate { get; } = validate; + public bool ValidateSpanTree { get; } = validateSpanTree; + } + + [TestCaseSource(nameof(GetExtensionSpanCases))] + public void ExtensionSpanTreeIsValid(ExtensionSpanCase testCase) + { + var builder = new MarkdownPipelineBuilder + { + PreciseSourceLocation = true + }; + testCase.ConfigurePipeline(builder); + + var document = Markdown.Parse(testCase.Markdown.ReplaceLineEndings("\n"), builder.Build()); + + if (testCase.ValidateSpanTree) + { + Assert.That(document.HasValidSpan(recursive: true), Is.True, $"{testCase.Name} has invalid container spans"); + } + + testCase.Validate?.Invoke(document); + } + + [Test] + public void JiraLinkSpanMatchesToken() + { + var pipeline = new MarkdownPipelineBuilder + { + PreciseSourceLocation = true + }.UseJiraLinks(new JiraLinkOptions("https://jira.example.com")).Build(); + + var document = Markdown.Parse("ABC-123", pipeline); + var jiraLink = document.Descendants().FirstOrDefault(); + Assert.That(jiraLink, Is.Not.Null); + Assert.That(jiraLink!.Span, Is.EqualTo(new SourceSpan(0, 6))); + + var literal = jiraLink.FirstChild as LiteralInline; + Assert.That(literal, Is.Not.Null); + Assert.That(literal!.Span, Is.EqualTo(new SourceSpan(0, 6))); + } + + [Test] + public void TaskListSpanMatchesCheckboxToken() + { + var pipeline = new MarkdownPipelineBuilder + { + PreciseSourceLocation = true + }.UseTaskLists().Build(); + + var document = Markdown.Parse("- [x] done", pipeline); + var task = document.Descendants().FirstOrDefault(); + Assert.That(task, Is.Not.Null); + Assert.That(task!.Span, Is.EqualTo(new SourceSpan(2, 4))); + } + + [Test] + public void AlertBlockSpanCoversSourceQuote() + { + var pipeline = new MarkdownPipelineBuilder + { + PreciseSourceLocation = true + }.UseAlertBlocks().Build(); + + var document = Markdown.Parse("> [!NOTE]\n> body", pipeline); + var alert = document.Descendants().FirstOrDefault(); + Assert.That(alert, Is.Not.Null); + Assert.That(alert!.Span.Start, Is.EqualTo(0)); + Assert.That(alert.Span.End, Is.GreaterThan(0)); + + var paragraph = alert.Descendants().FirstOrDefault(); + Assert.That(paragraph, Is.Not.Null); + Assert.That(paragraph!.Span.Start, Is.GreaterThanOrEqualTo(alert.Span.Start)); + Assert.That(paragraph.Span.End, Is.LessThanOrEqualTo(alert.Span.End)); + } + + [Test] + public void YamlFrontMatterSpanCoversFrontMatterContent() + { + var pipeline = new MarkdownPipelineBuilder + { + PreciseSourceLocation = true + }.UseYamlFrontMatter().Build(); + + var document = Markdown.Parse("---\na: 1\n---\ntext", pipeline); + var yaml = document.Descendants().FirstOrDefault(); + Assert.That(yaml, Is.Not.Null); + Assert.That(yaml!.Span.Start, Is.EqualTo(0)); + Assert.That(yaml.Span.End, Is.GreaterThanOrEqualTo(7)); + } + + private static IEnumerable GetExtensionSpanCases() + { + yield return Case( + "AlertBlocks", + builder => builder.UseAlertBlocks(), + "> [!NOTE]\n> body", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "AutoLinks", + builder => builder.UseAutoLinks(), + "http://example.com", + document => + { + var autoLink = document.Descendants().FirstOrDefault(link => link.IsAutoLink); + Assert.That(autoLink, Is.Not.Null); + Assert.That(autoLink!.Span.IsEmpty, Is.False); + Assert.That(autoLink.UrlSpan, Is.EqualTo(autoLink.Span)); + }); + + yield return Case( + "NonAsciiNoEscape", + builder => builder.UseNonAsciiNoEscape(), + "Café"); + + yield return Case( + "YamlFrontMatter", + builder => builder.UseYamlFrontMatter(), + "---\na: 1\n---\ntext", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "SelfPipeline", + builder => builder.UseSelfPipeline(), + "\n- [x] done", + document => AssertNodesHaveNonEmptySpan(document), + validateSpanTree: false); + + yield return Case( + "PragmaLines", + builder => builder.UsePragmaLines(), + "# Heading\n\nParagraph"); + + yield return Case( + "Diagrams", + builder => builder.UseDiagrams(), + "```mermaid\ngraph TD;\n```"); + + yield return Case( + "TaskLists", + builder => builder.UseTaskLists(), + "- [x] done", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "CustomContainers", + builder => builder.UseCustomContainers(), + ":::\nvalue\n:::", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "MediaLinks", + builder => builder.UseMediaLinks(), + "[video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "AutoIdentifiers", + builder => builder.UseAutoIdentifiers(), + "# Heading", + document => + { + var heading = document.Descendants().FirstOrDefault(); + Assert.That(heading, Is.Not.Null); + Assert.That(heading!.GetAttributes().Id, Is.Not.Null.And.Not.Empty); + }); + + yield return Case( + "SmartyPants", + builder => builder.UseSmartyPants(), + "<>", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "Bootstrap", + builder => builder.UseBootstrap(), + "![alt](img.png)"); + + yield return Case( + "Mathematics", + builder => builder.UseMathematics(), + "$a$", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "Figures", + builder => builder.UseFigures(), + "^^^\ntext\n^^^", + document => AssertNodesHaveNonEmptySpan
(document)); + + yield return Case( + "Abbreviations", + builder => builder.UseAbbreviations(), + "*[HTML]: HyperText Markup Language\n\nHTML", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "DefinitionLists", + builder => builder.UseDefinitionLists(), + "a0\n: 1234", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "PipeTables", + builder => builder.UsePipeTables(), + "a|b\n-|-\n1|2", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "GridTables", + builder => builder.UseGridTables(), + "+-+-+\n|a|b|\n+=+=+\n|c|d|\n+-+-+", + document => AssertNodesHaveNonEmptySpan
(document)); + + yield return Case( + "Citations", + builder => builder.UseCitations(), + "\"\"text\"\"", + document => + { + var citation = document.Descendants().FirstOrDefault(x => x.DelimiterChar == '"' && x.DelimiterCount == 2); + Assert.That(citation, Is.Not.Null); + Assert.That(citation!.Span.IsEmpty, Is.False); + }); + + yield return Case( + "Footers", + builder => builder.UseFooters(), + "^^ one\n^^ two", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "Footnotes", + builder => builder.UseFootnotes(), + "[^1]: note\n\nref[^1]", + document => + { + AssertNodesHaveNonEmptySpan(document); + AssertNodesHaveNonEmptySpan(document); + var links = document.Descendants().ToList(); + Assert.That(links.Count, Is.GreaterThan(0)); + }); + + yield return Case( + "Hardlines", + builder => builder.UseSoftlineBreakAsHardlineBreak(), + "a\nb", + document => + { + var lineBreak = document.Descendants().FirstOrDefault(); + Assert.That(lineBreak, Is.Not.Null); + Assert.That(lineBreak!.IsHard, Is.True); + }); + + yield return Case( + "EmphasisExtras", + builder => builder.UseEmphasisExtras(), + "~~text~~", + document => + { + var emphasis = document.Descendants().FirstOrDefault(x => x.DelimiterChar == '~' && x.DelimiterCount == 2); + Assert.That(emphasis, Is.Not.Null); + Assert.That(emphasis!.Span.IsEmpty, Is.False); + }); + + yield return Case( + "ListExtras", + builder => builder.UseListExtras(), + "A. item", + document => + { + var list = document.Descendants().FirstOrDefault(); + Assert.That(list, Is.Not.Null); + Assert.That(list!.Span.IsEmpty, Is.False); + Assert.That(list.IsOrdered, Is.True); + }); + + yield return Case( + "GenericAttributes", + builder => builder.UseGenericAttributes(), + "text{#custom-id}", + document => + { + var paragraph = document.Descendants().FirstOrDefault(); + Assert.That(paragraph, Is.Not.Null); + var attributes = paragraph!.TryGetAttributes(); + Assert.That(attributes, Is.Not.Null); + Assert.That(attributes!.Span.IsEmpty, Is.False); + }); + + yield return Case( + "EmojiAndSmiley", + builder => builder.UseEmojiAndSmiley(), + ":)", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "NoFollowLinks", + builder => builder.UseReferralLinks("nofollow"), + "[link](https://example.com)", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "ReferralLinks", + builder => builder.UseReferralLinks("nofollow", "noopener"), + "[link](https://example.com)", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "JiraLinks", + builder => builder.UseJiraLinks(new JiraLinkOptions("https://jira.example.com")), + "ABC-123", + document => AssertNodesHaveNonEmptySpan(document)); + + yield return Case( + "Globalization", + builder => builder.UseGlobalization(), + "## Héllo"); + } + + private static TestCaseData Case( + string name, + Action configurePipeline, + string markdown, + Action validate = null, + bool validateSpanTree = true) + { + return new TestCaseData(new ExtensionSpanCase(name, configurePipeline, markdown, validate, validateSpanTree)) + .SetName($"Extension_{name}_MaintainsValidSpans"); + } + + private static void AssertNodesHaveNonEmptySpan(MarkdownDocument document) where T : MarkdownObject + { + var nodes = document.Descendants().ToList(); + Assert.That(nodes.Count, Is.GreaterThan(0), $"Expected at least one node of type `{typeof(T).Name}`"); + foreach (var node in nodes) + { + Assert.That(node.Span.IsEmpty, Is.False, $"Node `{typeof(T).Name}` has an empty span"); + } + } +} diff --git a/src/Markdig.Tests/TestParserAuthoringHelpers.cs b/src/Markdig.Tests/TestParserAuthoringHelpers.cs index 46fe1b28..dbbcbf5e 100644 --- a/src/Markdig.Tests/TestParserAuthoringHelpers.cs +++ b/src/Markdig.Tests/TestParserAuthoringHelpers.cs @@ -48,6 +48,21 @@ public sealed class TestParserAuthoringHelpers Assert.That(processor.TryDiscard(document), Is.False); } + [Test] + public void EmitUpdatesInlineAndLeafSpans() + { + var pipeline = new MarkdownPipelineBuilder(); + pipeline.InlineParsers.InsertBefore(new SpanEmittingInlineParser()); + + var document = Markdown.Parse("~", pipeline.Build()); + var paragraph = document[0] as ParagraphBlock; + + Assert.That(paragraph, Is.Not.Null); + Assert.That(paragraph!.Inline, Is.Not.Null); + Assert.That(paragraph.Inline!.Span, Is.EqualTo(new SourceSpan(0, 0))); + Assert.That(paragraph.Span, Is.EqualTo(new SourceSpan(0, 0))); + } + private sealed class CountingInlineParser : InlineParser { public CountingInlineParser() @@ -75,4 +90,31 @@ public sealed class TestParserAuthoringHelpers { public int Count { get; set; } } + + private sealed class SpanEmittingInlineParser : InlineParser + { + public SpanEmittingInlineParser() + { + OpeningCharacters = ['~']; + } + + public override bool Match(InlineProcessor processor, ref StringSlice slice) + { + if (slice.CurrentChar != '~') + { + return false; + } + + int start = processor.GetSourcePosition(slice.Start, out int line, out int column); + processor.Emit(new LiteralInline("x") + { + Line = line, + Column = column, + Span = new SourceSpan(start, start) + }); + + slice.SkipChar(); + return true; + } + } } diff --git a/src/Markdig.Tests/TestSourcePosition.cs b/src/Markdig.Tests/TestSourcePosition.cs index eed7139a..fdfb15d9 100644 --- a/src/Markdig.Tests/TestSourcePosition.cs +++ b/src/Markdig.Tests/TestSourcePosition.cs @@ -580,7 +580,7 @@ literal ( 4, 0) 13-14 // 012 3456789A Check("a0\n: 1234", @" definitionlist ( 0, 0) 0-10 -definitionitem ( 1, 0) 3-10 +definitionitem ( 1, 0) 0-10 definitionterm ( 0, 0) 0-1 literal ( 0, 0) 0-1 paragraph ( 1, 4) 7-10 @@ -594,7 +594,7 @@ literal ( 1, 4) 7-10 // 012 3456789AB CDEF01234 Check("a0\n: 1234\n: 5678", @" definitionlist ( 0, 0) 0-20 -definitionitem ( 1, 0) 3-10 +definitionitem ( 1, 0) 0-10 definitionterm ( 0, 0) 0-1 literal ( 0, 0) 0-1 paragraph ( 1, 4) 7-10 @@ -997,4 +997,4 @@ literal ( 8, 2) 77-92 .Replace("block", string.Empty) .Replace("inline", string.Empty); } -} \ No newline at end of file +} diff --git a/src/Markdig.Tests/TestSpanConsistency.cs b/src/Markdig.Tests/TestSpanConsistency.cs new file mode 100644 index 00000000..31a586ae --- /dev/null +++ b/src/Markdig.Tests/TestSpanConsistency.cs @@ -0,0 +1,93 @@ +using Markdig.Syntax; +using Markdig.Syntax.Inlines; + +namespace Markdig.Tests; + +[TestFixture] +public sealed class TestSpanConsistency +{ + private sealed class MockContainerBlock : ContainerBlock + { + public MockContainerBlock() : base(null) + { + } + } + + [Test] + public void BlockUpdateSpanToIncludePropagatesToParents() + { + var root = new MockContainerBlock { Span = new SourceSpan(30, 40) }; + var child = new MockContainerBlock { Span = new SourceSpan(32, 35) }; + root.Add(child); + + child.UpdateSpanToInclude(new SourceSpan(10, 50)); + + Assert.That(child.Span, Is.EqualTo(new SourceSpan(10, 50))); + Assert.That(root.Span, Is.EqualTo(new SourceSpan(10, 50))); + } + + [Test] + public void ContainerBlockCanValidateAndUpdateSpansRecursively() + { + var document = new MarkdownDocument(); + var container = new MockContainerBlock { Span = new SourceSpan(30, 31) }; + var paragraph = new ParagraphBlock { Span = new SourceSpan(23, 24) }; + paragraph.Inline = new ContainerInline { Span = new SourceSpan(22, 24) }; + + container.Add(paragraph); + document.Add(container); + document.Span = new SourceSpan(0, 5); + + Assert.That(document.HasValidSpan(recursive: true), Is.False); + + Assert.That(document.UpdateSpanFromChildren(recursive: true), Is.True); + Assert.That(document.HasValidSpan(recursive: true), Is.True); + Assert.That(paragraph.Span, Is.EqualTo(new SourceSpan(22, 24))); + Assert.That(container.Span, Is.EqualTo(new SourceSpan(22, 31))); + Assert.That(document.Span, Is.EqualTo(new SourceSpan(0, 31))); + + container.Span = new SourceSpan(0, 100); + document.Span = new SourceSpan(0, 100); + + Assert.That(document.UpdateSpanFromChildren(recursive: true, preserveSelfSpan: false), Is.True); + Assert.That(container.Span, Is.EqualTo(new SourceSpan(22, 24))); + Assert.That(document.Span, Is.EqualTo(new SourceSpan(22, 24))); + } + + [Test] + public void ContainerInlineCanValidateAndUpdateSpansRecursively() + { + var root = new ContainerInline { Span = new SourceSpan(35, 36) }; + var nested = new EmphasisInline { Span = new SourceSpan(50, 51) }; + var literal = new LiteralInline("x") { Span = new SourceSpan(10, 12) }; + + nested.AppendChild(literal); + root.AppendChild(nested); + + Assert.That(root.HasValidSpan(recursive: true), Is.False); + + Assert.That(root.UpdateSpanFromChildren(recursive: true), Is.True); + Assert.That(root.HasValidSpan(recursive: true), Is.True); + Assert.That(nested.Span, Is.EqualTo(new SourceSpan(10, 51))); + Assert.That(root.Span, Is.EqualTo(new SourceSpan(10, 51))); + + nested.Span = new SourceSpan(0, 100); + root.Span = new SourceSpan(0, 100); + + Assert.That(root.UpdateSpanFromChildren(recursive: true, preserveSelfSpan: false), Is.True); + Assert.That(nested.Span, Is.EqualTo(new SourceSpan(10, 12))); + Assert.That(root.Span, Is.EqualTo(new SourceSpan(10, 12))); + } + + [Test] + public void ContainerBlockSpanCanBeExpandedAfterInsertions() + { + var container = new MockContainerBlock { Span = new SourceSpan(20, 25) }; + container.Insert(0, new ParagraphBlock { Span = new SourceSpan(10, 12) }); + container.Insert(1, new ParagraphBlock { Span = new SourceSpan(30, 35) }); + + Assert.That(container.HasValidSpan(), Is.False); + Assert.That(container.UpdateSpanFromChildren(), Is.True); + Assert.That(container.Span, Is.EqualTo(new SourceSpan(10, 35))); + } +} diff --git a/src/Markdig/Extensions/DefinitionLists/DefinitionListParser.cs b/src/Markdig/Extensions/DefinitionLists/DefinitionListParser.cs index bb3af8cf..e1dd0ced 100644 --- a/src/Markdig/Extensions/DefinitionLists/DefinitionListParser.cs +++ b/src/Markdig/Extensions/DefinitionLists/DefinitionListParser.cs @@ -75,7 +75,7 @@ public class DefinitionListParser : BlockParser { Line = processor.LineIndex, Column = column, - Span = new SourceSpan(startPosition, processor.Line.End), + Span = new SourceSpan(paragraphBlock.Span.Start, processor.Line.End), OpeningCharacter = processor.CurrentChar, }; @@ -199,4 +199,4 @@ public class DefinitionListParser : BlockParser list.Span.End = list.LastChild!.Span.End; return BlockState.Break; } -} \ No newline at end of file +} diff --git a/src/Markdig/Extensions/JiraLinks/JiraLinkInlineParser.cs b/src/Markdig/Extensions/JiraLinks/JiraLinkInlineParser.cs index 3793c73e..37584397 100644 --- a/src/Markdig/Extensions/JiraLinks/JiraLinkInlineParser.cs +++ b/src/Markdig/Extensions/JiraLinks/JiraLinkInlineParser.cs @@ -5,6 +5,7 @@ using Markdig.Helpers; using Markdig.Parsers; using Markdig.Renderers.Html; +using Markdig.Syntax; using Markdig.Syntax.Inlines; namespace Markdig.Extensions.JiraLinks; @@ -80,18 +81,15 @@ public class JiraLinkInlineParser : InlineParser return false; } + int spanStart = processor.GetSourcePosition(startKey, out int line, out int column); var jiraLink = new JiraLink() //create the link at the relevant position { - Span = - { - Start = processor.GetSourcePosition(slice.Start, out int line, out int column) - }, + Span = new SourceSpan(spanStart, spanStart + (endIssue - startKey)), Line = line, Column = column, Issue = new StringSlice(slice.Text, startIssue, endIssue), ProjectKey = new StringSlice(slice.Text, startKey, endKey), }; - jiraLink.Span.End = jiraLink.Span.Start + (endIssue - startKey); // Builds the Url var builder = new ValueStringBuilder(stackalloc char[ValueStringBuilder.StackallocThreshold]); @@ -107,7 +105,12 @@ public class JiraLinkInlineParser : InlineParser builder.Append(jiraLink.ProjectKey.AsSpan()); builder.Append('-'); builder.Append(jiraLink.Issue.AsSpan()); - jiraLink.AppendChild(new LiteralInline(builder.ToString())); + jiraLink.AppendChild(new LiteralInline(builder.ToString()) + { + Span = jiraLink.Span, + Line = line, + Column = column, + }); if (_options.OpenInNewWindow) { diff --git a/src/Markdig/Parsers/InlineProcessor.cs b/src/Markdig/Parsers/InlineProcessor.cs index efc28ab3..f42f322e 100644 --- a/src/Markdig/Parsers/InlineProcessor.cs +++ b/src/Markdig/Parsers/InlineProcessor.cs @@ -456,6 +456,29 @@ public class InlineProcessor var container = FindLastContainer(); container.AppendChild(inline); + + if (ReferenceEquals(container, Root) && !inline.Span.IsEmpty) + { + if (container.Span.IsEmpty) + { + container.Span = inline.Span; + } + else + { + if (inline.Span.Start < container.Span.Start) + { + container.Span.Start = inline.Span.Start; + } + + if (inline.Span.End > container.Span.End) + { + container.Span.End = inline.Span.End; + } + } + + Block?.UpdateSpanToInclude(inline.Span); + } + Inline = inline; } diff --git a/src/Markdig/Syntax/Block.cs b/src/Markdig/Syntax/Block.cs index 6f1da94f..14b29c3d 100644 --- a/src/Markdig/Syntax/Block.cs +++ b/src/Markdig/Syntax/Block.cs @@ -137,6 +137,45 @@ public abstract class Block : MarkdownObject, IBlock } } + /// + /// Updates this block span and all parent container spans to include the specified . + /// + /// The span to include. + public void UpdateSpanToInclude(SourceSpan span) + { + if (span.IsEmpty) + { + return; + } + + int depth = 0; + Block? current = this; + while (current is not null) + { + if (current.Span.IsEmpty) + { + current.Span = span; + } + else + { + if (span.Start < current.Span.Start) + { + current.Span.Start = span.Start; + } + + if (span.End > current.Span.End) + { + current.Span.End = span.End; + } + } + + current = current.Parent; + depth++; + } + + ThrowHelper.CheckDepthLimit(depth, useLargeLimit: true); + } + public void UpdateSpanEnd(int spanEnd) { // Update parent spans diff --git a/src/Markdig/Syntax/ContainerBlock.cs b/src/Markdig/Syntax/ContainerBlock.cs index c460d9be..f8c177a1 100644 --- a/src/Markdig/Syntax/ContainerBlock.cs +++ b/src/Markdig/Syntax/ContainerBlock.cs @@ -9,6 +9,7 @@ using System.Runtime.InteropServices; using Markdig.Helpers; using Markdig.Parsers; +using Markdig.Syntax.Inlines; namespace Markdig.Syntax; @@ -271,6 +272,124 @@ public abstract class ContainerBlock : Block, IList, IReadOnlyList } } + /// + /// Checks whether this container span is valid with respect to child block spans. + /// + /// + /// When true, validates descendant container blocks and inline containers recursively. + /// + /// + /// true when this container span contains all direct child spans and recursive checks (if enabled) succeed; + /// otherwise, false. + /// + public bool HasValidSpan(bool recursive = false) + { + var children = _children; + for (int i = 0; i < Count && i < children.Length; i++) + { + var child = children[i].Block; + if (!ContainsSpan(Span, child.Span)) + { + return false; + } + + if (!recursive) + { + continue; + } + + if (child is ContainerBlock containerBlock) + { + if (!containerBlock.HasValidSpan(recursive: true)) + { + return false; + } + } + else if (child is LeafBlock leafBlock && leafBlock.Inline is ContainerInline inline) + { + if (!ContainsSpan(leafBlock.Span, inline.Span)) + { + return false; + } + + if (!inline.HasValidSpan(recursive: true)) + { + return false; + } + } + } + + return true; + } + + /// + /// Updates this container span from its child block spans. + /// + /// + /// When true, updates descendant container blocks and inline containers recursively before updating this container. + /// + /// + /// When true, preserves this container current span and only expands it to include children. + /// When false, recomputes from children only. + /// + /// true when this container span changed; otherwise, false. + public bool UpdateSpanFromChildren(bool recursive = false, bool preserveSelfSpan = true) + { + var updatedSpan = SourceSpan.Empty; + bool hasUpdatedSpan = false; + + if (preserveSelfSpan && !Span.IsEmpty) + { + updatedSpan = Span; + hasUpdatedSpan = true; + } + + var children = _children; + for (int i = 0; i < Count && i < children.Length; i++) + { + var child = children[i].Block; + + if (recursive) + { + if (child is ContainerBlock containerBlock) + { + containerBlock.UpdateSpanFromChildren(recursive: true, preserveSelfSpan: preserveSelfSpan); + } + else if (child is LeafBlock leafBlock && leafBlock.Inline is ContainerInline inline) + { + inline.UpdateSpanFromChildren(recursive: true, preserveSelfSpan: preserveSelfSpan); + + if (!ContainsSpan(leafBlock.Span, inline.Span)) + { + if (preserveSelfSpan && !leafBlock.Span.IsEmpty) + { + leafBlock.UpdateSpanToInclude(inline.Span); + } + else + { + leafBlock.Span = inline.Span; + } + } + } + } + + AppendSpan(ref updatedSpan, ref hasUpdatedSpan, child.Span); + } + + if (!hasUpdatedSpan) + { + updatedSpan = SourceSpan.Empty; + } + + if (updatedSpan == Span) + { + return false; + } + + Span = updatedSpan; + return true; + } + public void Sort(IComparer comparer) { if (comparer is null) ThrowHelper.ArgumentNullException(nameof(comparer)); @@ -353,4 +472,36 @@ public abstract class ContainerBlock : Block, IList, IReadOnlyList return _comparer.Compare(x.Block, y.Block); } } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ContainsSpan(in SourceSpan containerSpan, in SourceSpan childSpan) + { + return childSpan.IsEmpty || (!containerSpan.IsEmpty && childSpan.Start >= containerSpan.Start && childSpan.End <= containerSpan.End); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AppendSpan(ref SourceSpan destinationSpan, ref bool hasDestinationSpan, in SourceSpan spanToAppend) + { + if (spanToAppend.IsEmpty) + { + return; + } + + if (!hasDestinationSpan) + { + destinationSpan = spanToAppend; + hasDestinationSpan = true; + return; + } + + if (spanToAppend.Start < destinationSpan.Start) + { + destinationSpan.Start = spanToAppend.Start; + } + + if (spanToAppend.End > destinationSpan.End) + { + destinationSpan.End = spanToAppend.End; + } + } } diff --git a/src/Markdig/Syntax/Inlines/ContainerInline.cs b/src/Markdig/Syntax/Inlines/ContainerInline.cs index 2596f79f..1a0b686b 100644 --- a/src/Markdig/Syntax/Inlines/ContainerInline.cs +++ b/src/Markdig/Syntax/Inlines/ContainerInline.cs @@ -5,8 +5,10 @@ using System.Collections; using System.Diagnostics; using System.IO; +using System.Runtime.CompilerServices; using Markdig.Helpers; +using Markdig.Syntax; namespace Markdig.Syntax.Inlines; @@ -264,6 +266,113 @@ public class ContainerInline : Inline, IEnumerable } } + /// + /// Checks whether this container span is valid with respect to child inline spans. + /// + /// When true, validates descendant container inline spans recursively. + /// + /// true when this container span contains all direct child spans and recursive checks (if enabled) succeed; + /// otherwise, false. + /// + public bool HasValidSpan(bool recursive = false) + { + var child = FirstChild; + while (child is not null) + { + if (!ContainsSpan(Span, child.Span)) + { + return false; + } + + if (recursive && child is ContainerInline childContainer && !childContainer.HasValidSpan(recursive: true)) + { + return false; + } + + child = child.NextSibling; + } + + return true; + } + + /// + /// Updates this container span from its child inline spans. + /// + /// When true, updates descendant container inline spans recursively before updating this container. + /// + /// When true, preserves this container current span and only expands it to include children. + /// When false, recomputes from children only. + /// + /// true when this container span changed; otherwise, false. + public bool UpdateSpanFromChildren(bool recursive = false, bool preserveSelfSpan = true) + { + var updatedSpan = SourceSpan.Empty; + bool hasUpdatedSpan = false; + + if (preserveSelfSpan && !Span.IsEmpty) + { + updatedSpan = Span; + hasUpdatedSpan = true; + } + + var child = FirstChild; + while (child is not null) + { + if (recursive && child is ContainerInline childContainer) + { + childContainer.UpdateSpanFromChildren(recursive: true, preserveSelfSpan: preserveSelfSpan); + } + + AppendSpan(ref updatedSpan, ref hasUpdatedSpan, child.Span); + child = child.NextSibling; + } + + if (!hasUpdatedSpan) + { + updatedSpan = SourceSpan.Empty; + } + + if (updatedSpan == Span) + { + return false; + } + + Span = updatedSpan; + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ContainsSpan(in SourceSpan containerSpan, in SourceSpan childSpan) + { + return childSpan.IsEmpty || (!containerSpan.IsEmpty && childSpan.Start >= containerSpan.Start && childSpan.End <= containerSpan.End); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void AppendSpan(ref SourceSpan destinationSpan, ref bool hasDestinationSpan, in SourceSpan spanToAppend) + { + if (spanToAppend.IsEmpty) + { + return; + } + + if (!hasDestinationSpan) + { + destinationSpan = spanToAppend; + hasDestinationSpan = true; + return; + } + + if (spanToAppend.Start < destinationSpan.Start) + { + destinationSpan.Start = spanToAppend.Start; + } + + if (spanToAppend.End > destinationSpan.End) + { + destinationSpan.End = spanToAppend.End; + } + } + public struct Enumerator : IEnumerator { private readonly ContainerInline container;