From 2c9a9a308c1091c8f763ca98c44a07b02acc4abf Mon Sep 17 00:00:00 2001 From: Alexandre Mutel Date: Sat, 21 Feb 2026 14:27:20 +0100 Subject: [PATCH] Add AST mutation helpers for block and inline trees --- src/Markdig.Tests/TestContainerBlocks.cs | 51 ++++++++++++++++++ src/Markdig.Tests/TestContainerInlines.cs | 25 +++++++++ .../Extensions/Alerts/AlertInlineParser.cs | 15 +----- src/Markdig/Syntax/Block.cs | 52 ++++++++++++++++++- src/Markdig/Syntax/ContainerBlock.cs | 35 ++++++++++++- src/Markdig/Syntax/Inlines/ContainerInline.cs | 26 +++++++++- 6 files changed, 187 insertions(+), 17 deletions(-) diff --git a/src/Markdig.Tests/TestContainerBlocks.cs b/src/Markdig.Tests/TestContainerBlocks.cs index abffe84f..d86c3cae 100644 --- a/src/Markdig.Tests/TestContainerBlocks.cs +++ b/src/Markdig.Tests/TestContainerBlocks.cs @@ -183,4 +183,55 @@ public class TestContainerBlocks Assert.Throws(() => container.CopyTo(destination, 3)); } + + [Test] + public void CanTransferChildrenToAnotherContainer() + { + var source = new MockContainerBlock(); + var first = new ParagraphBlock { Column = 1 }; + var second = new ParagraphBlock { Column = 2 }; + source.Add(first); + source.Add(second); + + var destination = new MockContainerBlock(); + var existing = new ParagraphBlock { Column = 0 }; + destination.Add(existing); + + source.TransferChildrenTo(destination); + + Assert.That(source.Count, Is.EqualTo(0)); + Assert.That(destination.Count, Is.EqualTo(3)); + Assert.That(destination[0], Is.SameAs(existing)); + Assert.That(destination[1], Is.SameAs(first)); + Assert.That(destination[2], Is.SameAs(second)); + Assert.That(first.Parent, Is.SameAs(destination)); + Assert.That(second.Parent, Is.SameAs(destination)); + } + + [Test] + public void BlockCanBeRemovedAndReplaced() + { + var root = new MockContainerBlock(); + var toRemove = new ParagraphBlock(); + root.Add(toRemove); + + toRemove.Remove(); + Assert.That(root.Count, Is.EqualTo(0)); + Assert.That(toRemove.Parent, Is.Null); + + var sourceContainer = new MockContainerBlock(); + sourceContainer.Add(new ParagraphBlock { Column = 3 }); + sourceContainer.Add(new ParagraphBlock { Column = 4 }); + root.Add(sourceContainer); + + var replacement = new MockContainerBlock(); + sourceContainer.ReplaceBy(replacement); + + Assert.That(root[0], Is.SameAs(replacement)); + Assert.That(sourceContainer.Parent, Is.Null); + Assert.That(sourceContainer.Count, Is.EqualTo(0)); + Assert.That(replacement.Count, Is.EqualTo(2)); + Assert.That(replacement[0].Column, Is.EqualTo(3)); + Assert.That(replacement[1].Column, Is.EqualTo(4)); + } } diff --git a/src/Markdig.Tests/TestContainerInlines.cs b/src/Markdig.Tests/TestContainerInlines.cs index 90520f1e..e8eacec0 100644 --- a/src/Markdig.Tests/TestContainerInlines.cs +++ b/src/Markdig.Tests/TestContainerInlines.cs @@ -35,4 +35,29 @@ public class TestContainerInlines var leafBlock2 = new MockLeafBlock(); Assert.Throws(() => leafBlock2.Inline = two); } + + [Test] + public void CanTransferChildrenToAnotherContainer() + { + var source = new ContainerInline(); + var first = new LiteralInline("a"); + var second = new LiteralInline("b"); + source.AppendChild(first); + source.AppendChild(second); + + var destination = new ContainerInline(); + var existing = new LiteralInline("x"); + destination.AppendChild(existing); + + source.TransferChildrenTo(destination); + + Assert.That(source.FirstChild, Is.Null); + Assert.That(source.LastChild, Is.Null); + Assert.That(destination.FirstChild, Is.SameAs(existing)); + Assert.That(existing.NextSibling, Is.SameAs(first)); + Assert.That(first.NextSibling, Is.SameAs(second)); + Assert.That(second.NextSibling, Is.Null); + Assert.That(first.Parent, Is.SameAs(destination)); + Assert.That(second.Parent, Is.SameAs(destination)); + } } diff --git a/src/Markdig/Extensions/Alerts/AlertInlineParser.cs b/src/Markdig/Extensions/Alerts/AlertInlineParser.cs index 7d609e5b..ff0f855b 100644 --- a/src/Markdig/Extensions/Alerts/AlertInlineParser.cs +++ b/src/Markdig/Extensions/Alerts/AlertInlineParser.cs @@ -113,20 +113,7 @@ public class AlertInlineParser : InlineParser attributes.AddClass("markdown-alert"); attributes.AddClass(s_alertTypeClassCache.Get(alertType.AsSpan())); - // Replace the quote block with the alert block - var parentQuoteBlock = quoteBlock.Parent!; - var indexOfQuoteBlock = parentQuoteBlock.IndexOf(quoteBlock); - parentQuoteBlock[indexOfQuoteBlock] = alertBlock; - - while (quoteBlock.Count > 0) - { - var block = quoteBlock[0]; - quoteBlock.RemoveAt(0); - alertBlock.Add(block); - } - - // Workaround to replace the parent container - // Experimental API, so we are keeping it internal for now until we are sure it's the way we want to go + quoteBlock.ReplaceBy(alertBlock); processor.ReplaceParentContainer(quoteBlock, alertBlock); return true; diff --git a/src/Markdig/Syntax/Block.cs b/src/Markdig/Syntax/Block.cs index 5c5ae66a..6f1da94f 100644 --- a/src/Markdig/Syntax/Block.cs +++ b/src/Markdig/Syntax/Block.cs @@ -154,6 +154,56 @@ public abstract class Block : MarkdownObject, IBlock ThrowHelper.CheckDepthLimit(depth, useLargeLimit: true); } + /// + /// Removes this block from its parent container. + /// + public void Remove() + { + Parent?.Remove(this); + } + + /// + /// Replaces this block with in its parent container. + /// + /// The replacement block. + /// + /// true to transfer children when both this block and are containers. + /// + /// Thrown if is null. + /// Thrown if is already attached to a parent container. + /// Thrown if this block has no parent container. + /// + /// This method does not recompute spans or trivia. Callers are responsible for updating those values + /// if a transform requires exact source information after replacement. + /// + public void ReplaceBy(Block replacement, bool moveChildren = true) + { + if (replacement is null) ThrowHelper.ArgumentNullException(nameof(replacement)); + if (replacement.Parent is not null) + { + ThrowHelper.ArgumentException("Cannot replace with a block that is already attached to another container (replacement.Parent != null)", nameof(replacement)); + } + + var parent = Parent; + if (parent is null) + { + ThrowHelper.InvalidOperationException("Cannot replace a block that has no parent"); + } + + int index = parent.IndexOf(this); + if (index < 0) + { + ThrowHelper.InvalidOperationException("Cannot replace a block that is not attached to its parent container"); + } + + parent[index] = replacement; + + if (moveChildren && this is ContainerBlock sourceContainer && replacement is ContainerBlock destinationContainer) + { + sourceContainer.TransferChildrenTo(destinationContainer); + } + } + internal static Block FindRootMostContainerParent(Block block) { while (true) @@ -186,4 +236,4 @@ public abstract class Block : MarkdownObject, IBlock public List? LinesBefore; public List? LinesAfter; } -} \ No newline at end of file +} diff --git a/src/Markdig/Syntax/ContainerBlock.cs b/src/Markdig/Syntax/ContainerBlock.cs index fb5e1595..c460d9be 100644 --- a/src/Markdig/Syntax/ContainerBlock.cs +++ b/src/Markdig/Syntax/ContainerBlock.cs @@ -119,6 +119,39 @@ public abstract class ContainerBlock : Block, IList, IReadOnlyList Count = 0; } + /// + /// Transfers all children from this container to . + /// + /// The destination container. + /// Thrown if is null. + /// + /// Child order is preserved. This method does not recompute source or destination spans/trivia. + /// + public void TransferChildrenTo(ContainerBlock destination) + { + if (destination is null) + { + ThrowHelper.ArgumentNullException(nameof(destination)); + } + + if (ReferenceEquals(this, destination)) + { + return; + } + + var children = _children; + int count = Count; + for (int i = 0; i < count && i < children.Length; i++) + { + var child = children[i].Block; + child.Parent = null; + children[i] = default; + destination.Add(child); + } + + Count = 0; + } + public bool Contains(Block item) { return IndexOf(item) >= 0; @@ -320,4 +353,4 @@ public abstract class ContainerBlock : Block, IList, IReadOnlyList return _comparer.Compare(x.Block, y.Block); } } -} \ No newline at end of file +} diff --git a/src/Markdig/Syntax/Inlines/ContainerInline.cs b/src/Markdig/Syntax/Inlines/ContainerInline.cs index 57b23cda..2596f79f 100644 --- a/src/Markdig/Syntax/Inlines/ContainerInline.cs +++ b/src/Markdig/Syntax/Inlines/ContainerInline.cs @@ -149,6 +149,30 @@ public class ContainerInline : Inline, IEnumerable } } + /// + /// Transfers all children from this container to . + /// + /// The destination container. + /// Thrown if is null. + public void TransferChildrenTo(ContainerInline destination) + { + if (destination is null) ThrowHelper.ArgumentNullException(nameof(destination)); + + if (ReferenceEquals(this, destination)) + { + return; + } + + var child = FirstChild; + while (child != null) + { + var next = child.NextSibling; + child.Remove(); + destination.AppendChild(child); + child = next; + } + } + /// /// Moves all the children of this container after the specified inline. /// @@ -293,4 +317,4 @@ public class ContainerInline : Inline, IEnumerable { return GetEnumerator(); } -} \ No newline at end of file +}