Add span validation/update APIs and tests

This commit is contained in:
Alexandre Mutel
2026-02-21 19:06:30 +01:00
parent c488a27497
commit 148fd08b1d
10 changed files with 844 additions and 11 deletions

View File

@@ -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<MarkdownPipelineBuilder> configurePipeline,
string markdown,
Action<MarkdownDocument> validate,
bool validateSpanTree = true)
{
public string Name { get; } = name;
public Action<MarkdownPipelineBuilder> ConfigurePipeline { get; } = configurePipeline;
public string Markdown { get; } = markdown;
public Action<MarkdownDocument> 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<JiraLink>().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<TaskList>().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<AlertBlock>().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<ParagraphBlock>().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<YamlFrontMatterBlock>().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<TestCaseData> GetExtensionSpanCases()
{
yield return Case(
"AlertBlocks",
builder => builder.UseAlertBlocks(),
"> [!NOTE]\n> body",
document => AssertNodesHaveNonEmptySpan<AlertBlock>(document));
yield return Case(
"AutoLinks",
builder => builder.UseAutoLinks(),
"http://example.com",
document =>
{
var autoLink = document.Descendants<LinkInline>().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<YamlFrontMatterBlock>(document));
yield return Case(
"SelfPipeline",
builder => builder.UseSelfPipeline(),
"<!--markdig:tasklists-->\n- [x] done",
document => AssertNodesHaveNonEmptySpan<TaskList>(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<TaskList>(document));
yield return Case(
"CustomContainers",
builder => builder.UseCustomContainers(),
":::\nvalue\n:::",
document => AssertNodesHaveNonEmptySpan<Markdig.Extensions.CustomContainers.CustomContainer>(document));
yield return Case(
"MediaLinks",
builder => builder.UseMediaLinks(),
"[video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)",
document => AssertNodesHaveNonEmptySpan<LinkInline>(document));
yield return Case(
"AutoIdentifiers",
builder => builder.UseAutoIdentifiers(),
"# Heading",
document =>
{
var heading = document.Descendants<HeadingBlock>().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(),
"<<a>>",
document => AssertNodesHaveNonEmptySpan<SmartyPant>(document));
yield return Case(
"Bootstrap",
builder => builder.UseBootstrap(),
"![alt](img.png)");
yield return Case(
"Mathematics",
builder => builder.UseMathematics(),
"$a$",
document => AssertNodesHaveNonEmptySpan<MathInline>(document));
yield return Case(
"Figures",
builder => builder.UseFigures(),
"^^^\ntext\n^^^",
document => AssertNodesHaveNonEmptySpan<Figure>(document));
yield return Case(
"Abbreviations",
builder => builder.UseAbbreviations(),
"*[HTML]: HyperText Markup Language\n\nHTML",
document => AssertNodesHaveNonEmptySpan<AbbreviationInline>(document));
yield return Case(
"DefinitionLists",
builder => builder.UseDefinitionLists(),
"a0\n: 1234",
document => AssertNodesHaveNonEmptySpan<DefinitionList>(document));
yield return Case(
"PipeTables",
builder => builder.UsePipeTables(),
"a|b\n-|-\n1|2",
document => AssertNodesHaveNonEmptySpan<Table>(document));
yield return Case(
"GridTables",
builder => builder.UseGridTables(),
"+-+-+\n|a|b|\n+=+=+\n|c|d|\n+-+-+",
document => AssertNodesHaveNonEmptySpan<Table>(document));
yield return Case(
"Citations",
builder => builder.UseCitations(),
"\"\"text\"\"",
document =>
{
var citation = document.Descendants<EmphasisInline>().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<FooterBlock>(document));
yield return Case(
"Footnotes",
builder => builder.UseFootnotes(),
"[^1]: note\n\nref[^1]",
document =>
{
AssertNodesHaveNonEmptySpan<FootnoteGroup>(document);
AssertNodesHaveNonEmptySpan<Footnote>(document);
var links = document.Descendants<FootnoteLink>().ToList();
Assert.That(links.Count, Is.GreaterThan(0));
});
yield return Case(
"Hardlines",
builder => builder.UseSoftlineBreakAsHardlineBreak(),
"a\nb",
document =>
{
var lineBreak = document.Descendants<LineBreakInline>().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<EmphasisInline>().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<ListBlock>().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<ParagraphBlock>().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<EmojiInline>(document));
yield return Case(
"NoFollowLinks",
builder => builder.UseReferralLinks("nofollow"),
"[link](https://example.com)",
document => AssertNodesHaveNonEmptySpan<LinkInline>(document));
yield return Case(
"ReferralLinks",
builder => builder.UseReferralLinks("nofollow", "noopener"),
"[link](https://example.com)",
document => AssertNodesHaveNonEmptySpan<LinkInline>(document));
yield return Case(
"JiraLinks",
builder => builder.UseJiraLinks(new JiraLinkOptions("https://jira.example.com")),
"ABC-123",
document => AssertNodesHaveNonEmptySpan<JiraLink>(document));
yield return Case(
"Globalization",
builder => builder.UseGlobalization(),
"## Héllo");
}
private static TestCaseData Case(
string name,
Action<MarkdownPipelineBuilder> configurePipeline,
string markdown,
Action<MarkdownDocument> validate = null,
bool validateSpanTree = true)
{
return new TestCaseData(new ExtensionSpanCase(name, configurePipeline, markdown, validate, validateSpanTree))
.SetName($"Extension_{name}_MaintainsValidSpans");
}
private static void AssertNodesHaveNonEmptySpan<T>(MarkdownDocument document) where T : MarkdownObject
{
var nodes = document.Descendants<T>().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");
}
}
}

View File

@@ -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<AutolinkInlineParser>(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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)));
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)
{

View File

@@ -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;
}

View File

@@ -137,6 +137,45 @@ public abstract class Block : MarkdownObject, IBlock
}
}
/// <summary>
/// Updates this block span and all parent container spans to include the specified <paramref name="span"/>.
/// </summary>
/// <param name="span">The span to include.</param>
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

View File

@@ -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<Block>, IReadOnlyList<Block>
}
}
/// <summary>
/// Checks whether this container span is valid with respect to child block spans.
/// </summary>
/// <param name="recursive">
/// When <c>true</c>, validates descendant container blocks and inline containers recursively.
/// </param>
/// <returns>
/// <c>true</c> when this container span contains all direct child spans and recursive checks (if enabled) succeed;
/// otherwise, <c>false</c>.
/// </returns>
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;
}
/// <summary>
/// Updates this container span from its child block spans.
/// </summary>
/// <param name="recursive">
/// When <c>true</c>, updates descendant container blocks and inline containers recursively before updating this container.
/// </param>
/// <param name="preserveSelfSpan">
/// When <c>true</c>, preserves this container current span and only expands it to include children.
/// When <c>false</c>, recomputes from children only.
/// </param>
/// <returns><c>true</c> when this container span changed; otherwise, <c>false</c>.</returns>
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<Block> comparer)
{
if (comparer is null) ThrowHelper.ArgumentNullException(nameof(comparer));
@@ -353,4 +472,36 @@ public abstract class ContainerBlock : Block, IList<Block>, IReadOnlyList<Block>
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;
}
}
}

View File

@@ -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<Inline>
}
}
/// <summary>
/// Checks whether this container span is valid with respect to child inline spans.
/// </summary>
/// <param name="recursive">When <c>true</c>, validates descendant container inline spans recursively.</param>
/// <returns>
/// <c>true</c> when this container span contains all direct child spans and recursive checks (if enabled) succeed;
/// otherwise, <c>false</c>.
/// </returns>
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;
}
/// <summary>
/// Updates this container span from its child inline spans.
/// </summary>
/// <param name="recursive">When <c>true</c>, updates descendant container inline spans recursively before updating this container.</param>
/// <param name="preserveSelfSpan">
/// When <c>true</c>, preserves this container current span and only expands it to include children.
/// When <c>false</c>, recomputes from children only.
/// </param>
/// <returns><c>true</c> when this container span changed; otherwise, <c>false</c>.</returns>
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<Inline>
{
private readonly ContainerInline container;