Add AST mutation helpers for block and inline trees

This commit is contained in:
Alexandre Mutel
2026-02-21 14:27:20 +01:00
parent ab6204cf61
commit 2c9a9a308c
6 changed files with 187 additions and 17 deletions

View File

@@ -183,4 +183,55 @@ public class TestContainerBlocks
Assert.Throws<IndexOutOfRangeException>(() => 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));
}
}

View File

@@ -35,4 +35,29 @@ public class TestContainerInlines
var leafBlock2 = new MockLeafBlock();
Assert.Throws<ArgumentException>(() => 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));
}
}

View File

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

View File

@@ -154,6 +154,56 @@ public abstract class Block : MarkdownObject, IBlock
ThrowHelper.CheckDepthLimit(depth, useLargeLimit: true);
}
/// <summary>
/// Removes this block from its parent container.
/// </summary>
public void Remove()
{
Parent?.Remove(this);
}
/// <summary>
/// Replaces this block with <paramref name="replacement"/> in its parent container.
/// </summary>
/// <param name="replacement">The replacement block.</param>
/// <param name="moveChildren">
/// <c>true</c> to transfer children when both this block and <paramref name="replacement"/> are containers.
/// </param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="replacement"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown if <paramref name="replacement"/> is already attached to a parent container.</exception>
/// <exception cref="InvalidOperationException">Thrown if this block has no parent container.</exception>
/// <remarks>
/// This method does not recompute spans or trivia. Callers are responsible for updating those values
/// if a transform requires exact source information after replacement.
/// </remarks>
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<StringSlice>? LinesBefore;
public List<StringSlice>? LinesAfter;
}
}
}

View File

@@ -119,6 +119,39 @@ public abstract class ContainerBlock : Block, IList<Block>, IReadOnlyList<Block>
Count = 0;
}
/// <summary>
/// Transfers all children from this container to <paramref name="destination"/>.
/// </summary>
/// <param name="destination">The destination container.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="destination"/> is null.</exception>
/// <remarks>
/// Child order is preserved. This method does not recompute source or destination spans/trivia.
/// </remarks>
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<Block>, IReadOnlyList<Block>
return _comparer.Compare(x.Block, y.Block);
}
}
}
}

View File

@@ -149,6 +149,30 @@ public class ContainerInline : Inline, IEnumerable<Inline>
}
}
/// <summary>
/// Transfers all children from this container to <paramref name="destination"/>.
/// </summary>
/// <param name="destination">The destination container.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="destination"/> is null.</exception>
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;
}
}
/// <summary>
/// Moves all the children of this container after the specified inline.
/// </summary>
@@ -293,4 +317,4 @@ public class ContainerInline : Inline, IEnumerable<Inline>
{
return GetEnumerator();
}
}
}