From 59b69862ade637658c78ac13c02ee0db6504b389 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 12 Jan 2026 02:02:15 -0800 Subject: [PATCH 1/5] feat: add 'Inner' to named block queries --- TinyTokenizer.Tests/SyntaxEditorTests.cs | 58 +++++++++++++ TinyTokenizer/Ast/IBlockContainerNode.cs | 103 +++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index eaa9a68..9a23d9b 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -877,6 +877,64 @@ public void Inner_WithPredicate_SelectsMatchingBlockInner() Assert.Equal("{short} {X}", tree.ToText()); } + + #endregion + + #region NamedBlockQuery.Inner() Tests + + private static Schema CreateBlockContainerTestSchema() + { + return Schema.Create() + .DefineSyntax(Syntax.Define("testBlockContainer") + .Match(Query.AnyIdent, Query.BraceBlock) + .Build()) + .Build(); + } + + private sealed class TestBlockContainerSyntax : SyntaxNode, IBlockContainerNode + { + internal TestBlockContainerSyntax(CreationContext context) + : base(context) + { + } + + public SyntaxToken NameNode => GetTypedChild(0); + public SyntaxBlock Body => GetTypedChild(1); + + public IReadOnlyList BlockNames => ["body"]; + + public SyntaxBlock GetBlock(string? name = null) => name switch + { + null or "body" => Body, + _ => throw new ArgumentException($"Unknown block: {name}") + }; + } + + [Fact] + public void NamedBlockInner_Replace_ReplacesContentPreservingDelimiters() + { + var schema = CreateBlockContainerTestSchema(); + var tree = SyntaxTree.Parse("f{old}", schema); + + tree.CreateEditor() + .Replace(Query.Syntax().Block("body").Inner(), "new") + .Commit(); + + Assert.Equal("f{new}", tree.ToText()); + } + + [Fact] + public void NamedBlockInner_Replace_EmptyBlock_InsertsContent() + { + var schema = CreateBlockContainerTestSchema(); + var tree = SyntaxTree.Parse("f{}", schema); + + tree.CreateEditor() + .Replace(Query.Syntax().Block("body").Inner(), "inserted") + .Commit(); + + Assert.Equal("f{inserted}", tree.ToText()); + } #endregion diff --git a/TinyTokenizer/Ast/IBlockContainerNode.cs b/TinyTokenizer/Ast/IBlockContainerNode.cs index cebd3eb..e142d2c 100644 --- a/TinyTokenizer/Ast/IBlockContainerNode.cs +++ b/TinyTokenizer/Ast/IBlockContainerNode.cs @@ -165,6 +165,109 @@ public bool TryMatch(SyntaxNode startNode, out int consumedCount) /// Use with InsertBefore to insert at the end of block content. /// public BoundaryQuery End() => new BoundaryQuery(this, BoundarySide.End); + + /// + /// Returns a query that selects all inner children of the named block as a single range. + /// Use with Replace/Edit/Remove to modify block content while preserving delimiters. + /// Empty blocks yield an empty region at the inner position, enabling insertion via Replace. + /// + /// + /// + /// // Replace the inside of the function body block + /// editor.Replace(Query.Syntax<FunctionSyntax>().Block("body").Inner(), "new content") + /// + /// + public NamedBlockInnerContentQuery Inner() => new(this); +} + +/// +/// A query that selects all inner children of blocks selected by as a single range. +/// Returns a region spanning slots 1 through SlotCount-2 (excluding opener/closer). +/// Empty blocks yield an empty region at slot 1, enabling insertion. +/// +public sealed record NamedBlockInnerContentQuery : INodeQuery, IRegionQuery +{ + /// The query that selects the target block. + public NamedBlockQuery BlockQuery { get; } + + internal NamedBlockInnerContentQuery(NamedBlockQuery blockQuery) + { + BlockQuery = blockQuery; + } + + /// + public IEnumerable Select(SyntaxTree tree) => Select(tree.Root); + + /// + public IEnumerable Select(SyntaxNode root) + { + foreach (var region in SelectRegionsCore(root)) + { + foreach (var node in region.Nodes) + yield return node; + } + } + + /// + public bool Matches(SyntaxNode node) + { + // Check if node is an inner child of a matching block + var parent = node.Parent; + if (parent is SyntaxBlock block && BlockQuery.Matches(block)) + { + var index = node.SiblingIndex; + return index >= 1 && index < block.SlotCount - 1; + } + return false; + } + + /// + public bool TryMatch(SyntaxNode startNode, out int consumedCount) + { + var parent = startNode.Parent; + if (parent is SyntaxBlock block && + BlockQuery.Matches(block) && + startNode.SiblingIndex == 1) + { + consumedCount = block.ChildCount; + return true; + } + consumedCount = 0; + return false; + } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => SelectRegionsCore(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + => SelectRegionsCore(root); + + private IEnumerable SelectRegionsCore(SyntaxNode root) + { + foreach (var container in BlockQuery.Select(root)) + { + if (container is not SyntaxBlock block) + continue; + + var innerCount = block.ChildCount; + SyntaxNode? firstInner = null; + foreach (var child in block.InnerChildren) + { + firstInner = child; + break; + } + + yield return new QueryRegion( + parent: block, + startSlot: 1, + endSlot: 1 + innerCount, + firstNode: firstInner, + position: block.InnerStartPosition + ); + } + } } /// From bb4683df1b37f796d58e92b63cda7265d6dd8710 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 12 Jan 2026 02:20:43 -0800 Subject: [PATCH 2/5] tests: add bug reproduction test case --- TinyTokenizer.E2ETests/GlslEditorTests.cs | 17 +++++++++++++++++ TinyTokenizer.Tests/SyntaxEditorTests.cs | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/TinyTokenizer.E2ETests/GlslEditorTests.cs b/TinyTokenizer.E2ETests/GlslEditorTests.cs index fddd790..969023e 100644 --- a/TinyTokenizer.E2ETests/GlslEditorTests.cs +++ b/TinyTokenizer.E2ETests/GlslEditorTests.cs @@ -193,6 +193,23 @@ public void DumpTokenTree() _output.WriteLine(dump); Assert.NotNull(dump); } + + [Fact] + public void Editor_Between_MethodBodyStartEnd_Replace_WithNonDelimitedText() + { + var schema = CreateGlslSchema(); + var tree = SyntaxTree.Parse("void main() { return 0.0; }", schema); + + var editor = tree.CreateEditor(); + + var functionName = "main"; + var methodBody = Query.Syntax().Named(functionName).Block("body"); + var bodyContents = Query.Between(methodBody.Start(), methodBody.End()); + + editor.Replace(bodyContents, "\nreturn 1.0;"); + + editor.Commit(); + } [Fact] public void Parse_RecognizesGlslFunctions() diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index 9a23d9b..921a6c1 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -935,7 +935,7 @@ public void NamedBlockInner_Replace_EmptyBlock_InsertsContent() Assert.Equal("f{inserted}", tree.ToText()); } - + #endregion #region Function-Like Block Insertion Scenarios From 11f7517a3f264b786adda7666c8f3755f92afda6 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 12 Jan 2026 02:43:32 -0800 Subject: [PATCH 3/5] refactor: `Query.Between` uses inclusive=false by default now --- TinyTokenizer.Tests/QueryCombinatorTests.cs | 41 +++++--- TinyTokenizer.Tests/SyntaxEditorTests.cs | 10 +- TinyTokenizer/Ast/Query.cs | 6 +- TinyTokenizer/Ast/QueryCombinators.cs | 103 ++++++++++++++++++-- TinyTokenizer/Ast/QueryExtensions.cs | 4 +- 5 files changed, 131 insertions(+), 33 deletions(-) diff --git a/TinyTokenizer.Tests/QueryCombinatorTests.cs b/TinyTokenizer.Tests/QueryCombinatorTests.cs index 02f2992..23117ad 100644 --- a/TinyTokenizer.Tests/QueryCombinatorTests.cs +++ b/TinyTokenizer.Tests/QueryCombinatorTests.cs @@ -1294,21 +1294,34 @@ public void BOF_MatchesOnlyAtRootLevel() #region Edge Case Tests - Between [Fact] - public void Between_Exclusive_DoesNotCountDelimiters() + public void Between_Default_IsExclusive_ForEditorRegions() { - var tree = Parse("< a b c >"); - var root = tree.Root; - var children = root.Children.ToList(); - var openAngle = children.First(c => c.ToText().Trim() == "<"); - - var queryInclusive = Query.Between(Query.Operator("<"), Query.Operator(">"), inclusive: true); - var queryExclusive = Query.Between(Query.Operator("<"), Query.Operator(">"), inclusive: false); - - Assert.True(queryInclusive.TryMatch(openAngle, out var consumedInclusive)); - Assert.True(queryExclusive.TryMatch(openAngle, out var consumedExclusive)); - - // Exclusive should report fewer consumed (just the content, not delimiters) - Assert.True(consumedExclusive < consumedInclusive); + var tree = Parse("before < a b c > after"); + + // Default Between should replace ONLY the content between delimiters. + tree.CreateEditor() + .Replace(Query.Between(Query.Operator("<"), Query.Operator(">")), " CONTENT ") + .Commit(); + + var text = tree.ToText(); + Assert.Contains("<", text); + Assert.Contains(">", text); + Assert.Matches(@"<\s*CONTENT\s*>", text); + } + + [Fact] + public void Between_Inclusive_ReplacesDelimitersToo() + { + var tree = Parse("before < a b c > after"); + + tree.CreateEditor() + .Replace(Query.Between(Query.Operator("<"), Query.Operator(">"), inclusive: true), " CONTENT ") + .Commit(); + + var text = tree.ToText(); + Assert.DoesNotContain("<", text); + Assert.DoesNotContain(">", text); + Assert.Contains("CONTENT", text); } [Fact] diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index 921a6c1..8d844cd 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -3006,7 +3006,7 @@ public void Replace_QueryBetween_ReplacesEntireRange() // Act: replace everything from 'a' to 'c' (inclusive) tree.CreateEditor() - .Replace(Q.Between(Q.Ident("a"), Q.Ident("c")), "REPLACED") + .Replace(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true), "REPLACED") .Commit(); // Assert: the range [a, b, c] should be replaced with REPLACED @@ -3022,7 +3022,7 @@ public void Remove_QueryBetween_RemovesEntireRange() var tree = SyntaxTree.Parse("start a b c end"); tree.CreateEditor() - .Remove(Q.Between(Q.Ident("a"), Q.Ident("c"))) + .Remove(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true)) .Commit(); // Trivia handling: leading trivia of 'a' (space) is removed with 'a', @@ -3039,7 +3039,7 @@ public void InsertBefore_QueryBetween_InsertsBeforeRange() var tree = SyntaxTree.Parse("x a b c y"); tree.CreateEditor() - .InsertBefore(Q.Between(Q.Ident("a"), Q.Ident("c")), "BEFORE ") + .InsertBefore(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true), "BEFORE ") .Commit(); Assert.Equal("x BEFORE a b c y", tree.ToText()); @@ -3061,7 +3061,7 @@ public void InsertAfter_QueryBetween_InsertsAfterRange() // - Insert "AFTER " (with trailing space for separation from y) // - y has no trivia -> "y" tree.CreateEditor() - .InsertAfter(Q.Between(Q.Ident("a"), Q.Ident("c")), "AFTER ") + .InsertAfter(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true), "AFTER ") .Commit(); Assert.Equal("x a b c AFTER y", tree.ToText()); @@ -3105,7 +3105,7 @@ public void Edit_QueryBetween_TransformsRangeContent() var tree = SyntaxTree.Parse("x abc def ghi y"); tree.CreateEditor() - .Edit(Q.Between(Q.Ident("abc"), Q.Ident("ghi")), content => content.ToUpper()) + .Edit(Q.Between(Q.Ident("abc"), Q.Ident("ghi"), inclusive: true), content => content.ToUpper()) .Commit(); // The content between abc and ghi (inclusive) should be uppercased diff --git a/TinyTokenizer/Ast/Query.cs b/TinyTokenizer/Ast/Query.cs index 56e88e3..9cbf1c3 100644 --- a/TinyTokenizer/Ast/Query.cs +++ b/TinyTokenizer/Ast/Query.cs @@ -189,11 +189,11 @@ public static class Query /// /// Creates a query that matches content between a start and end pattern. - /// Consumes all nodes from start through end (inclusive). + /// By default, the matched region excludes the start/end delimiters. /// /// The starting delimiter/pattern. /// The ending delimiter/pattern. - /// If true (default), includes start/end in consumed count. + /// If true, includes start/end delimiters in the matched region. /// A query matching the content between start and end. /// /// @@ -201,7 +201,7 @@ public static class Query /// Query.Between(Query.Symbol("("), Query.Symbol(")")) /// /// - public static BetweenQuery Between(INodeQuery start, INodeQuery end, bool inclusive = true) => + public static BetweenQuery Between(INodeQuery start, INodeQuery end, bool inclusive = false) => new BetweenQuery(start, end, inclusive); #endregion diff --git a/TinyTokenizer/Ast/QueryCombinators.cs b/TinyTokenizer/Ast/QueryCombinators.cs index 6744a35..ce78bbe 100644 --- a/TinyTokenizer/Ast/QueryCombinators.cs +++ b/TinyTokenizer/Ast/QueryCombinators.cs @@ -80,7 +80,8 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// /// Matches and captures content between a start and end query/delimiter. -/// Consumes all nodes from start through end (inclusive of delimiters). +/// Matches a contiguous region between a start and end pattern. +/// By default, the matched region excludes the start/end delimiters. /// /// /// Useful for extracting content between matching patterns: @@ -95,8 +96,8 @@ public sealed record BetweenQuery : INodeQuery, IGreenNodeQuery, IRegionQuery, I /// Creates a query matching content between start and end. /// The starting delimiter/pattern. /// The ending delimiter/pattern. - /// If true, includes start/end in consumed count; if false, only content between. - public BetweenQuery(INodeQuery start, INodeQuery end, bool inclusive = true) + /// If true, includes the start/end delimiters in the matched region. + public BetweenQuery(INodeQuery start, INodeQuery end, bool inclusive = false) { _start = start; _end = end; @@ -156,7 +157,11 @@ public bool TryMatch(SyntaxNode startNode, out int consumedCount) if (_end.TryMatch(current, out var endConsumed)) { totalConsumed += endConsumed; - consumedCount = _inclusive ? totalConsumed : totalConsumed - startConsumed - endConsumed; + // Always report the full span consumed (start..end, inclusive) so this query + // is safe to use in sequences and other sibling-consuming contexts. + // The inclusive/exclusive behavior is applied when translating this query + // into edit regions via IRegionQuery. + consumedCount = totalConsumed; return true; } @@ -196,7 +201,9 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI if (endGreen.TryMatchGreen(siblings, currentIndex, out var endConsumed)) { totalConsumed += endConsumed; - consumedCount = _inclusive ? totalConsumed : totalConsumed - startConsumed - endConsumed; + // Always report the full span consumed (start..end, inclusive). + // Inclusive/exclusive is handled at the region-translation layer. + consumedCount = totalConsumed; return true; } @@ -209,13 +216,91 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI } /// - IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) => ((IRegionQuery)this).SelectRegions(tree.Root); - - /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) { - return RegionTraversal.SelectRegions(root, TryMatch); + var walker = new TreeWalker(root); + + foreach (var node in walker.DescendantsAndSelf()) + { + var parent = node.Parent; + if (parent == null) + continue; + + if (!_start.TryMatch(node, out var startConsumed)) + continue; + + // Advance to first node after the start match. + var afterStart = node; + for (int i = 0; i < startConsumed && afterStart != null; i++) + { + afterStart = afterStart.NextSibling(); + } + + if (afterStart == null) + continue; + + // Scan for end starting at the first node after start. + var current = afterStart; + while (current != null) + { + if (_end.TryMatch(current, out var endConsumed)) + { + var startSlot = node.SiblingIndex; + var endStartSlot = current.SiblingIndex; + + if (startSlot < 0 || endStartSlot < 0) + break; + + if (_inclusive) + { + yield return new QueryRegion( + parent: parent, + startSlot: startSlot, + endSlot: endStartSlot + endConsumed, + firstNode: node, + position: node.Position); + } + else + { + var regionStart = startSlot + startConsumed; + var regionEnd = endStartSlot; + + if (regionStart < 0) + regionStart = 0; + if (regionEnd < regionStart) + regionEnd = regionStart; + + SyntaxNode? firstNode = null; + int position = current.Position; + + for (int i = regionStart; i < regionEnd; i++) + { + var child = parent.GetChild(i); + if (child != null) + { + firstNode = child; + position = child.Position; + break; + } + } + + yield return new QueryRegion( + parent: parent, + startSlot: regionStart, + endSlot: regionEnd, + firstNode: firstNode, + position: position); + } + + break; + } + + current = current.NextSibling(); + } + } } } diff --git a/TinyTokenizer/Ast/QueryExtensions.cs b/TinyTokenizer/Ast/QueryExtensions.cs index 73da281..057e54f 100644 --- a/TinyTokenizer/Ast/QueryExtensions.cs +++ b/TinyTokenizer/Ast/QueryExtensions.cs @@ -119,9 +119,9 @@ public static NoneOfQuery ExceptFor(this INodeQuery query, params INodeQuery[] o /// /// Creates a query matching content between this query (start) and the end query. - /// Consumes all nodes from start through end (inclusive). + /// By default, the matched region excludes the start/end delimiters. /// - public static BetweenQuery Between(this INodeQuery start, INodeQuery end, bool inclusive = true) => + public static BetweenQuery Between(this INodeQuery start, INodeQuery end, bool inclusive = false) => new(start, end, inclusive); #endregion From b0b82f46ae3625b398ee4c6c672813830f2a84ef Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 12 Jan 2026 02:57:40 -0800 Subject: [PATCH 4/5] refactor: more efficient region traversal for queries --- TinyTokenizer/Ast/QueryCombinators.cs | 92 ++++++++++----------------- TinyTokenizer/Ast/QueryRegion.cs | 50 +++++++++++++++ 2 files changed, 85 insertions(+), 57 deletions(-) diff --git a/TinyTokenizer/Ast/QueryCombinators.cs b/TinyTokenizer/Ast/QueryCombinators.cs index ce78bbe..097cb33 100644 --- a/TinyTokenizer/Ast/QueryCombinators.cs +++ b/TinyTokenizer/Ast/QueryCombinators.cs @@ -221,85 +221,63 @@ IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) { - var walker = new TreeWalker(root); - - foreach (var node in walker.DescendantsAndSelf()) + return RegionTraversal.SelectRegions(root, TryGetBetweenRegion); + + bool TryGetBetweenRegion( + SyntaxNode startNode, + out int startSlotOffset, + out int slotCount, + out SyntaxNode? firstNode, + out int position) { - var parent = node.Parent; - if (parent == null) - continue; + startSlotOffset = 0; + slotCount = 0; + firstNode = null; + position = 0; - if (!_start.TryMatch(node, out var startConsumed)) - continue; + if (!_start.TryMatch(startNode, out var startConsumed)) + return false; - // Advance to first node after the start match. - var afterStart = node; + // Navigate to the first node AFTER the start match. + var afterStart = startNode; for (int i = 0; i < startConsumed && afterStart != null; i++) - { afterStart = afterStart.NextSibling(); - } if (afterStart == null) - continue; + return false; - // Scan for end starting at the first node after start. + // Scan for end starting at afterStart. var current = afterStart; + int totalConsumed = startConsumed; + while (current != null) { if (_end.TryMatch(current, out var endConsumed)) { - var startSlot = node.SiblingIndex; - var endStartSlot = current.SiblingIndex; - - if (startSlot < 0 || endStartSlot < 0) - break; + totalConsumed += endConsumed; if (_inclusive) { - yield return new QueryRegion( - parent: parent, - startSlot: startSlot, - endSlot: endStartSlot + endConsumed, - firstNode: node, - position: node.Position); - } - else - { - var regionStart = startSlot + startConsumed; - var regionEnd = endStartSlot; - - if (regionStart < 0) - regionStart = 0; - if (regionEnd < regionStart) - regionEnd = regionStart; - - SyntaxNode? firstNode = null; - int position = current.Position; - - for (int i = regionStart; i < regionEnd; i++) - { - var child = parent.GetChild(i); - if (child != null) - { - firstNode = child; - position = child.Position; - break; - } - } - - yield return new QueryRegion( - parent: parent, - startSlot: regionStart, - endSlot: regionEnd, - firstNode: firstNode, - position: position); + startSlotOffset = 0; + slotCount = totalConsumed; + firstNode = startNode; + position = startNode.Position; + return true; } - break; + // Exclusive: region starts after the start match and ends before the end delimiter. + startSlotOffset = startConsumed; + slotCount = totalConsumed - startConsumed - endConsumed; + position = afterStart.Position; + firstNode = slotCount > 0 ? afterStart : null; + return true; } + totalConsumed++; current = current.NextSibling(); } + + return false; } } } diff --git a/TinyTokenizer/Ast/QueryRegion.cs b/TinyTokenizer/Ast/QueryRegion.cs index 616b2d2..469bb52 100644 --- a/TinyTokenizer/Ast/QueryRegion.cs +++ b/TinyTokenizer/Ast/QueryRegion.cs @@ -141,6 +141,13 @@ internal static class RegionTraversal { internal delegate bool TryGetRegionDelegate(SyntaxNode node, out int consumedCount); + internal delegate bool TryGetOffsetRegionDelegate( + SyntaxNode node, + out int startSlotOffset, + out int slotCount, + out SyntaxNode? firstNode, + out int position); + internal static IEnumerable SelectRegions(SyntaxNode root, TryGetRegionDelegate tryGetRegion) { ArgumentNullException.ThrowIfNull(tryGetRegion); @@ -172,6 +179,49 @@ internal static IEnumerable SelectRegions(SyntaxNode root, TryGetRe } } + internal static IEnumerable SelectRegions(SyntaxNode root, TryGetOffsetRegionDelegate tryGetRegion) + { + ArgumentNullException.ThrowIfNull(tryGetRegion); + + var pathStack = new List(8); + var current = root; + + while (true) + { + if (current.Parent != null && + tryGetRegion(current, out var startSlotOffset, out var slotCount, out var firstNode, out var position)) + { + var parent = current.Parent; + var startSlot = current.SiblingIndex; + + if (startSlot >= 0) + { + var regionStart = startSlot + startSlotOffset; + if (regionStart < 0) + regionStart = 0; + + if (slotCount < 0) + slotCount = 0; + + yield return new QueryRegion( + parentPath: CreateParentPath(pathStack), + parent: parent, + startSlot: regionStart, + endSlot: regionStart + slotCount, + firstNode: firstNode, + position: position + ); + } + } + + if (TryMoveToFirstChild(ref current, pathStack)) + continue; + + if (!TryMoveToNextSiblingOrAncestor(ref current, pathStack)) + break; + } + } + private static NodePath CreateParentPath(List pathStack) { // pathStack is the path to the CURRENT node; parent path is pathStack without the last element. From 9a22f711a9498c6b415fc022edf2116ad6870ec4 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 12 Jan 2026 03:05:21 -0800 Subject: [PATCH 5/5] feat: add wrapper queries --- TinyTokenizer.Tests/SyntaxEditorTests.cs | 33 +++++++++++++++++ TinyTokenizer/Ast/Query.cs | 45 ++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index 8d844cd..4a0a41a 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -3130,4 +3130,37 @@ public void Replace_QuerySequence_ReplacesAllMatchedNodes() } #endregion + + #region Query.Wrap / Query.Inner (BlockNode) + + [Fact] + public void Replace_QueryWrapBlock_Inner_ReplacesOnlyThatBlock() + { + var tree = SyntaxTree.Parse("{a}{b}"); + var blocks = tree.Select(Query.BraceBlock).OfType().ToList(); + Assert.Equal(2, blocks.Count); + + var second = blocks[1]; + + tree.CreateEditor() + .Replace(Query.Wrap(second).Inner(), "c") + .Commit(); + + Assert.Equal("{a}{c}", tree.ToText()); + } + + [Fact] + public void Replace_QueryInnerBlock_ReplacesOnlyThatBlock() + { + var tree = SyntaxTree.Parse("{a}{b}"); + var second = tree.Select(Query.BraceBlock).OfType().Skip(1).First(); + + tree.CreateEditor() + .Replace(Query.Inner(second), "c") + .Commit(); + + Assert.Equal("{a}{c}", tree.ToText()); + } + + #endregion } diff --git a/TinyTokenizer/Ast/Query.cs b/TinyTokenizer/Ast/Query.cs index 9cbf1c3..b9bee3d 100644 --- a/TinyTokenizer/Ast/Query.cs +++ b/TinyTokenizer/Ast/Query.cs @@ -309,6 +309,51 @@ public static BetweenQuery Between(INodeQuery start, INodeQuery end, bool inclus /// public static ExactNodeQuery Exact(SyntaxNode node) => new ExactNodeQuery(node); + #endregion + + #region Wrap / Inner (Node-based) + + /// + /// Wraps an existing instance as a . + /// This is useful when you already have a block node and want to use block-specific query helpers + /// like , , and . + /// + /// + /// Like , this query is intended for immediate use within the current tree state. + /// Red nodes are recreated on tree mutations. + /// + public static BlockNodeQuery Wrap(SyntaxBlock block) + { + ArgumentNullException.ThrowIfNull(block); + + // Match the block by red-node equality (same green node + position). + // Also constrain by opener for fast rejection. + return new BlockNodeQuery(block.Opener).Where(n => n == block); + } + + /// + /// Wraps an existing node instance as a query. + /// If the node is a , returns a ; + /// otherwise returns an . + /// + /// + /// Like , this matches the specific node instance and is intended for immediate use. + /// + public static INodeQuery Wrap(SyntaxNode node) + { + ArgumentNullException.ThrowIfNull(node); + return node is SyntaxBlock block ? Wrap(block) : Exact(node); + } + + /// + /// Creates a query selecting the inner content region of an existing block node. + /// Equivalent to Query.Wrap(block).Inner(). + /// + /// + /// This query matches the current node instance and is intended for immediate use. + /// + public static InnerContentQuery Inner(SyntaxBlock block) => Wrap(block).Inner(); + #endregion #region Keyword Queries