From 1746bd687932b542e74b9962582bf9c0ae25be78 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Mon, 5 Jan 2026 21:45:44 -0800 Subject: [PATCH 1/3] issue reproduction tests --- TinyTokenizer.Tests/SyntaxEditorTests.cs | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index ab9591e..8d3c59e 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -1813,4 +1813,56 @@ public void TriviaPreservation_NestedBlocksWithSchema_IndentationBeforeElseLost( } #endregion + + #region Query.Between Replace Bug + + /// + /// Reproduction test for Query.Between replacement bug. + /// Using Query.Between to select content between function block delimiters + /// should allow replacing the inner contents of a function body. + /// + [Fact] + public void Replace_BetweenBraces_ReplacesInnerContents() + { + // Arrange: a simple function with a body + var tree = SyntaxTree.Parse("function foo() { old body content }"); + + // Act: use Query.Between to match content between { and } + // and replace it with new content + var query = Q.Between(Q.Symbol("{"), Q.Symbol("}")); + + tree.CreateEditor() + .Replace(query, "{ new body }") + .Commit(); + + // Assert: the function body should be replaced + var result = tree.ToText(); + Assert.Equal("function foo() { new body }", result); + } + + /// + /// Tests that Query.Between can be used to replace inner block content + /// while preserving surrounding structure. + /// + [Fact] + public void Replace_BetweenBraces_InFunctionBlock_PreservesStructure() + { + // Arrange: code with a function containing a block + var tree = SyntaxTree.Parse("int main() { return 0; }"); + + // Act: select the brace block content using Between and replace + var query = Q.Between(Q.Symbol("{"), Q.Symbol("}")); + + tree.CreateEditor() + .Replace(query, "{ return 42; }") + .Commit(); + + // Assert: the block content should be replaced while keeping function signature + var result = tree.ToText(); + Assert.Contains("int main()", result); + Assert.Contains("return 42", result); + Assert.DoesNotContain("return 0", result); + } + + #endregion } From 3529f7fd5703d87f87de8e10094cd5d5eac89c0e Mon Sep 17 00:00:00 2001 From: David Sisco Date: Tue, 6 Jan 2026 18:35:48 -0800 Subject: [PATCH 2/3] refactor the entire node query system ti fix fundamental shortcomings and discrepancies --- .../SyntaxEditorBenchmarks.cs | 375 ++++++++++++ TinyTokenizer.Tests/GreenNodeTests.cs | 201 +++--- TinyTokenizer.Tests/QueryCombinatorTests.cs | 24 +- TinyTokenizer.Tests/SyntaxEditorTests.cs | 364 ++++++++++- TinyTokenizer.Tests/SyntaxTreeTests.cs | 40 +- TinyTokenizer.Tests/TreeWalkerTests.cs | 17 +- TinyTokenizer.code-workspace | 4 +- TinyTokenizer/Ast/GreenBlock.cs | 203 ++++++- TinyTokenizer/Ast/GreenNode.cs | 88 +++ TinyTokenizer/Ast/NodeQuery.cs | 74 ++- TinyTokenizer/Ast/NodeQueryTypes.cs | 286 ++++++++- TinyTokenizer/Ast/QueryCombinators.cs | 181 +++++- TinyTokenizer/Ast/QueryRegion.cs | 245 ++++++++ TinyTokenizer/Ast/SemanticMatchExtensions.cs | 6 + TinyTokenizer/Ast/SyntaxBlock.cs | 78 ++- TinyTokenizer/Ast/SyntaxEditor.cs | 572 ++++++++++++------ 16 files changed, 2363 insertions(+), 395 deletions(-) create mode 100644 TinyTokenizer.Benchmarks/SyntaxEditorBenchmarks.cs create mode 100644 TinyTokenizer/Ast/QueryRegion.cs diff --git a/TinyTokenizer.Benchmarks/SyntaxEditorBenchmarks.cs b/TinyTokenizer.Benchmarks/SyntaxEditorBenchmarks.cs new file mode 100644 index 0000000..9f73d1c --- /dev/null +++ b/TinyTokenizer.Benchmarks/SyntaxEditorBenchmarks.cs @@ -0,0 +1,375 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using TinyTokenizer.Ast; +using Q = TinyTokenizer.Ast.Query; + +namespace TinyTokenizer.Benchmarks; + +/// +/// Benchmarks for SyntaxEditor operations including replacements, insertions, +/// and region resolution on various tree sizes and depths. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class SyntaxEditorBenchmarks +{ + #region Test Data + + /// + /// Simple expression for single-token replacements. + /// + private const string SimpleExpression = "foo + bar * baz"; + + /// + /// Empty block for inner replacement benchmarks. + /// + private const string EmptyBlockInput = "function test() { }"; + + /// + /// Block with content for inner replacement benchmarks. + /// + private const string BlockWithContent = """ + function test() { + var x = 1; + var y = 2; + return x + y; + } + """; + + /// + /// Multiple functions for multi-position insertions. + /// + private static readonly string MultiFunctionInput = GenerateMultiFunctionInput(); + + /// + /// Deeply nested blocks for deep tree traversal. + /// + private static readonly string DeeplyNestedInput = GenerateDeeplyNestedInput(20); + + /// + /// Very deeply nested blocks for stress testing. + /// + private static readonly string VeryDeeplyNestedInput = GenerateDeeplyNestedInput(50); + + private static readonly Schema DefaultSchema = Schema.Create() + .AddCommentStyles(CommentStyle.CStyleSingleLine, CommentStyle.CStyleMultiLine) + .WithOperators(CommonOperators.CFamily) + .Build(); + + // Pre-parsed trees + private static readonly SyntaxTree SimpleTree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + private static readonly SyntaxTree EmptyBlockTree = SyntaxTree.Parse(EmptyBlockInput, DefaultSchema); + private static readonly SyntaxTree BlockWithContentTree = SyntaxTree.Parse(BlockWithContent, DefaultSchema); + private static readonly SyntaxTree MultiFunctionTree = SyntaxTree.Parse(MultiFunctionInput, DefaultSchema); + private static readonly SyntaxTree DeeplyNestedTree = SyntaxTree.Parse(DeeplyNestedInput, DefaultSchema); + private static readonly SyntaxTree VeryDeeplyNestedTree = SyntaxTree.Parse(VeryDeeplyNestedInput, DefaultSchema); + + private static string GenerateMultiFunctionInput() + { + var template = """ + function func{0}(a, b) {{ + return a + b; + }} + + """; + + return string.Concat(Enumerable.Range(0, 50).Select(i => + string.Format(template, i))); + } + + private static string GenerateDeeplyNestedInput(int depth) + { + var open = string.Concat(Enumerable.Repeat("{ ", depth)); + var close = string.Concat(Enumerable.Repeat(" }", depth)); + return $"function deep() {open}x{close}"; + } + + #endregion + + #region Replace - Single Token + + /// + /// Replace a single identifier token with another identifier. + /// Baseline for minimal edit operation. + /// + [Benchmark(Baseline = true)] + [BenchmarkCategory("Replace")] + public SyntaxTree Replace_SingleToken() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + tree.CreateEditor() + .Replace(Q.Ident("foo"), "replaced") + .Commit(); + return tree; + } + + /// + /// Replace multiple tokens in a single edit batch. + /// + [Benchmark] + [BenchmarkCategory("Replace")] + public SyntaxTree Replace_MultipleTokens() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + tree.CreateEditor() + .Replace(Q.Ident("foo"), "a") + .Replace(Q.Ident("bar"), "b") + .Replace(Q.Ident("baz"), "c") + .Commit(); + return tree; + } + + /// + /// Replace all identifiers using a single query. + /// + [Benchmark] + [BenchmarkCategory("Replace")] + public SyntaxTree Replace_AllIdents() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + tree.CreateEditor() + .Replace(Q.AnyIdent, "x") + .Commit(); + return tree; + } + + #endregion + + #region Replace - Block Inner + + /// + /// Replace the inner content of an empty block. + /// Tests zero-width region handling. + /// + [Benchmark] + [BenchmarkCategory("BlockInner")] + public SyntaxTree Replace_BlockInner_Empty() + { + var tree = SyntaxTree.Parse(EmptyBlockInput, DefaultSchema); + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), "return 42;") + .Commit(); + return tree; + } + + /// + /// Replace the inner content of a block with existing content. + /// Tests multi-slot region replacement. + /// + [Benchmark] + [BenchmarkCategory("BlockInner")] + public SyntaxTree Replace_BlockInner_WithContent() + { + var tree = SyntaxTree.Parse(BlockWithContent, DefaultSchema); + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), "return 0;") + .Commit(); + return tree; + } + + /// + /// Replace inner content with larger replacement text. + /// Tests tree rebuilding with size change. + /// + [Benchmark] + [BenchmarkCategory("BlockInner")] + public SyntaxTree Replace_BlockInner_Larger() + { + var tree = SyntaxTree.Parse(EmptyBlockInput, DefaultSchema); + var largeContent = string.Join("\n", Enumerable.Range(0, 20).Select(i => $"var x{i} = {i};")); + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), largeContent) + .Commit(); + return tree; + } + + #endregion + + #region Insert - Multiple Positions + + /// + /// Insert after a single position. + /// + [Benchmark] + [BenchmarkCategory("Insert")] + public SyntaxTree InsertAfter_SinglePosition() + { + var tree = SyntaxTree.Parse(MultiFunctionInput, DefaultSchema); + tree.CreateEditor() + .InsertAfter(Q.BraceBlock.First().Start(), "// inserted\n") + .Commit(); + return tree; + } + + /// + /// Insert after all function body starts (50 positions). + /// Tests edit batching with many positions. + /// + [Benchmark] + [BenchmarkCategory("Insert")] + public SyntaxTree InsertAfter_ManyPositions() + { + var tree = SyntaxTree.Parse(MultiFunctionInput, DefaultSchema); + tree.CreateEditor() + .InsertAfter(Q.BraceBlock.Start(), "// inserted\n") + .Commit(); + return tree; + } + + /// + /// Insert before all closing braces. + /// + [Benchmark] + [BenchmarkCategory("Insert")] + public SyntaxTree InsertBefore_ManyPositions() + { + var tree = SyntaxTree.Parse(MultiFunctionInput, DefaultSchema); + tree.CreateEditor() + .InsertBefore(Q.BraceBlock.End(), "\n// end") + .Commit(); + return tree; + } + + #endregion + + #region SelectRegions - Deep Trees + + /// + /// Select regions in a moderately deep tree (20 levels). + /// + [Benchmark] + [BenchmarkCategory("SelectRegions")] + public int SelectRegions_DeepTree() + { + var count = 0; + foreach (var _ in ((IRegionQuery)Q.AnyIdent).SelectRegions(DeeplyNestedTree)) + { + count++; + } + return count; + } + + /// + /// Select regions in a very deep tree (50 levels). + /// + [Benchmark] + [BenchmarkCategory("SelectRegions")] + public int SelectRegions_VeryDeepTree() + { + var count = 0; + foreach (var _ in ((IRegionQuery)Q.AnyIdent).SelectRegions(VeryDeeplyNestedTree)) + { + count++; + } + return count; + } + + /// + /// Select block regions in a deep tree. + /// Tests BlockNodeQuery optimization. + /// + [Benchmark] + [BenchmarkCategory("SelectRegions")] + public int SelectRegions_DeepTree_Blocks() + { + var count = 0; + foreach (var _ in ((IRegionQuery)Q.BraceBlock).SelectRegions(DeeplyNestedTree)) + { + count++; + } + return count; + } + + /// + /// Select first match only - tests short-circuit. + /// + [Benchmark] + [BenchmarkCategory("SelectRegions")] + public int SelectRegions_DeepTree_First() + { + var count = 0; + foreach (var _ in ((IRegionQuery)Q.AnyIdent.First()).SelectRegions(VeryDeeplyNestedTree)) + { + count++; + } + return count; + } + + #endregion + + #region Edit with Transformer + + /// + /// Edit using a transformer function (uppercase identifiers). + /// + [Benchmark] + [BenchmarkCategory("Transform")] + public SyntaxTree Edit_Transform_SingleToken() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + tree.CreateEditor() + .Edit(Q.AnyIdent, text => text.ToUpperInvariant()) + .Commit(); + return tree; + } + + /// + /// Edit using Replace with RedNode transformer. + /// + [Benchmark] + [BenchmarkCategory("Transform")] + public SyntaxTree Replace_WithNodeTransformer() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + tree.CreateEditor() + .Replace(Q.AnyIdent, node => node.ToText().ToUpperInvariant()) + .Commit(); + return tree; + } + + #endregion + + #region Undo/Redo + + /// + /// Perform edit then undo. + /// + [Benchmark] + [BenchmarkCategory("UndoRedo")] + public SyntaxTree Edit_ThenUndo() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + tree.CreateEditor() + .Replace(Q.Ident("foo"), "replaced") + .Commit(); + tree.Undo(); + return tree; + } + + /// + /// Perform multiple edits then undo all. + /// + [Benchmark] + [BenchmarkCategory("UndoRedo")] + public SyntaxTree MultipleEdits_ThenUndoAll() + { + var tree = SyntaxTree.Parse(SimpleExpression, DefaultSchema); + + for (int i = 0; i < 5; i++) + { + tree.CreateEditor() + .Replace(Q.AnyIdent.First(), $"v{i}") + .Commit(); + } + + while (tree.CanUndo) + { + tree.Undo(); + } + + return tree; + } + + #endregion +} diff --git a/TinyTokenizer.Tests/GreenNodeTests.cs b/TinyTokenizer.Tests/GreenNodeTests.cs index d4ca5bd..20512b2 100644 --- a/TinyTokenizer.Tests/GreenNodeTests.cs +++ b/TinyTokenizer.Tests/GreenNodeTests.cs @@ -46,12 +46,16 @@ public void GreenNode_GetSlotOffset_ComputesCorrectly() var child3 = new GreenLeaf(NodeKind.Ident, "c"); // width 1 var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3)); - // Offset 0: after opener '{' (1 char) - Assert.Equal(1, block.GetSlotOffset(0)); - // Offset 1: after child1 (1 + 3 = 4) - Assert.Equal(4, block.GetSlotOffset(1)); - // Offset 2: after child2 (1 + 3 + 2 = 6) - Assert.Equal(6, block.GetSlotOffset(2)); + // Slot 0: opener '{' at offset 0 + Assert.Equal(0, block.GetSlotOffset(0)); + // Slot 1: first inner child after opener (1) + Assert.Equal(1, block.GetSlotOffset(1)); + // Slot 2: after child1 (1 + 3 = 4) + Assert.Equal(4, block.GetSlotOffset(2)); + // Slot 3: after child2 (1 + 3 + 2 = 6) + Assert.Equal(6, block.GetSlotOffset(3)); + // Slot 4: closer after all children (1 + 3 + 2 + 1 = 7) + Assert.Equal(7, block.GetSlotOffset(4)); } #endregion @@ -327,7 +331,10 @@ public void GreenBlock_SlotCount_ReturnsChildCount() new GreenLeaf(NodeKind.Ident, "c")); var block = GreenBlock.Create('{', children); - Assert.Equal(3, block.SlotCount); + // SlotCount = opener + 3 inner children + closer = 5 + Assert.Equal(5, block.SlotCount); + // InnerChildren should still be 3 + Assert.Equal(3, block.InnerChildren.Length); } [Fact] @@ -336,7 +343,10 @@ public void GreenBlock_GetSlot_ReturnsChild() var child = new GreenLeaf(NodeKind.Ident, "test"); var block = GreenBlock.Create('{', ImmutableArray.Create(child)); - Assert.Same(child, block.GetSlot(0)); + // Slot 0 is opener, slot 1 is first inner child, slot 2 is closer + Assert.Equal(NodeKind.Symbol, block.GetSlot(0)!.Kind); // opener + Assert.Same(child, block.GetSlot(1)); // inner child + Assert.Equal(NodeKind.Symbol, block.GetSlot(2)!.Kind); // closer } [Fact] @@ -344,7 +354,10 @@ public void GreenBlock_GetSlot_OutOfRange_ReturnsNull() { var block = GreenBlock.Create('{', ImmutableArray.Empty); - Assert.Null(block.GetSlot(0)); + // Empty block has 2 slots: opener (0) and closer (1) + Assert.NotNull(block.GetSlot(0)); // opener + Assert.NotNull(block.GetSlot(1)); // closer + Assert.Null(block.GetSlot(2)); // out of range Assert.Null(block.GetSlot(-1)); Assert.Null(block.GetSlot(100)); } @@ -371,10 +384,14 @@ public void GreenBlock_GetSlotOffset_SmallBlock() var child2 = new GreenLeaf(NodeKind.Ident, "bbb"); // width 3 var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2)); - // slot 0: after opener = 1 - Assert.Equal(1, block.GetSlotOffset(0)); - // slot 1: after opener + child1 = 1 + 2 = 3 - Assert.Equal(3, block.GetSlotOffset(1)); + // slot 0: opener at offset 0 + Assert.Equal(0, block.GetSlotOffset(0)); + // slot 1: first child after opener = 1 + Assert.Equal(1, block.GetSlotOffset(1)); + // slot 2: after opener + child1 = 1 + 2 = 3 + Assert.Equal(3, block.GetSlotOffset(2)); + // slot 3: closer after all = 1 + 2 + 3 = 6 + Assert.Equal(6, block.GetSlotOffset(3)); } [Fact] @@ -384,8 +401,10 @@ public void GreenBlock_GetSlotOffset_WithLeadingTrivia() var child = new GreenLeaf(NodeKind.Ident, "x"); var block = GreenBlock.Create('{', ImmutableArray.Create(child), leading); - // slot 0: leading trivia(2) + opener(1) = 3 - Assert.Equal(3, block.GetSlotOffset(0)); + // slot 0: opener at offset 0 (trivia is part of opener's width) + Assert.Equal(0, block.GetSlotOffset(0)); + // slot 1: first child after opener = leading trivia(2) + opener(1) = 3 + Assert.Equal(3, block.GetSlotOffset(1)); } [Fact] @@ -399,13 +418,19 @@ public void GreenBlock_GetSlotOffset_LargeBlock_UsesPrecomputed() } var block = GreenBlock.Create('{', children.ToImmutableArray()); - // Verify offsets are computed correctly + // Slot 0 is opener at offset 0 + Assert.Equal(0, block.GetSlotOffset(0)); + + // Verify inner child offsets are computed correctly (slots 1..15) int expectedOffset = 1; // After opener for (int i = 0; i < 15; i++) { - Assert.Equal(expectedOffset, block.GetSlotOffset(i)); + Assert.Equal(expectedOffset, block.GetSlotOffset(i + 1)); // +1 because slot 0 is opener expectedOffset += children[i].Width; } + + // Slot 16 is closer + Assert.Equal(expectedOffset, block.GetSlotOffset(16)); } #endregion @@ -419,11 +444,12 @@ public void GreenBlock_WithSlot_ReplacesChild() var replacement = new GreenLeaf(NodeKind.Ident, "new"); var block = GreenBlock.Create('{', ImmutableArray.Create(original)); - var modified = block.WithSlot(0, replacement); + // Replace slot 1 (first inner child) + var modified = block.WithSlot(1, replacement); Assert.NotSame(block, modified); - Assert.Same(replacement, modified.GetSlot(0)); - Assert.Same(original, block.GetSlot(0)); // Original unchanged + Assert.Same(replacement, modified.GetSlot(1)); + Assert.Same(original, block.GetSlot(1)); // Original unchanged } [Fact] @@ -435,12 +461,13 @@ public void GreenBlock_WithSlot_SharesUnchangedChildren() var replacement = new GreenLeaf(NodeKind.Ident, "X"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3)); - var modified = block.WithSlot(1, replacement); + // Replace slot 2 (second inner child) + var modified = block.WithSlot(2, replacement); - // child1 and child3 should be shared - Assert.Same(child1, modified.GetSlot(0)); - Assert.Same(replacement, modified.GetSlot(1)); - Assert.Same(child3, modified.GetSlot(2)); + // child1 and child3 should be shared (at slots 1 and 3) + Assert.Same(child1, modified.GetSlot(1)); + Assert.Same(replacement, modified.GetSlot(2)); + Assert.Same(child3, modified.GetSlot(3)); } [Fact] @@ -449,7 +476,10 @@ public void GreenBlock_WithSlot_InvalidIndex_Throws() var block = GreenBlock.Create('{', ImmutableArray.Empty); var child = new GreenLeaf(NodeKind.Ident, "x"); - Assert.Throws(() => block.WithSlot(0, child)); + // Empty block has 2 slots: opener (0) and closer (1) + // Slot 0 can only be replaced with a valid opener leaf + // Slot 2+ is out of range + Assert.Throws(() => block.WithSlot(2, child)); Assert.Throws(() => block.WithSlot(-1, child)); } @@ -461,12 +491,14 @@ public void GreenBlock_WithInsert_InsertsAtIndex() var inserted = new GreenLeaf(NodeKind.Ident, "X"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2)); - var modified = block.WithInsert(1, ImmutableArray.Create(inserted)); + // Insert at slot 2 (between a and b) + var modified = block.WithInsert(2, ImmutableArray.Create(inserted)); - Assert.Equal(3, modified.SlotCount); - Assert.Same(child1, modified.GetSlot(0)); - Assert.Same(inserted, modified.GetSlot(1)); - Assert.Same(child2, modified.GetSlot(2)); + // 5 slots: opener + 3 inner + closer + Assert.Equal(5, modified.SlotCount); + Assert.Same(child1, modified.GetSlot(1)); + Assert.Same(inserted, modified.GetSlot(2)); + Assert.Same(child2, modified.GetSlot(3)); } [Fact] @@ -476,10 +508,11 @@ public void GreenBlock_WithInsert_AtStart() var inserted = new GreenLeaf(NodeKind.Ident, "X"); var block = GreenBlock.Create('{', ImmutableArray.Create(child)); - var modified = block.WithInsert(0, ImmutableArray.Create(inserted)); + // Insert at slot 1 (after opener, before first inner child) + var modified = block.WithInsert(1, ImmutableArray.Create(inserted)); - Assert.Same(inserted, modified.GetSlot(0)); - Assert.Same(child, modified.GetSlot(1)); + Assert.Same(inserted, modified.GetSlot(1)); // inserted at slot 1 + Assert.Same(child, modified.GetSlot(2)); // original moved to slot 2 } [Fact] @@ -489,10 +522,11 @@ public void GreenBlock_WithInsert_AtEnd() var inserted = new GreenLeaf(NodeKind.Ident, "X"); var block = GreenBlock.Create('{', ImmutableArray.Create(child)); - var modified = block.WithInsert(1, ImmutableArray.Create(inserted)); + // Insert at slot 2 (after last inner child, before closer) + var modified = block.WithInsert(2, ImmutableArray.Create(inserted)); - Assert.Same(child, modified.GetSlot(0)); - Assert.Same(inserted, modified.GetSlot(1)); + Assert.Same(child, modified.GetSlot(1)); // original at slot 1 + Assert.Same(inserted, modified.GetSlot(2)); // inserted at slot 2 } [Fact] @@ -503,12 +537,14 @@ public void GreenBlock_WithInsert_MultipleNodes() var insert2 = new GreenLeaf(NodeKind.Ident, "Y"); var block = GreenBlock.Create('{', ImmutableArray.Create(original)); - var modified = block.WithInsert(0, ImmutableArray.Create(insert1, insert2)); + // Insert at slot 1 (start of inner content) + var modified = block.WithInsert(1, ImmutableArray.Create(insert1, insert2)); - Assert.Equal(3, modified.SlotCount); - Assert.Same(insert1, modified.GetSlot(0)); - Assert.Same(insert2, modified.GetSlot(1)); - Assert.Same(original, modified.GetSlot(2)); + // 5 slots: opener + 3 inner + closer + Assert.Equal(5, modified.SlotCount); + Assert.Same(insert1, modified.GetSlot(1)); + Assert.Same(insert2, modified.GetSlot(2)); + Assert.Same(original, modified.GetSlot(3)); } [Fact] @@ -517,10 +553,17 @@ public void GreenBlock_WithInsert_InvalidIndex_Throws() var block = GreenBlock.Create('{', ImmutableArray.Empty); var child = new GreenLeaf(NodeKind.Ident, "x"); + // Cannot insert at slot 0 (opener) or past slot 1 (closer) + Assert.Throws(() => + block.WithInsert(0, ImmutableArray.Create(child))); Assert.Throws(() => block.WithInsert(-1, ImmutableArray.Create(child))); Assert.Throws(() => block.WithInsert(10, ImmutableArray.Create(child))); + + // Valid: insert at slot 1 (the only valid position for empty block) + var modified = block.WithInsert(1, ImmutableArray.Create(child)); + Assert.Equal(3, modified.SlotCount); // opener + 1 inner + closer } [Fact] @@ -531,11 +574,13 @@ public void GreenBlock_WithRemove_RemovesChildren() var child3 = new GreenLeaf(NodeKind.Ident, "c"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3)); - var modified = block.WithRemove(1, 1); + // Remove slot 2 (second inner child 'b') + var modified = block.WithRemove(2, 1); - Assert.Equal(2, modified.SlotCount); - Assert.Same(child1, modified.GetSlot(0)); - Assert.Same(child3, modified.GetSlot(1)); + // 4 slots remaining: opener + 2 inner + closer + Assert.Equal(4, modified.SlotCount); + Assert.Same(child1, modified.GetSlot(1)); // 'a' at slot 1 + Assert.Same(child3, modified.GetSlot(2)); // 'c' moved to slot 2 } [Fact] @@ -547,11 +592,13 @@ public void GreenBlock_WithRemove_MultipleChildren() var child4 = new GreenLeaf(NodeKind.Ident, "d"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3, child4)); - var modified = block.WithRemove(1, 2); // Remove b and c + // Remove slots 2-3 (b and c, inner indices 1-2) + var modified = block.WithRemove(2, 2); - Assert.Equal(2, modified.SlotCount); - Assert.Same(child1, modified.GetSlot(0)); - Assert.Same(child4, modified.GetSlot(1)); + // 4 slots remaining: opener + 2 inner + closer + Assert.Equal(4, modified.SlotCount); + Assert.Same(child1, modified.GetSlot(1)); // 'a' at slot 1 + Assert.Same(child4, modified.GetSlot(2)); // 'd' moved to slot 2 } [Fact] @@ -560,8 +607,12 @@ public void GreenBlock_WithRemove_InvalidRange_Throws() var child = new GreenLeaf(NodeKind.Ident, "a"); var block = GreenBlock.Create('{', ImmutableArray.Create(child)); + // Cannot remove opener (slot 0) + Assert.Throws(() => block.WithRemove(0, 1)); + // Cannot remove with negative index Assert.Throws(() => block.WithRemove(-1, 1)); - Assert.Throws(() => block.WithRemove(0, 5)); + // Cannot remove past inner children (would affect closer) + Assert.Throws(() => block.WithRemove(1, 5)); } [Fact] @@ -573,12 +624,14 @@ public void GreenBlock_WithReplace_ReplacesRange() var replacement = new GreenLeaf(NodeKind.Ident, "X"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3)); - var modified = block.WithReplace(1, 1, ImmutableArray.Create(replacement)); + // Replace slot 2 (second inner child 'b') + var modified = block.WithReplace(2, 1, ImmutableArray.Create(replacement)); - Assert.Equal(3, modified.SlotCount); - Assert.Same(child1, modified.GetSlot(0)); - Assert.Same(replacement, modified.GetSlot(1)); - Assert.Same(child3, modified.GetSlot(2)); + // 5 slots: opener + 3 inner + closer + Assert.Equal(5, modified.SlotCount); + Assert.Same(child1, modified.GetSlot(1)); + Assert.Same(replacement, modified.GetSlot(2)); + Assert.Same(child3, modified.GetSlot(3)); } [Fact] @@ -591,14 +644,15 @@ public void GreenBlock_WithReplace_ExpandsRange() var repl3 = new GreenLeaf(NodeKind.Ident, "Z"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2)); - // Replace 1 child with 3 - var modified = block.WithReplace(1, 1, ImmutableArray.Create(repl1, repl2, repl3)); + // Replace slot 2 (second inner child 'b') with 3 nodes + var modified = block.WithReplace(2, 1, ImmutableArray.Create(repl1, repl2, repl3)); - Assert.Equal(4, modified.SlotCount); - Assert.Same(child1, modified.GetSlot(0)); - Assert.Same(repl1, modified.GetSlot(1)); - Assert.Same(repl2, modified.GetSlot(2)); - Assert.Same(repl3, modified.GetSlot(3)); + // 6 slots: opener + 4 inner + closer + Assert.Equal(6, modified.SlotCount); + Assert.Same(child1, modified.GetSlot(1)); + Assert.Same(repl1, modified.GetSlot(2)); + Assert.Same(repl2, modified.GetSlot(3)); + Assert.Same(repl3, modified.GetSlot(4)); } [Fact] @@ -610,12 +664,13 @@ public void GreenBlock_WithReplace_ContractsRange() var replacement = new GreenLeaf(NodeKind.Ident, "X"); var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3)); - // Replace 2 children with 1 - var modified = block.WithReplace(0, 2, ImmutableArray.Create(replacement)); + // Replace slots 1-2 (a and b) with 1 node + var modified = block.WithReplace(1, 2, ImmutableArray.Create(replacement)); - Assert.Equal(2, modified.SlotCount); - Assert.Same(replacement, modified.GetSlot(0)); - Assert.Same(child3, modified.GetSlot(1)); + // 4 slots: opener + 2 inner + closer + Assert.Equal(4, modified.SlotCount); + Assert.Same(replacement, modified.GetSlot(1)); + Assert.Same(child3, modified.GetSlot(2)); } [Fact] @@ -624,10 +679,15 @@ public void GreenBlock_WithReplace_InvalidRange_Throws() var block = GreenBlock.Create('{', ImmutableArray.Empty); var child = new GreenLeaf(NodeKind.Ident, "x"); + // Cannot replace with negative index Assert.Throws(() => block.WithReplace(-1, 1, ImmutableArray.Create(child))); + // Cannot replace range that exceeds total slots Assert.Throws(() => - block.WithReplace(0, 5, ImmutableArray.Create(child))); + block.WithReplace(1, 5, ImmutableArray.Create(child))); + // Cannot partially replace starting at opener without replacing entire block + Assert.Throws(() => + block.WithReplace(0, 1, ImmutableArray.Create(child))); } [Fact] @@ -711,7 +771,8 @@ public void GreenBlock_NestedBlocks() var outer = GreenBlock.Create('{', ImmutableArray.Create(inner)); Assert.Equal(4, outer.Width); // { + ( + ) + } - Assert.Same(inner, outer.GetSlot(0)); + // Inner block is at slot 1 (slot 0 is opener) + Assert.Same(inner, outer.GetSlot(1)); } [Fact] diff --git a/TinyTokenizer.Tests/QueryCombinatorTests.cs b/TinyTokenizer.Tests/QueryCombinatorTests.cs index dbac949..02f2992 100644 --- a/TinyTokenizer.Tests/QueryCombinatorTests.cs +++ b/TinyTokenizer.Tests/QueryCombinatorTests.cs @@ -1085,7 +1085,8 @@ public void Ancestor_MatchesDirectParent() var tree = Parse("{ foo }"); var root = tree.Root; var block = root.Children.First() as SyntaxBlock; - var innerIdent = block!.Children.First(); + // Use InnerChildren to skip opener/closer + var innerIdent = block!.InnerChildren.First(); var query = Query.Ancestor(Query.BraceBlock); Assert.True(query.Matches(innerIdent)); @@ -1097,8 +1098,9 @@ public void Ancestor_MatchesGrandparent() var tree = Parse("{ [ foo ] }"); var root = tree.Root; var braceBlock = root.Children.First() as SyntaxBlock; - var bracketBlock = braceBlock!.Children.First() as SyntaxBlock; - var innerIdent = bracketBlock!.Children.First(); + // Use InnerChildren to skip opener/closer + var bracketBlock = braceBlock!.InnerChildren.First() as SyntaxBlock; + var innerIdent = bracketBlock!.InnerChildren.First(); // innerIdent's grandparent is brace block var query = Query.Ancestor(Query.BraceBlock); @@ -1111,7 +1113,8 @@ public void Ancestor_FailsWhenNoAncestorMatches() var tree = Parse("{ foo }"); var root = tree.Root; var block = root.Children.First() as SyntaxBlock; - var innerIdent = block!.Children.First(); + // Use InnerChildren to skip opener/closer + var innerIdent = block!.InnerChildren.First(); var query = Query.Ancestor(Query.ParenBlock); // No paren ancestor Assert.False(query.Matches(innerIdent)); @@ -1165,7 +1168,8 @@ public void AsParent_Extension_CreatesParentQuery() var tree = Parse("{ foo }"); var root = tree.Root; var block = root.Children.First() as SyntaxBlock; - var innerIdent = block!.Children.First(); + // Use InnerChildren to skip opener/closer + var innerIdent = block!.InnerChildren.First(); var query = Query.BraceBlock.AsParent(); Assert.True(query.Matches(innerIdent)); @@ -1178,8 +1182,9 @@ public void AsAncestor_Extension_CreatesAncestorQuery() var tree = Parse("{ [ foo ] }"); var root = tree.Root; var braceBlock = root.Children.First() as SyntaxBlock; - var bracketBlock = braceBlock!.Children.First() as SyntaxBlock; - var innerIdent = bracketBlock!.Children.First(); + // Use InnerChildren to skip opener/closer + var bracketBlock = braceBlock!.InnerChildren.First() as SyntaxBlock; + var innerIdent = bracketBlock!.InnerChildren.First(); var query = Query.BraceBlock.AsAncestor(); Assert.True(query.Matches(innerIdent)); @@ -1638,8 +1643,9 @@ public void Ancestor_Combined_With_Parent() var tree = Parse("{ [ foo ] }"); var root = tree.Root; var braceBlock = root.Children.First() as SyntaxBlock; - var bracketBlock = braceBlock!.Children.First() as SyntaxBlock; - var innerIdent = bracketBlock!.Children.First(); + // Use InnerChildren to skip opener/closer + var bracketBlock = braceBlock!.InnerChildren.First() as SyntaxBlock; + var innerIdent = bracketBlock!.InnerChildren.First(); // Direct parent is bracket, ancestor is brace Assert.True(Query.Parent(Query.BracketBlock).Matches(innerIdent)); diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index 8d3c59e..abbeafe 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -656,6 +656,230 @@ public void Insert_ParenBlock_InnerEnd() #endregion + #region Inner() Query Tests + + [Fact] + public void Inner_Select_ReturnsInnerChildren() + { + var tree = SyntaxTree.Parse("{a b c}"); + + var inner = tree.Select(Q.BraceBlock.First().Inner()).ToList(); + + // "a", "b", "c" (whitespace is trivia attached to tokens) + Assert.Equal(3, inner.Count); + } + + [Fact] + public void Inner_Replace_ReplacesContentPreservingDelimiters() + { + var tree = SyntaxTree.Parse("{old content}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), "new") + .Commit(); + + Assert.Equal("{new}", tree.ToText()); + } + + [Fact] + public void Inner_Replace_EmptyBlock_InsertsContent() + { + var tree = SyntaxTree.Parse("{}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), "inserted") + .Commit(); + + Assert.Equal("{inserted}", tree.ToText()); + } + + [Fact] + public void Inner_Replace_WithWhitespace() + { + var tree = SyntaxTree.Parse("{ old }"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), " new ") + .Commit(); + + // Original trivia (" ") + new content (" new ") + original trailing trivia (" ") + // But trivia from "old" is transferred: leading space + content + trailing space + Assert.Equal("{ new }", tree.ToText()); + } + + [Fact] + public void Inner_Replace_MultipleBlocks() + { + var tree = SyntaxTree.Parse("{a} {b}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.Inner(), "X") + .Commit(); + + Assert.Equal("{X} {X}", tree.ToText()); + } + + [Fact] + public void Inner_Remove_ClearsBlockContent() + { + var tree = SyntaxTree.Parse("{content}"); + + tree.CreateEditor() + .Remove(Q.BraceBlock.First().Inner()) + .Commit(); + + Assert.Equal("{}", tree.ToText()); + } + + [Fact] + public void Inner_Remove_EmptyBlock_NoChange() + { + var tree = SyntaxTree.Parse("{}"); + + tree.CreateEditor() + .Remove(Q.BraceBlock.First().Inner()) + .Commit(); + + Assert.Equal("{}", tree.ToText()); + } + + [Fact] + public void Inner_InsertBefore_InsertsAtStart() + { + var tree = SyntaxTree.Parse("{existing}"); + + tree.CreateEditor() + .InsertBefore(Q.BraceBlock.First().Inner(), "prefix ") + .Commit(); + + Assert.Equal("{prefix existing}", tree.ToText()); + } + + [Fact] + public void Inner_InsertAfter_InsertsAtEnd() + { + var tree = SyntaxTree.Parse("{existing}"); + + tree.CreateEditor() + .InsertAfter(Q.BraceBlock.First().Inner(), " suffix") + .Commit(); + + Assert.Equal("{existing suffix}", tree.ToText()); + } + + [Fact] + public void Inner_InsertBefore_EmptyBlock_InsertsContent() + { + var tree = SyntaxTree.Parse("{}"); + + tree.CreateEditor() + .InsertBefore(Q.BraceBlock.First().Inner(), "new") + .Commit(); + + Assert.Equal("{new}", tree.ToText()); + } + + [Fact] + public void Inner_InsertAfter_EmptyBlock_InsertsContent() + { + var tree = SyntaxTree.Parse("{}"); + + tree.CreateEditor() + .InsertAfter(Q.BraceBlock.First().Inner(), "new") + .Commit(); + + Assert.Equal("{new}", tree.ToText()); + } + + [Fact] + public void Inner_BracketBlock_Works() + { + var tree = SyntaxTree.Parse("[old]"); + + tree.CreateEditor() + .Replace(Q.BracketBlock.First().Inner(), "new") + .Commit(); + + Assert.Equal("[new]", tree.ToText()); + } + + [Fact] + public void Inner_ParenBlock_Works() + { + var tree = SyntaxTree.Parse("(old)"); + + tree.CreateEditor() + .Replace(Q.ParenBlock.First().Inner(), "new") + .Commit(); + + Assert.Equal("(new)", tree.ToText()); + } + + [Fact] + public void Inner_NestedBlocks_WorksOnOuter() + { + var tree = SyntaxTree.Parse("{outer {inner}}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), "replaced") + .Commit(); + + Assert.Equal("{replaced}", tree.ToText()); + } + + [Fact] + public void Inner_NestedBlocks_WorksOnInner() + { + var tree = SyntaxTree.Parse("{outer {inner}}"); + + // Get the inner brace block (second one) + var innerBlock = tree.Select(Q.BraceBlock).Skip(1).First(); + + tree.CreateEditor() + .Replace(innerBlock, "{replaced}") + .Commit(); + + Assert.Equal("{outer {replaced}}", tree.ToText()); + } + + [Fact] + public void Inner_WithFirst_SelectsFirstBlockInner() + { + var tree = SyntaxTree.Parse("{a} {b}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.First().Inner(), "X") + .Commit(); + + Assert.Equal("{X} {b}", tree.ToText()); + } + + [Fact] + public void Inner_WithLast_SelectsLastBlockInner() + { + var tree = SyntaxTree.Parse("{a} {b}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.Last().Inner(), "X") + .Commit(); + + Assert.Equal("{a} {X}", tree.ToText()); + } + + [Fact] + public void Inner_WithPredicate_SelectsMatchingBlockInner() + { + var tree = SyntaxTree.Parse("{short} {longer content}"); + + tree.CreateEditor() + .Replace(Q.BraceBlock.Where(b => b.Width > 10).First().Inner(), "X") + .Commit(); + + Assert.Equal("{short} {X}", tree.ToText()); + } + + #endregion + #region Function-Like Block Insertion Scenarios [Fact] @@ -1814,54 +2038,140 @@ public void TriviaPreservation_NestedBlocksWithSchema_IndentationBeforeElseLost( #endregion - #region Query.Between Replace Bug + #region Query.Between with SyntaxEditor /// - /// Reproduction test for Query.Between replacement bug. - /// Using Query.Between to select content between function block delimiters - /// should allow replacing the inner contents of a function body. + /// Tests that Query.Between correctly replaces a range of nodes. /// [Fact] - public void Replace_BetweenBraces_ReplacesInnerContents() + public void Replace_QueryBetween_ReplacesEntireRange() { - // Arrange: a simple function with a body - var tree = SyntaxTree.Parse("function foo() { old body content }"); + // Arrange: a, b, c - we want to replace from 'a' to 'c' inclusive + var tree = SyntaxTree.Parse("x a b c y"); - // Act: use Query.Between to match content between { and } - // and replace it with new content - var query = Q.Between(Q.Symbol("{"), Q.Symbol("}")); + // Act: replace everything from 'a' to 'c' (inclusive) + tree.CreateEditor() + .Replace(Q.Between(Q.Ident("a"), Q.Ident("c")), "REPLACED") + .Commit(); + + // Assert: the range [a, b, c] should be replaced with REPLACED + Assert.Equal("x REPLACED y", tree.ToText()); + } + + /// + /// Tests that Remove with Query.Between removes the entire matched range. + /// + [Fact] + public void Remove_QueryBetween_RemovesEntireRange() + { + var tree = SyntaxTree.Parse("start a b c end"); tree.CreateEditor() - .Replace(query, "{ new body }") + .Remove(Q.Between(Q.Ident("a"), Q.Ident("c"))) .Commit(); - // Assert: the function body should be replaced - var result = tree.ToText(); - Assert.Equal("function foo() { new body }", result); + // Trivia handling: leading trivia of 'a' (space) is removed with 'a', + // but 'end' keeps its leading trivia (space) + Assert.Equal("start end", tree.ToText()); } /// - /// Tests that Query.Between can be used to replace inner block content - /// while preserving surrounding structure. + /// Tests that InsertBefore with Query.Between inserts before the start of the range. /// [Fact] - public void Replace_BetweenBraces_InFunctionBlock_PreservesStructure() + public void InsertBefore_QueryBetween_InsertsBeforeRange() { - // Arrange: code with a function containing a block - var tree = SyntaxTree.Parse("int main() { return 0; }"); + var tree = SyntaxTree.Parse("x a b c y"); + + tree.CreateEditor() + .InsertBefore(Q.Between(Q.Ident("a"), Q.Ident("c")), "BEFORE ") + .Commit(); - // Act: select the brace block content using Between and replace - var query = Q.Between(Q.Symbol("{"), Q.Symbol("}")); + Assert.Equal("x BEFORE a b c y", tree.ToText()); + } + + /// + /// Tests that InsertAfter with Query.Between inserts after the end of the range. + /// Note: With trailing trivia model, the preceding token keeps its trailing whitespace, + /// so inserted content should use trailing space (not leading) for proper separation. + /// + [Fact] + public void InsertAfter_QueryBetween_InsertsAfterRange() + { + // Test with whitespace-separated tokens where spaces are trailing trivia + var tree = SyntaxTree.Parse("x a b c y"); + // Insert "AFTER " after the range a..c + // - c has trailing trivia (space) -> "c " + // - Insert "AFTER " (with trailing space for separation from y) + // - y has no trivia -> "y" tree.CreateEditor() - .Replace(query, "{ return 42; }") + .InsertAfter(Q.Between(Q.Ident("a"), Q.Ident("c")), "AFTER ") .Commit(); - // Assert: the block content should be replaced while keeping function signature - var result = tree.ToText(); - Assert.Contains("int main()", result); - Assert.Contains("return 42", result); - Assert.DoesNotContain("return 0", result); + Assert.Equal("x a b c AFTER y", tree.ToText()); + } + + /// + /// Tests that InsertAfter preserves the target token's trailing trivia and + /// uses trailing space on inserted content for proper separation. + /// + /// + /// In TinyAst's trivia model, same-line whitespace is trailing trivia on the preceding token. + /// When inserting after a token, the token keeps its trailing trivia, so inserted content + /// should use trailing whitespace (not leading) for proper spacing with following tokens. + /// + [Fact] + public void InsertAfter_RedNode_InMiddle_PreservesFollowingTrivia() + { + var tree = SyntaxTree.Parse("x a b c y"); + + // Verify trivia model: each token (except last) has trailing whitespace + var beforeTrivia = string.Join(", ", tree.Leaves.Select(l => + $"{l.Text}(T:{l.TrailingTriviaWidth})")); + Assert.Equal("x(T:1), a(T:1), b(T:1), c(T:1), y(T:0)", beforeTrivia); + + var cNode = tree.Root.Children.First(n => n is SyntaxToken leaf && leaf.Text == "c"); + + // Insert "AFTER " with trailing space for proper separation from y + tree.CreateEditor() + .InsertAfter(cNode, "AFTER ") + .Commit(); + + Assert.Equal("x a b c AFTER y", tree.ToText()); + } + + /// + /// Tests that Edit with Query.Between transforms the concatenated content of the range. + /// + [Fact] + 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()) + .Commit(); + + // The content between abc and ghi (inclusive) should be uppercased + Assert.Equal("x ABC DEF GHI y", tree.ToText()); + } + + /// + /// Tests Query.Sequence with SyntaxEditor Replace. + /// + [Fact] + public void Replace_QuerySequence_ReplacesAllMatchedNodes() + { + var tree = SyntaxTree.Parse("a = 1 ; b = 2"); + + // Replace sequence "a =" with "x =" + // Note: '=' is parsed as an operator by default, not a symbol + tree.CreateEditor() + .Replace(Q.Sequence(Q.Ident("a"), Q.Operator("=")), "x =") + .Commit(); + + Assert.Equal("x = 1 ; b = 2", tree.ToText()); } #endregion diff --git a/TinyTokenizer.Tests/SyntaxTreeTests.cs b/TinyTokenizer.Tests/SyntaxTreeTests.cs index 906291e..c8f48df 100644 --- a/TinyTokenizer.Tests/SyntaxTreeTests.cs +++ b/TinyTokenizer.Tests/SyntaxTreeTests.cs @@ -128,12 +128,13 @@ public void GreenBlock_WithSlot_SharesSiblings() var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2, child3)); var newChild = new GreenLeaf(NodeKind.Ident, "X"); - var newBlock = block.WithSlot(1, newChild); + // Replace slot 2 (second inner child) + var newBlock = block.WithSlot(2, newChild); - // Siblings should be shared - Assert.Same(child1, newBlock.GetSlot(0)); - Assert.Same(child3, newBlock.GetSlot(2)); - Assert.NotSame(child2, newBlock.GetSlot(1)); + // Siblings should be shared (slot 0 is opener, slots 1,3 are first/third children) + Assert.Same(child1, newBlock.GetSlot(1)); + Assert.Same(child3, newBlock.GetSlot(3)); + Assert.NotSame(child2, newBlock.GetSlot(2)); } [Fact] @@ -144,12 +145,14 @@ public void GreenBlock_WithInsert_PreservesExisting() var block = GreenBlock.Create('{', ImmutableArray.Create(child1, child2)); var newChild = new GreenLeaf(NodeKind.Ident, "X"); - var newBlock = block.WithInsert(1, ImmutableArray.Create(newChild)); + // Insert at slot 2 (between a and b) + var newBlock = block.WithInsert(2, ImmutableArray.Create(newChild)); - Assert.Equal(3, newBlock.SlotCount); - Assert.Same(child1, newBlock.GetSlot(0)); - Assert.Same(newChild, newBlock.GetSlot(1)); - Assert.Same(child2, newBlock.GetSlot(2)); + // 5 slots: opener + 3 inner + closer + Assert.Equal(5, newBlock.SlotCount); + Assert.Same(child1, newBlock.GetSlot(1)); + Assert.Same(newChild, newBlock.GetSlot(2)); + Assert.Same(child2, newBlock.GetSlot(3)); } #endregion @@ -1466,7 +1469,8 @@ public void GreenTreeBuilder_InsertAt_InsertsNodes() var newNodes = lexer.ParseToGreenNodes("b"); var builder = new GreenTreeBuilder(tree.GreenRoot); - var newRoot = builder.InsertAt(new[] { 0 }, 1, newNodes); + // Insert at slot 2 (after first inner child 'a', before closer) + var newRoot = builder.InsertAt(new[] { 0 }, 2, newNodes); Assert.NotNull(newRoot); } @@ -1477,7 +1481,8 @@ public void GreenTreeBuilder_RemoveAt_RemovesNodes() var tree = SyntaxTree.Parse("{a b}"); var builder = new GreenTreeBuilder(tree.GreenRoot); - var newRoot = builder.RemoveAt(new[] { 0 }, 0, 1); + // Remove slot 1 (first inner child 'a') + var newRoot = builder.RemoveAt(new[] { 0 }, 1, 1); Assert.NotNull(newRoot); } @@ -1490,7 +1495,8 @@ public void GreenTreeBuilder_ReplaceAt_ReplacesNodes() var newNodes = lexer.ParseToGreenNodes("new"); var builder = new GreenTreeBuilder(tree.GreenRoot); - var newRoot = builder.ReplaceAt(new[] { 0 }, 0, 1, newNodes); + // Replace slot 1 (first inner child 'old') + var newRoot = builder.ReplaceAt(new[] { 0 }, 1, 1, newNodes); Assert.NotNull(newRoot); } @@ -1505,7 +1511,8 @@ public void GreenTreeBuilder_ReplaceChild_ReplacesChild() if (newNode != null) { var builder = new GreenTreeBuilder(tree.GreenRoot); - var newRoot = builder.ReplaceChild(new[] { 0 }, 0, newNode); + // Replace slot 1 (first inner child 'a') + var newRoot = builder.ReplaceChild(new[] { 0 }, 1, newNode); Assert.NotNull(newRoot); } @@ -1518,9 +1525,10 @@ public void GreenTreeBuilder_DeepPath_WorksCorrectly() var lexer = new GreenLexer(); var newNodes = lexer.ParseToGreenNodes("x"); - // Path: root -> first child (outer block) -> first child (inner block) + // Path: root -> first child (outer block at 0) -> inner block (at slot 1, since slot 0 is opener) + // Insert at slot 1 (start of inner block content) var builder = new GreenTreeBuilder(tree.GreenRoot); - var newRoot = builder.InsertAt(new[] { 0, 0 }, 0, newNodes); + var newRoot = builder.InsertAt(new[] { 0, 1 }, 1, newNodes); Assert.NotNull(newRoot); } diff --git a/TinyTokenizer.Tests/TreeWalkerTests.cs b/TinyTokenizer.Tests/TreeWalkerTests.cs index 93c5389..e15b245 100644 --- a/TinyTokenizer.Tests/TreeWalkerTests.cs +++ b/TinyTokenizer.Tests/TreeWalkerTests.cs @@ -651,9 +651,16 @@ public void NextNode_WithSkipOnFirstChild_RecursesCorrectly() var first = walker.NextNode(); - // Should skip the block and return the ident inside + // Should skip the block and recurse into it + // With Roslyn-style slots, first child of block is opener '{' Assert.NotNull(first); - Assert.Equal(NodeKind.Ident, first.Kind); + Assert.Equal(NodeKind.Symbol, first.Kind); + Assert.Equal("{", first.ToText()); + + // Continue to get the actual content + var second = walker.NextNode(); + Assert.NotNull(second); + Assert.Equal(NodeKind.Ident, second.Kind); } [Fact] @@ -757,9 +764,11 @@ public void TraverseChildren_WithSkip_RecursesIntoSkippedNode() var first = walker.FirstChild(); - // Should skip outer block, skip inner block, and return "deep" + // Should skip outer block, skip inner block + // With Roslyn-style slots, first child encountered is the outer opener '{' Assert.NotNull(first); - Assert.Equal(NodeKind.Ident, first.Kind); + Assert.Equal(NodeKind.Symbol, first.Kind); + Assert.Equal("{", first.ToText()); } [Fact] diff --git a/TinyTokenizer.code-workspace b/TinyTokenizer.code-workspace index c4e866c..6dd2aa4 100644 --- a/TinyTokenizer.code-workspace +++ b/TinyTokenizer.code-workspace @@ -7,5 +7,7 @@ "path": "../TinyTokenizer.wiki" } ], - "settings": {} + "settings": { + "powershell.cwd": "TinyTokenizer" + } } \ No newline at end of file diff --git a/TinyTokenizer/Ast/GreenBlock.cs b/TinyTokenizer/Ast/GreenBlock.cs index 6604168..968dc1e 100644 --- a/TinyTokenizer/Ast/GreenBlock.cs +++ b/TinyTokenizer/Ast/GreenBlock.cs @@ -106,22 +106,74 @@ public static GreenBlock Create( return new GreenBlock(openerNode, closerNode, children); } - /// Gets the children of this block. - public override ImmutableArray Children => _children; + /// Gets the inner children of this block (excluding delimiters). + public ImmutableArray InnerChildren => _children; + + /// + /// Gets all children including delimiters (opener at slot 0, closer at slot N+1). + /// This is the Roslyn-style slot model where delimiters are traversable children. + /// + public override ImmutableArray Children + { + get + { + var builder = ImmutableArray.CreateBuilder(_children.Length + 2); + builder.Add(OpenerNode); + builder.AddRange(_children); + builder.Add(CloserNode); + return builder.MoveToImmutable(); + } + } + + /// + /// Number of slots: opener + inner children + closer. + /// + public override int SlotCount => _children.Length + 2; /// + /// + /// Slot 0 = opener, slots 1..N = inner children, slot N+1 = closer. + /// public override GreenNode? GetSlot(int index) - => index >= 0 && index < _children.Length ? _children[index] : null; + { + if (index < 0 || index > _children.Length + 1) + return null; + if (index == 0) + return OpenerNode; + if (index == _children.Length + 1) + return CloserNode; + return _children[index - 1]; // Adjust for opener at slot 0 + } /// + /// + /// Returns offset from block start: + /// - Slot 0 (opener): 0 + /// - Slot 1..N (inner children): opener width + sum of preceding inner children + /// - Slot N+1 (closer): opener width + all inner children widths + /// public override int GetSlotOffset(int index) { + if (index == 0) + return 0; // Opener starts at block start + + if (index == _children.Length + 1) + { + // Closer is after opener and all children + int closerOffset = OpenerNode.Width; + foreach (var child in _children) + closerOffset += child.Width; + return closerOffset; + } + + // Inner child: use precomputed offsets if available + int innerIndex = index - 1; // Convert to inner children index if (_childOffsets != null) - return _childOffsets[index]; // O(1) + return _childOffsets[innerIndex]; // O(1) // O(index) for small blocks int offset = OpenerNode.Width; // After opener (including its trivia) - for (int i = 0; i < index; i++) + for (int i = 0; i < innerIndex; i++) offset += _children[i].Width; return offset; } @@ -147,50 +199,157 @@ public override void WriteTo(IBufferWriter writer) #region Structural Sharing Mutations /// + /// + /// Creates a new block with one slot replaced. + /// Slot 0 = opener, slots 1..N = inner children, slot N+1 = closer. + /// public override GreenBlock WithSlot(int index, GreenNode newChild) { - if (index < 0 || index >= _children.Length) + if (index < 0 || index > _children.Length + 1) throw new ArgumentOutOfRangeException(nameof(index)); - var newChildren = _children.SetItem(index, newChild); + // Slot 0 = opener + if (index == 0) + { + if (newChild is not GreenLeaf newOpener) + throw new ArgumentException("Opener slot must be a GreenLeaf", nameof(newChild)); + return new GreenBlock(newOpener, CloserNode, _children); + } + + // Slot N+1 = closer + if (index == _children.Length + 1) + { + if (newChild is not GreenLeaf newCloser) + throw new ArgumentException("Closer slot must be a GreenLeaf", nameof(newChild)); + return new GreenBlock(OpenerNode, newCloser, _children); + } + + // Inner child slot (1..N) + var innerIndex = index - 1; + var newChildren = _children.SetItem(innerIndex, newChild); return new GreenBlock(OpenerNode, CloserNode, newChildren); } - /// + /// + /// Creates a new block with all children replaced (including delimiters). + /// First element must be opener, last must be closer. + /// public override GreenBlock WithChildren(ImmutableArray newChildren) - => new(OpenerNode, CloserNode, newChildren); + { + if (newChildren.Length < 2) + throw new ArgumentException("Children must include at least opener and closer", nameof(newChildren)); + + if (newChildren[0] is not GreenLeaf opener) + throw new ArgumentException("First child must be opener (GreenLeaf)", nameof(newChildren)); + if (newChildren[^1] is not GreenLeaf closer) + throw new ArgumentException("Last child must be closer (GreenLeaf)", nameof(newChildren)); + + // Extract inner children (everything between opener and closer) + var innerChildren = newChildren.RemoveAt(newChildren.Length - 1).RemoveAt(0); + return new GreenBlock(opener, closer, innerChildren); + } - /// + /// + /// Creates a new block with inner children replaced (preserves delimiters). + /// + public GreenBlock WithInnerChildren(ImmutableArray newInnerChildren) + => new(OpenerNode, CloserNode, newInnerChildren); + + /// + /// Inserts nodes at the specified slot index. + /// Slot 0 = opener position, slots 1..N = inner content, slot N+1 = after last inner/before closer. + /// Cannot insert before slot 0 or after slot N+2 (would be outside block bounds). + /// + /// + /// Inserting at slot 1 places content after the opener. + /// Inserting at slot N+1 (where N+1 = _children.Length + 1) places content before the closer. + /// public override GreenBlock WithInsert(int index, ImmutableArray nodes) { - if (index < 0 || index > _children.Length) - throw new ArgumentOutOfRangeException(nameof(index)); + // Valid insertion range: 1 through _children.Length + 1 (after opener through before closer) + // Slot 0 is the opener, slot _children.Length + 1 is the closer + if (index < 1 || index > _children.Length + 1) + throw new ArgumentOutOfRangeException(nameof(index), + $"Insert index must be between 1 and {_children.Length + 1} (inner content range)"); + var innerIndex = index - 1; // Convert to inner children index var builder = _children.ToBuilder(); - builder.InsertRange(index, nodes); + builder.InsertRange(innerIndex, nodes); return new GreenBlock(OpenerNode, CloserNode, builder.ToImmutable()); } - /// + /// + /// Removes nodes starting at the specified slot index. + /// Can only remove inner children (slots 1..N). Cannot remove opener (slot 0) or closer (slot N+1). + /// public override GreenBlock WithRemove(int index, int count) { - if (index < 0 || count < 0 || index + count > _children.Length) - throw new ArgumentOutOfRangeException(nameof(index)); + // Valid removal range: slots 1..N (inner children only) + // Slot 0 is opener, slot _children.Length + 1 is closer - neither can be removed + if (index < 1 || count < 0) + throw new ArgumentOutOfRangeException(nameof(index), + "Cannot remove opener (slot 0). Use WithSlot to replace delimiters."); + + var innerIndex = index - 1; + if (innerIndex + count > _children.Length) + throw new ArgumentOutOfRangeException(nameof(count), + "Cannot remove closer. Removal range extends past inner children."); var builder = _children.ToBuilder(); - builder.RemoveRange(index, count); + builder.RemoveRange(innerIndex, count); return new GreenBlock(OpenerNode, CloserNode, builder.ToImmutable()); } - /// + /// + /// Replaces nodes starting at the specified slot index. + /// Can replace inner children (slots 1..N) or entire block content including delimiters. + /// + /// + /// If replacing only inner content (index >= 1, not touching closer), preserves delimiters. + /// If replacing from slot 0 through the closer, the replacement becomes the new children + /// (first replacement node becomes opener if it's a leaf, etc.). + /// public override GreenBlock WithReplace(int index, int count, ImmutableArray replacement) { - if (index < 0 || count < 0 || index + count > _children.Length) - throw new ArgumentOutOfRangeException(nameof(index)); + var totalSlots = _children.Length + 2; // opener + children + closer + + if (index < 0 || count < 0 || index + count > totalSlots) + throw new ArgumentOutOfRangeException(nameof(index), + $"Replace range [{index}..{index + count}) is out of bounds for slot count {totalSlots}"); + + // Special case: replacing entire block content (slot 0 through last slot) + // This replaces opener, all inner children, and closer + if (index == 0 && count == totalSlots) + { + // The replacement becomes the new full children array + return WithChildren(replacement); + } + + // Special case: replacing from opener through some inner content + // This is invalid - can't partially replace including opener without replacing all + if (index == 0) + { + throw new ArgumentException( + "Cannot replace range starting at opener (slot 0) without replacing entire block. " + + "Use WithSlot to replace just the opener, or WithChildren to replace all.", + nameof(index)); + } + + // Special case: replacing through the closer + // This is invalid - can't partially replace including closer without replacing all + if (index + count == totalSlots && index != 0) + { + throw new ArgumentException( + "Cannot replace range including closer without replacing entire block. " + + "Use WithSlot to replace just the closer, or WithChildren to replace all.", + nameof(count)); + } + // Normal case: replacing inner content only (slots 1 through N) + var innerIndex = index - 1; var builder = _children.ToBuilder(); - builder.RemoveRange(index, count); - builder.InsertRange(index, replacement); + builder.RemoveRange(innerIndex, count); + builder.InsertRange(innerIndex, replacement); return new GreenBlock(OpenerNode, CloserNode, builder.ToImmutable()); } diff --git a/TinyTokenizer/Ast/GreenNode.cs b/TinyTokenizer/Ast/GreenNode.cs index 9516151..b563cae 100644 --- a/TinyTokenizer/Ast/GreenNode.cs +++ b/TinyTokenizer/Ast/GreenNode.cs @@ -171,6 +171,94 @@ public virtual int GetSlotOffset(int index) /// public bool IsLeaf => !IsContainer; + #region Trivia Access + + /// + /// Gets the leading trivia of this node by finding the first leaf descendant. + /// For leaves, returns the leaf's leading trivia directly. + /// + public ImmutableArray GetLeadingTrivia() + { + var firstLeaf = GetFirstLeaf(); + return firstLeaf?.LeadingTrivia ?? ImmutableArray.Empty; + } + + /// + /// Gets the trailing trivia of this node by finding the last leaf descendant. + /// For leaves, returns the leaf's trailing trivia directly. + /// + public ImmutableArray GetTrailingTrivia() + { + var lastLeaf = GetLastLeaf(); + return lastLeaf?.TrailingTrivia ?? ImmutableArray.Empty; + } + + /// + /// Gets the width of leading trivia (precomputed for leaves). + /// + public int GetLeadingTriviaWidth() + { + var firstLeaf = GetFirstLeaf(); + return firstLeaf?.LeadingTriviaWidth ?? 0; + } + + /// + /// Gets the width of trailing trivia (precomputed for leaves). + /// + public int GetTrailingTriviaWidth() + { + var lastLeaf = GetLastLeaf(); + return lastLeaf?.TrailingTriviaWidth ?? 0; + } + + /// + /// Gets the first leaf descendant of this node. + /// For leaves, returns self. For containers, descends to find leftmost leaf. + /// + internal GreenLeaf? GetFirstLeaf() + { + if (this is GreenLeaf leaf) + return leaf; + + for (int i = 0; i < SlotCount; i++) + { + var child = GetSlot(i); + if (child != null) + { + var firstLeaf = child.GetFirstLeaf(); + if (firstLeaf != null) + return firstLeaf; + } + } + + return null; + } + + /// + /// Gets the last leaf descendant of this node. + /// For leaves, returns self. For containers, descends to find rightmost leaf. + /// + internal GreenLeaf? GetLastLeaf() + { + if (this is GreenLeaf leaf) + return leaf; + + for (int i = SlotCount - 1; i >= 0; i--) + { + var child = GetSlot(i); + if (child != null) + { + var lastLeaf = child.GetLastLeaf(); + if (lastLeaf != null) + return lastLeaf; + } + } + + return null; + } + + #endregion + #region IFormattable /// diff --git a/TinyTokenizer/Ast/NodeQuery.cs b/TinyTokenizer/Ast/NodeQuery.cs index 7315f0a..acfe1d6 100644 --- a/TinyTokenizer/Ast/NodeQuery.cs +++ b/TinyTokenizer/Ast/NodeQuery.cs @@ -27,11 +27,13 @@ public interface INodeQuery { /// /// Selects all nodes matching this query from the tree. + /// For range queries (e.g., BetweenQuery), returns only the start node of each match. /// IEnumerable Select(SyntaxTree tree); /// /// Selects all nodes matching this query from a subtree. + /// For range queries, returns only the start node of each match. /// IEnumerable Select(SyntaxNode root); @@ -56,7 +58,7 @@ public interface INodeQuery /// preserving type-specific methods through the fluent chain. /// /// The derived query type. -public abstract record NodeQuery : INodeQuery, IGreenNodeQuery where TSelf : NodeQuery +public abstract record NodeQuery : INodeQuery, IGreenNodeQuery, IRegionQuery where TSelf : NodeQuery { /// /// Selects all nodes matching this query from the tree. @@ -68,6 +70,76 @@ public abstract record NodeQuery : INodeQuery, IGreenNodeQuery where TSel /// public abstract IEnumerable Select(SyntaxNode root); + #region IRegionQuery Implementation + + /// + /// Resolves this query to regions in the tree. + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => SelectRegionsCore(tree.Root); + + /// + /// Resolves this query to regions in a subtree. + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + => SelectRegionsCore(root); + + /// + /// Default region resolution: traverses tree with PathTrackingWalker, calls TryMatch once per node, + /// then applies selection filtering (First/Last/Nth via ApplyRegionFilter). + /// Uses incremental path tracking for O(1) per node instead of O(depth). + /// + internal virtual IEnumerable SelectRegionsCore(SyntaxNode root) + { + return ApplyRegionFilter(SelectAllRegions(root)); + } + + /// + /// Traverses tree and yields a region for each matching node. + /// Uses PathTrackingWalker for O(1) path computation per node. + /// + private IEnumerable SelectAllRegions(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (TryMatch(node, out var consumedCount)) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } + + /// + /// Gets the selection mode for this query. Override in derived classes. + /// + internal virtual SelectionMode Mode => SelectionMode.All; + + /// + /// Gets the selection mode argument (e.g., N for Nth, count for Skip/Take). + /// + internal virtual int ModeArg => 0; + + /// + /// Applies selection mode filtering (First/Last/Nth/Skip/Take) to regions. + /// Uses and properties. + /// + internal IEnumerable ApplyRegionFilter(IEnumerable regions) => + SelectionModeHelper.Apply(regions, Mode, ModeArg); + + #endregion + /// /// Tests whether a single node matches this query's criteria. /// diff --git a/TinyTokenizer/Ast/NodeQueryTypes.cs b/TinyTokenizer/Ast/NodeQueryTypes.cs index dcdd1d2..74bdbfe 100644 --- a/TinyTokenizer/Ast/NodeQueryTypes.cs +++ b/TinyTokenizer/Ast/NodeQueryTypes.cs @@ -2,6 +2,23 @@ namespace TinyTokenizer.Ast; +/// +/// Helper for applying selection mode filtering to regions. +/// +internal static class SelectionModeHelper +{ + public static IEnumerable Apply(IEnumerable regions, SelectionMode mode, int modeArg) => + mode switch + { + SelectionMode.First => regions.Take(1), + SelectionMode.Last => regions.TakeLast(1), + SelectionMode.Nth => regions.Skip(modeArg).Take(1), + SelectionMode.Skip => regions.Skip(modeArg), + SelectionMode.Take => regions.Take(modeArg), + _ => regions + }; +} + /// /// Compares red nodes for equality using SyntaxNode's equality semantics. /// Two red nodes are equal if they wrap the same green node and have the same position. @@ -96,6 +113,44 @@ protected override KindNodeQuery CreateFiltered(Func predicate protected override KindNodeQuery CreateSkip(int count) => new(Kind, _predicate, SelectionMode.Skip, count, _textConstraint); protected override KindNodeQuery CreateTake(int count) => new(Kind, _predicate, SelectionMode.Take, count, _textConstraint); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + + /// + /// Optimized region resolution: single-pass traversal that checks Kind directly + /// and applies selection mode inline for efficient First()/Take() short-circuit. + /// Uses PathTrackingWalker for O(1) path computation per node. + /// + internal override IEnumerable SelectRegionsCore(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + var regions = SelectRegionsFromWalker(walker); + return ApplyRegionFilter(regions); + } + + private IEnumerable SelectRegionsFromWalker(PathTrackingWalker walker) + { + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + // Inline match check - avoids virtual TryMatch call + if (node.Kind == Kind && (_predicate == null || _predicate(node))) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + 1, // KindNodeQuery always consumes 1 + firstNode: node, + position: node.Position + ); + } + } + } + } + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } @@ -172,9 +227,49 @@ protected override BlockNodeQuery CreateFiltered(Func predicat protected override BlockNodeQuery CreateSkip(int count) => new(_opener, _predicate, SelectionMode.Skip, count); protected override BlockNodeQuery CreateTake(int count) => new(_opener, _predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + + /// + /// Optimized region resolution: single-pass traversal that checks block type directly + /// and applies selection mode inline for efficient First()/Take() short-circuit. + /// Uses PathTrackingWalker for O(1) path computation per node. + /// + internal override IEnumerable SelectRegionsCore(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + var regions = SelectRegionsFromWalker(walker); + return ApplyRegionFilter(regions); + } + + private IEnumerable SelectRegionsFromWalker(PathTrackingWalker walker) + { + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + // Inline match check - avoids virtual TryMatch call + if (node is SyntaxBlock block && + (_opener == null || block.Opener == _opener.Value) && + (_predicate == null || _predicate(node))) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + 1, // BlockNodeQuery always consumes 1 + firstNode: node, + position: node.Position + ); + } + } + } + } + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); - + /// /// Returns a query that selects the opening delimiter (start) of matched blocks. /// Use with InsertAfter to insert at the beginning of block content. @@ -198,6 +293,22 @@ protected override BlockNodeQuery CreateFiltered(Func predicat /// /// public BoundaryQuery End() => new BoundaryQuery(this, BoundarySide.End); + + /// + /// Returns a query that selects all inner children of matched blocks as a range. + /// Use with Replace/Edit to modify block content while preserving delimiters. + /// Empty blocks yield an empty region, enabling insertion via Replace. + /// + /// + /// + /// // Replace content between braces + /// editor.Replace(Query.BraceBlock.Inner(), "new content") + /// + /// // Works with empty blocks too + /// editor.Replace(Query.BraceBlock.Inner(), "inserted into empty") + /// + /// + public InnerContentQuery Inner() => new InnerContentQuery(this); } #endregion @@ -225,7 +336,7 @@ public enum BoundarySide /// uses this metadata to compute insertion positions, /// even for empty containers where returns no results. /// -public sealed record BoundaryQuery : INodeQuery +public sealed record BoundaryQuery : INodeQuery, IRegionQuery { /// Gets the underlying container query. public INodeQuery ContainerQuery { get; } @@ -282,33 +393,156 @@ public bool TryMatch(SyntaxNode startNode, out int consumedCount) return false; } + #region IRegionQuery Implementation + /// - /// Gets the boundary node for a container. - /// For blocks: returns OpenerNode or CloserNode. - /// For other containers: returns first or last child. + /// Resolves this query to regions in the tree. /// - private SyntaxNode? GetBoundaryNode(SyntaxNode container) + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => SelectRegionsCore(tree.Root); + + /// + /// Resolves this query to regions in a subtree. + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + => SelectRegionsCore(root); + + private IEnumerable SelectRegionsCore(SyntaxNode root) { - if (container is SyntaxBlock block) + foreach (var container in ContainerQuery.Select(root)) { - return Side == BoundarySide.Start ? block.OpenerNode : block.CloserNode; + if (container.SlotCount == 0) + continue; + + int slot = Side == BoundarySide.Start ? 0 : container.SlotCount - 1; + var boundaryNode = container.GetChild(slot); + + if (boundaryNode != null) + { + yield return new QueryRegion( + parent: container, + startSlot: slot, + endSlot: slot + 1, + firstNode: boundaryNode, + position: boundaryNode.Position + ); + } } - - // For non-block containers (lists, syntax nodes), return first/last child - var children = container.Children.ToList(); - if (children.Count == 0) + } + + #endregion + + /// + /// Gets the boundary node for a container. + /// Uses slot-based access which works uniformly for all container types: + /// - Blocks: slot 0 = opener, slot N = closer (Roslyn-style) + /// - Lists/other: slot 0 = first child, slot N = last child + /// + private SyntaxNode? GetBoundaryNode(SyntaxNode container) + { + if (container.SlotCount == 0) return null; // Empty container - no boundary node to return - return Side == BoundarySide.Start ? children[0] : children[^1]; + return Side == BoundarySide.Start + ? container.GetChild(0) + : container.GetChild(container.SlotCount - 1); + } +} + +#endregion + +#region Inner Content Query + +/// +/// A query that selects all inner children of a block 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. +/// +/// +/// Use Query.BraceBlock.Inner() to create this query. +/// Works with to replace block content while preserving delimiters. +/// +public sealed record InnerContentQuery : INodeQuery, IRegionQuery +{ + /// The container query that selects blocks. + public BlockNodeQuery ContainerQuery { get; } + + internal InnerContentQuery(BlockNodeQuery containerQuery) + { + ContainerQuery = containerQuery; + } + + /// + 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 && ContainerQuery.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 && + ContainerQuery.Matches(block) && + startNode.SiblingIndex == 1) + { + consumedCount = block.ChildCount; + return true; + } + consumedCount = 0; + return false; } /// - /// Resolves the containers matched by this boundary query. - /// Used by to compute insertion positions. + /// Resolves this query to regions in the tree. /// - internal IEnumerable ResolveContainers(SyntaxTree tree) + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => SelectRegionsCore(tree.Root); + + /// + /// Resolves this query to regions in a subtree. + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + => SelectRegionsCore(root); + + private IEnumerable SelectRegionsCore(SyntaxNode root) { - return ContainerQuery.Select(tree); + foreach (var container in ContainerQuery.Select(root)) + { + if (container is SyntaxBlock block) + { + var innerCount = block.ChildCount; + var firstInner = block.InnerChildren.FirstOrDefault(); + + yield return new QueryRegion( + parent: block, + startSlot: 1, + endSlot: 1 + innerCount, + firstNode: firstInner, + position: block.InnerStartPosition + ); + } + } } } @@ -369,6 +603,9 @@ protected override AnyNodeQuery CreateFiltered(Func predicate) protected override AnyNodeQuery CreateSkip(int count) => new(_predicate, SelectionMode.Skip, count); protected override AnyNodeQuery CreateTake(int count) => new(_predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } @@ -430,6 +667,9 @@ protected override LeafNodeQuery CreateFiltered(Func predicate protected override LeafNodeQuery CreateSkip(int count) => new(_predicate, SelectionMode.Skip, count); protected override LeafNodeQuery CreateTake(int count) => new(_predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } @@ -570,6 +810,9 @@ protected override NewlineNodeQuery CreateFiltered(Func predic protected override NewlineNodeQuery CreateSkip(int count) => new(_predicate, SelectionMode.Skip, count, _negated); protected override NewlineNodeQuery CreateTake(int count) => new(_predicate, SelectionMode.Take, count, _negated); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } @@ -1246,6 +1489,9 @@ protected override ExactNodeQuery CreateFiltered(Func predicat protected override ExactNodeQuery CreateSkip(int count) => new(_target, _predicate, SelectionMode.Skip, count); protected override ExactNodeQuery CreateTake(int count) => new(_target, _predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); @@ -1320,6 +1566,9 @@ protected override AnyKeywordQuery CreateFiltered(Func predica protected override AnyKeywordQuery CreateSkip(int count) => new(_predicate, SelectionMode.Skip, count); protected override AnyKeywordQuery CreateTake(int count) => new(_predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } @@ -1411,6 +1660,9 @@ protected override KeywordCategoryQuery CreateFiltered(Func pr protected override KeywordCategoryQuery CreateSkip(int count) => new(_categoryName, _predicate, SelectionMode.Skip, count); protected override KeywordCategoryQuery CreateTake(int count) => new(_categoryName, _predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } diff --git a/TinyTokenizer/Ast/QueryCombinators.cs b/TinyTokenizer/Ast/QueryCombinators.cs index 4952a31..0c8dd84 100644 --- a/TinyTokenizer/Ast/QueryCombinators.cs +++ b/TinyTokenizer/Ast/QueryCombinators.cs @@ -76,7 +76,7 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// Useful for extracting content between matching patterns: /// Query.Between(Query.Symbol("("), Query.Symbol(")")) /// -public sealed record BetweenQuery : INodeQuery, IGreenNodeQuery +public sealed record BetweenQuery : INodeQuery, IGreenNodeQuery, IRegionQuery { private readonly INodeQuery _start; private readonly INodeQuery _end; @@ -183,6 +183,34 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI // End not found return false; } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => ((IRegionQuery)this).SelectRegions(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (TryMatch(node, out var consumedCount)) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } } #endregion @@ -192,7 +220,7 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// /// Matches a sequence of queries in order, consuming multiple sibling nodes. /// -public sealed record SequenceQuery : INodeQuery, IGreenNodeQuery +public sealed record SequenceQuery : INodeQuery, IGreenNodeQuery, IRegionQuery { private readonly ImmutableArray _parts; @@ -308,6 +336,34 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI consumedCount = totalConsumed; return true; } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => ((IRegionQuery)this).SelectRegions(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (TryMatch(node, out var consumedCount)) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } } #endregion @@ -318,7 +374,7 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// Matches zero or one occurrence of the inner query. /// Always succeeds - returns empty match if inner doesn't match. /// -public sealed record OptionalQuery : INodeQuery, IGreenNodeQuery +public sealed record OptionalQuery : INodeQuery, IGreenNodeQuery, IRegionQuery { private readonly INodeQuery _inner; @@ -355,6 +411,35 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI consumedCount = 0; return true; // Optional always succeeds with 0 consumed } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => ((IRegionQuery)this).SelectRegions(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + { + // Optional delegates to inner query - traverse and match inner directly + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (_inner.TryMatch(node, out var consumedCount) && consumedCount > 0) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } } #endregion @@ -364,7 +449,7 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// /// Matches the inner query multiple times (min to max occurrences). /// -public sealed record RepeatQuery : INodeQuery, IGreenNodeQuery +public sealed record RepeatQuery : INodeQuery, IGreenNodeQuery, IRegionQuery { private readonly INodeQuery _inner; private readonly int _min; @@ -463,6 +548,34 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI consumedCount = totalConsumed; return true; } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => ((IRegionQuery)this).SelectRegions(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (TryMatch(node, out var consumedCount) && consumedCount > 0) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } } #endregion @@ -473,7 +586,7 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// Matches the inner query repeatedly until a terminator is encountered. /// The terminator is NOT consumed (lookahead-style matching). /// -public sealed record RepeatUntilQuery : INodeQuery, IGreenNodeQuery +public sealed record RepeatUntilQuery : INodeQuery, IGreenNodeQuery, IRegionQuery { private readonly INodeQuery _inner; private readonly INodeQuery _terminator; @@ -642,6 +755,34 @@ private bool TerminatorMatchesGreen(IReadOnlyList siblings, int index return false; } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => ((IRegionQuery)this).SelectRegions(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (TryMatch(node, out var consumedCount)) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } } #endregion @@ -652,7 +793,7 @@ private bool TerminatorMatchesGreen(IReadOnlyList siblings, int index /// Matches the inner query only if followed by (or not followed by) the lookahead query. /// The lookahead is not consumed (zero-width assertion). /// -public sealed record LookaheadQuery : INodeQuery, IGreenNodeQuery +public sealed record LookaheadQuery : INodeQuery, IGreenNodeQuery, IRegionQuery { private readonly INodeQuery _inner; private readonly INodeQuery _lookahead; @@ -756,6 +897,34 @@ _lookahead is IGreenNodeQuery lookaheadGreen && consumedCount = innerConsumed; return true; } + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + => ((IRegionQuery)this).SelectRegions(tree.Root); + + /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) + { + var walker = new PathTrackingWalker(root); + foreach (var (node, parentPath) in walker.DescendantsAndSelfWithPath()) + { + if (TryMatch(node, out var consumedCount)) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parentPath: parentPath, + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } } #endregion diff --git a/TinyTokenizer/Ast/QueryRegion.cs b/TinyTokenizer/Ast/QueryRegion.cs new file mode 100644 index 0000000..63c7ad1 --- /dev/null +++ b/TinyTokenizer/Ast/QueryRegion.cs @@ -0,0 +1,245 @@ +using System.Collections.Immutable; + +namespace TinyTokenizer.Ast; + +/// +/// Represents a resolved region in the syntax tree. +/// A region is a contiguous range of slots within a parent container. +/// May be empty (StartSlot == EndSlot) representing a zero-width position. +/// +/// +/// This is an internal type used by to translate +/// queries into edit operations. Users interact with the public Query API. +/// +/// is computed lazily to avoid tree-walking overhead +/// when regions are enumerated but not all are used for editing. +/// +/// +internal readonly struct QueryRegion +{ + private readonly NodePath? _parentPath; + private readonly SyntaxNode _parent; + + /// + /// Creates a new region with lazy path computation. + /// + public QueryRegion( + SyntaxNode parent, + int startSlot, + int endSlot, + SyntaxNode? firstNode, + int position) + { + _parentPath = null; + _parent = parent; + StartSlot = startSlot; + EndSlot = endSlot; + FirstNode = firstNode; + Position = position; + } + + /// + /// Creates a new region with pre-computed path. + /// Use when path is already available to avoid recomputation. + /// + public QueryRegion( + NodePath parentPath, + SyntaxNode parent, + int startSlot, + int endSlot, + SyntaxNode? firstNode, + int position) + { + _parentPath = parentPath; + _parent = parent; + StartSlot = startSlot; + EndSlot = endSlot; + FirstNode = firstNode; + Position = position; + } + + /// + /// Path to the parent container holding this region. + /// Computed lazily on first access. + /// + public NodePath ParentPath => _parentPath ?? NodePath.FromNode(_parent); + + /// Parent container node. + public SyntaxNode Parent => _parent; + + /// First slot index in the region (inclusive). + public int StartSlot { get; } + + /// Slot index after the last slot in the region (exclusive). + public int EndSlot { get; } + + /// Number of slots in the region. + public int SlotCount => EndSlot - StartSlot; + + /// Whether this region is empty (zero-width position). + public bool IsEmpty => StartSlot == EndSlot; + + /// The first node in the region, if any exist. Null for empty regions. + public SyntaxNode? FirstNode { get; } + + /// Document position at the start of this region. + public int Position { get; } + + /// Enumerates all nodes currently in this region. + public IEnumerable Nodes + { + get + { + for (int i = StartSlot; i < EndSlot; i++) + { + var child = Parent.GetChild(i); + if (child != null) + yield return child; + } + } + } + + /// Gets the last node in the region, or null if empty. + public SyntaxNode? LastNode + { + get + { + if (IsEmpty) return null; + for (int i = EndSlot - 1; i >= StartSlot; i--) + { + var child = Parent.GetChild(i); + if (child != null) + return child; + } + return null; + } + } + + /// Document position at the end of this region. + public int EndPosition => LastNode?.EndPosition ?? Position; +} + +/// +/// Internal interface for queries that can resolve to regions. +/// Used by SyntaxEditor to translate queries into edit operations. +/// +internal interface IRegionQuery +{ + /// Resolves this query to regions in the tree. + IEnumerable SelectRegions(SyntaxTree tree); + + /// Resolves this query to regions in a subtree. + IEnumerable SelectRegions(SyntaxNode root); +} + +/// +/// A tree walker that incrementally tracks the path during traversal. +/// O(1) per traversal step instead of O(depth) for NodePath.FromNode(). +/// +internal sealed class PathTrackingWalker +{ + private readonly SyntaxNode _root; + private readonly List _pathStack; + private SyntaxNode _current; + + public PathTrackingWalker(SyntaxNode root) + { + _root = root; + _current = root; + _pathStack = new List(8); // Pre-allocate for typical tree depth + } + + /// The current node. + public SyntaxNode Current => _current; + + /// + /// Gets the current path as a NodePath. + /// The path leads to the CURRENT node (not its parent). + /// + public NodePath CurrentPath => new NodePath(ImmutableArray.CreateRange(_pathStack)); + + /// + /// Gets the path to the parent of the current node. + /// Returns Root path if current is the root. + /// + public NodePath ParentPath + { + get + { + if (_pathStack.Count == 0) + return NodePath.Root; + + // Return path without the last index (current node's sibling index) + return new NodePath(ImmutableArray.CreateRange(_pathStack.Take(_pathStack.Count - 1))); + } + } + + /// + /// Enumerates all descendants of the root in document order, + /// yielding each node along with its parent path. + /// + public IEnumerable<(SyntaxNode Node, NodePath ParentPath)> DescendantsAndSelfWithPath() + { + // Yield root first + yield return (_root, NodePath.Root); + + // Reset state for traversal + _current = _root; + _pathStack.Clear(); + + while (MoveNext()) + { + yield return (_current, ParentPath); + } + } + + /// + /// Moves to the next node in document order (depth-first pre-order). + /// Returns true if moved, false if at end. + /// + private bool MoveNext() + { + // Try first child + if (_current.SlotCount > 0) + { + for (int i = 0; i < _current.SlotCount; i++) + { + var child = _current.GetChild(i); + if (child != null) + { + _pathStack.Add(i); + _current = child; + return true; + } + } + } + + // Try next sibling or ancestor's next sibling + while (_pathStack.Count > 0) + { + var parent = _current.Parent; + if (parent == null) + break; + + var currentIndex = _pathStack[_pathStack.Count - 1]; + _pathStack.RemoveAt(_pathStack.Count - 1); + + // Try next sibling + for (int i = currentIndex + 1; i < parent.SlotCount; i++) + { + var sibling = parent.GetChild(i); + if (sibling != null) + { + _pathStack.Add(i); + _current = sibling; + return true; + } + } + + // Move up to try parent's siblings + _current = parent; + } + + return false; + } +} diff --git a/TinyTokenizer/Ast/SemanticMatchExtensions.cs b/TinyTokenizer/Ast/SemanticMatchExtensions.cs index d5af20b..d28b1c4 100644 --- a/TinyTokenizer/Ast/SemanticMatchExtensions.cs +++ b/TinyTokenizer/Ast/SemanticMatchExtensions.cs @@ -163,6 +163,9 @@ protected override SyntaxNodeQuery CreateFiltered(Func predica protected override SyntaxNodeQuery CreateSkip(int count) => new(_kind, _predicate, SelectionMode.Skip, count); protected override SyntaxNodeQuery CreateTake(int count) => new(_kind, _predicate, SelectionMode.Take, count); + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; + private static Func? CombinePredicates(Func? a, Func b) => a == null ? b : n => a(n) && b(n); } @@ -272,4 +275,7 @@ protected override SyntaxNodeQuery CreateFiltered(Func pred protected override SyntaxNodeQuery CreateNth(int n) => new SyntaxNodeQuery(_predicate, SelectionMode.Nth, n); protected override SyntaxNodeQuery CreateSkip(int count) => new SyntaxNodeQuery(_predicate, SelectionMode.Skip, count); protected override SyntaxNodeQuery CreateTake(int count) => new SyntaxNodeQuery(_predicate, SelectionMode.Take, count); + + internal override SelectionMode Mode => _mode; + internal override int ModeArg => _modeArg; } diff --git a/TinyTokenizer/Ast/SyntaxBlock.cs b/TinyTokenizer/Ast/SyntaxBlock.cs index 6952ee9..3adc7f6 100644 --- a/TinyTokenizer/Ast/SyntaxBlock.cs +++ b/TinyTokenizer/Ast/SyntaxBlock.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using System.Diagnostics; +using CommunityToolkit.HighPerformance.Buffers; namespace TinyTokenizer.Ast; @@ -32,20 +33,15 @@ internal SyntaxBlock(GreenBlock green, SyntaxNode? parent, int position, int sib public char Closer => Green.Closer; /// The opening delimiter node with its trivia. - public SyntaxToken OpenerNode => (SyntaxToken)Green.OpenerNode.CreateRed(this, Position, -1, Tree); + /// Opener is slot 0 in the Roslyn-style slot model. + public SyntaxToken OpenerNode => (SyntaxToken)GetChild(0)!; /// The closing delimiter node with its trivia. - public SyntaxToken CloserNode - { - get - { - var closerPosition = EndPosition - Green.CloserNode.Width; - return (SyntaxToken)Green.CloserNode.CreateRed(this, closerPosition, -1, Tree); - } - } + /// Closer is the last slot (N+1) in the Roslyn-style slot model. + public SyntaxToken CloserNode => (SyntaxToken)GetChild(SlotCount - 1)!; - /// Number of children in this block. - public int ChildCount => Green.SlotCount; + /// Number of inner children in this block (excluding opener/closer). + public int ChildCount => Green.InnerChildren.Length; /// Leading trivia before the opening delimiter (from opener node). internal ImmutableArray GreenLeadingTrivia => Green.OpenerNode.LeadingTrivia; @@ -140,13 +136,48 @@ public IEnumerable GetTrailingTrivia() public int InnerEndPosition => EndPosition - Green.CloserNode.Width; /// - /// Gets all children as an enumerable (lazy creation). + /// Gets the text of inner children only (excluding opener and closer delimiters). + /// + public string InnerText + { + get + { + using var buffer = new ArrayPoolBufferWriter(); + foreach (var child in InnerChildren) + { + child.WriteTo(buffer); + } + return buffer.WrittenSpan.ToString(); + } + } + + /// + /// Gets all children including opener and closer (Roslyn-style traversal). + /// Slot 0 = opener, slots 1..N = inner children, slot N+1 = closer. /// public new IEnumerable Children { get { - for (int i = 0; i < ChildCount; i++) + for (int i = 0; i < SlotCount; i++) + { + var child = GetChild(i); + if (child != null) + yield return child; + } + } + } + + /// + /// Gets inner children only (excluding opener and closer delimiters). + /// Use this for content-focused traversal. + /// + public IEnumerable InnerChildren + { + get + { + // Inner children are slots 1 through SlotCount-2 (excluding opener at 0 and closer at N+1) + for (int i = 1; i < SlotCount - 1; i++) { var child = GetChild(i); if (child != null) @@ -156,11 +187,11 @@ public IEnumerable GetTrailingTrivia() } /// - /// Gets children of a specific kind. + /// Gets inner children of a specific kind. /// public IEnumerable ChildrenOfKind(NodeKind kind) { - foreach (var child in Children) + foreach (var child in InnerChildren) { if (child.Kind == kind) yield return child; @@ -168,13 +199,13 @@ public IEnumerable ChildrenOfKind(NodeKind kind) } /// - /// Gets all leaf children. + /// Gets all inner leaf children (excluding opener/closer). /// public IEnumerable LeafChildren { get { - foreach (var child in Children) + foreach (var child in InnerChildren) { if (child is SyntaxToken leaf) yield return leaf; @@ -183,13 +214,13 @@ public IEnumerable LeafChildren } /// - /// Gets all block children. + /// Gets all inner block children. /// public IEnumerable BlockChildren { get { - foreach (var child in Children) + foreach (var child in InnerChildren) { if (child is SyntaxBlock block) yield return block; @@ -198,13 +229,14 @@ public IEnumerable BlockChildren } /// - /// Finds the index of a child node. + /// Finds the index of a child node within inner children. + /// Returns slot index (1-based for inner children, 0 for opener, SlotCount-1 for closer). /// Uses SyntaxNode equality which compares by green node identity and position. /// public int IndexOf(SyntaxNode child) { // First check if the child has a valid sibling index from its parent - if (child.SiblingIndex >= 0 && child.SiblingIndex < ChildCount) + if (child.SiblingIndex >= 0 && child.SiblingIndex < SlotCount) { // Verify it's actually from this block var candidate = GetChild(child.SiblingIndex); @@ -214,8 +246,8 @@ public int IndexOf(SyntaxNode child) } } - // Fall back to linear search - for (int i = 0; i < ChildCount; i++) + // Fall back to linear search across all slots + for (int i = 0; i < SlotCount; i++) { var candidate = GetChild(i); if (candidate == child) diff --git a/TinyTokenizer/Ast/SyntaxEditor.cs b/TinyTokenizer/Ast/SyntaxEditor.cs index fada12b..47efd84 100644 --- a/TinyTokenizer/Ast/SyntaxEditor.cs +++ b/TinyTokenizer/Ast/SyntaxEditor.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using CommunityToolkit.HighPerformance.Buffers; namespace TinyTokenizer.Ast; @@ -44,11 +45,53 @@ internal SyntaxEditor(SyntaxTree tree, TokenizerOptions? options = null) /// public bool HasPendingEdits => _edits.Count > 0; + #region Region Resolution + + /// + /// Gets regions from a query, using IRegionQuery if available, + /// otherwise falling back to match-based resolution. + /// + private IEnumerable GetRegions(INodeQuery query) + { + if (query is IRegionQuery regionQuery) + { + return regionQuery.SelectRegions(_tree); + } + + // Fallback for queries that don't implement IRegionQuery + return GetRegionsFromMatches(query); + } + + private IEnumerable GetRegionsFromMatches(INodeQuery query) + { + // Use Select + TryMatch to get matches without SelectMatches + foreach (var node in query.Select(_tree)) + { + if (query.TryMatch(node, out var consumedCount)) + { + var parent = node.Parent; + if (parent != null) + { + yield return new QueryRegion( + parent: parent, + startSlot: node.SiblingIndex, + endSlot: node.SiblingIndex + consumedCount, + firstNode: node, + position: node.Position + ); + } + } + } + } + + #endregion + #region Insert (Query-based) /// /// Queues an insertion of text before all nodes matching the query. - /// For , handles empty containers by using container metadata. + /// For range queries (e.g., Query.Between), inserts before the start of the matched range. + /// For empty regions (e.g., empty blocks via Inner()), inserts at the region position. /// /// A query specifying which nodes to insert before. /// The text to insert (will be parsed into nodes). @@ -60,29 +103,20 @@ internal SyntaxEditor(SyntaxTree tree, TokenizerOptions? options = null) /// public SyntaxEditor InsertBefore(INodeQuery query, string text) { - // Handle BoundaryQuery specially for empty container support - if (query is BoundaryQuery boundaryQuery) - { - foreach (var container in boundaryQuery.ResolveContainers(_tree)) - { - var pos = CreateBoundaryInsertionPosition(container, boundaryQuery.Side, before: true); - if (pos.HasValue) - _edits.Add(new InsertEdit(pos.Value, text) { SequenceNumber = _sequenceNumber++ }); - } - return this; - } - - // Standard query - insert before each matched node - foreach (var node in query.Select(_tree)) + foreach (var region in GetRegions(query)) { - InsertBefore(node, text); + _edits.Add(new InsertAtSlotEdit(region.ParentPath, region.StartSlot, region.Position, text) + { + SequenceNumber = _sequenceNumber++ + }); } return this; } /// /// Queues an insertion of text after all nodes matching the query. - /// For , handles empty containers by using container metadata. + /// For range queries (e.g., Query.Between), inserts after the end of the matched range. + /// For empty regions (e.g., empty blocks via Inner()), inserts at the region position. /// /// A query specifying which nodes to insert after. /// The text to insert (will be parsed into nodes). @@ -94,22 +128,12 @@ public SyntaxEditor InsertBefore(INodeQuery query, string text) /// public SyntaxEditor InsertAfter(INodeQuery query, string text) { - // Handle BoundaryQuery specially for empty container support - if (query is BoundaryQuery boundaryQuery) + foreach (var region in GetRegions(query)) { - foreach (var container in boundaryQuery.ResolveContainers(_tree)) - { - var pos = CreateBoundaryInsertionPosition(container, boundaryQuery.Side, before: false); - if (pos.HasValue) - _edits.Add(new InsertEdit(pos.Value, text) { SequenceNumber = _sequenceNumber++ }); - } - return this; - } - - // Standard query - insert after each matched node - foreach (var node in query.Select(_tree)) - { - InsertAfter(node, text); + _edits.Add(new InsertAtSlotEdit(region.ParentPath, region.EndSlot, region.EndPosition, text) + { + SequenceNumber = _sequenceNumber++ + }); } return this; } @@ -304,15 +328,20 @@ internal SyntaxEditor InsertAfter(IEnumerable targets, IEnumerable /// Queues removal of all nodes matching the query. + /// For range queries (e.g., Query.Between), removes all nodes in the matched range. /// public SyntaxEditor Remove(INodeQuery query) { - var nodes = query.Select(_tree).ToList(); - - foreach (var node in nodes) + foreach (var region in GetRegions(query)) { - var path = NodePath.FromNode(node); - _edits.Add(new RemoveEdit(path, node.Position) { SequenceNumber = _sequenceNumber++ }); + if (!region.IsEmpty) + { + var path = region.FirstNode != null + ? NodePath.FromNode(region.FirstNode) + : region.ParentPath.Child(region.StartSlot); + _edits.Add(new RemoveEdit(path, region.Position, region.SlotCount) { SequenceNumber = _sequenceNumber++ }); + } + // Empty regions: nothing to remove, silently skip } return this; @@ -362,16 +391,17 @@ public SyntaxEditor Remove(IEnumerable nodes) /// /// Queues replacement of all nodes matching the query with new text. + /// For range queries (e.g., Query.Between), replaces all nodes in the matched range. /// public SyntaxEditor Replace(INodeQuery query, string text) { - var nodes = query.Select(_tree).ToList(); - - foreach (var node in nodes) + foreach (var region in GetRegions(query)) { - var path = NodePath.FromNode(node); - var (leading, trailing) = GetTrivia(node); - _edits.Add(new ReplaceEdit(path, text, node.Position, leading, trailing) { SequenceNumber = _sequenceNumber++ }); + var (leading, trailing) = GetTriviaForRegion(region); + _edits.Add(new ReplaceRegionEdit(region, text, leading, trailing) + { + SequenceNumber = _sequenceNumber++ + }); } return this; @@ -396,14 +426,17 @@ public SyntaxEditor Replace(IEnumerable queries, string text) /// public SyntaxEditor Replace(INodeQuery query, Func replacer) { - var nodes = query.Select(_tree).ToList(); - - foreach (var node in nodes) + foreach (var region in GetRegions(query)) { + var node = region.FirstNode; + if (node == null) continue; + var text = replacer(node); - var path = NodePath.FromNode(node); - var (leading, trailing) = GetTrivia(node); - _edits.Add(new ReplaceEdit(path, text, node.Position, leading, trailing) { SequenceNumber = _sequenceNumber++ }); + var (leading, trailing) = GetTriviaForRegion(region); + _edits.Add(new ReplaceRegionEdit(region, text, leading, trailing) + { + SequenceNumber = _sequenceNumber++ + }); } return this; @@ -428,13 +461,13 @@ public SyntaxEditor Replace(IEnumerable queries, Func internal SyntaxEditor Replace(INodeQuery query, ImmutableArray nodes) { - var matchedNodes = query.Select(_tree).ToList(); - - foreach (var node in matchedNodes) + foreach (var region in GetRegions(query)) { - var path = NodePath.FromNode(node); - var (leading, trailing) = GetTrivia(node); - _edits.Add(new ReplaceNodesEdit(path, nodes, node.Position, leading, trailing) { SequenceNumber = _sequenceNumber++ }); + var (leading, trailing) = GetTriviaForRegion(region); + _edits.Add(new ReplaceNodesRegionEdit(region, nodes, leading, trailing) + { + SequenceNumber = _sequenceNumber++ + }); } return this; @@ -550,20 +583,24 @@ internal SyntaxEditor Replace(SyntaxNode node, IEnumerable replacemen /// /// Queues a transformation edit of all nodes matching the query. /// The transformer receives the node's content WITHOUT trivia, and trivia is automatically preserved. + /// For range queries, receives the concatenated content of all matched nodes. /// /// Query to select nodes to edit. /// Function that transforms the node's content (without trivia). public SyntaxEditor Edit(INodeQuery query, Func transformer) { - var nodes = query.Select(_tree).ToList(); - - foreach (var node in nodes) + foreach (var region in GetRegions(query)) { - var path = NodePath.FromNode(node); - var (leading, trailing) = GetTrivia(node); - var contentWithoutTrivia = GetContentWithoutTrivia(node); + if (region.IsEmpty) continue; + + var firstNode = region.FirstNode; + if (firstNode == null) continue; + + var path = NodePath.FromNode(firstNode); + var (leading, trailing) = GetTriviaForRegion(region); + var contentWithoutTrivia = GetContentWithoutTriviaForRegion(region); var newText = transformer(contentWithoutTrivia); - _edits.Add(new ReplaceEdit(path, newText, node.Position, leading, trailing) { SequenceNumber = _sequenceNumber++ }); + _edits.Add(new ReplaceEdit(path, newText, region.Position, leading, trailing, region.SlotCount) { SequenceNumber = _sequenceNumber++ }); } return this; @@ -623,6 +660,7 @@ public SyntaxEditor Edit(IEnumerable nodes, Func tra /// Gets the content of a node without its leading and trailing trivia. /// For leaves, returns the token text. For blocks, returns the full content minus outer trivia. /// For syntax nodes (containers), returns full content minus the trivia from first/last children. + /// Uses precomputed trivia widths from green nodes for efficiency. /// private static string GetContentWithoutTrivia(SyntaxNode node) { @@ -645,75 +683,126 @@ private static string GetContentWithoutTrivia(SyntaxNode node) if (contentLength <= 0) return string.Empty; - return fullText.Substring(leadingWidth, contentLength); + return fullText.AsSpan(leadingWidth, contentLength).ToString(); } - // For syntax nodes and other containers, check first/last children for trivia - var (leadingTrivia, trailingTrivia) = GetTrivia(node); - if (leadingTrivia.IsEmpty && trailingTrivia.IsEmpty) + // For syntax nodes and other containers, use precomputed trivia widths + var green = node.Green; + var leadingTriviaWidth = green.GetLeadingTriviaWidth(); + var trailingTriviaWidth = green.GetTrailingTriviaWidth(); + + if (leadingTriviaWidth == 0 && trailingTriviaWidth == 0) { return node.ToText(); } var text = node.ToText(); - var leadingTriviaWidth = leadingTrivia.Sum(t => t.Width); - var trailingTriviaWidth = trailingTrivia.Sum(t => t.Width); - var contentLen = text.Length - leadingTriviaWidth - trailingTriviaWidth; if (contentLen <= 0) return string.Empty; - return text.Substring(leadingTriviaWidth, contentLen); + return text.AsSpan(leadingTriviaWidth, contentLen).ToString(); } /// - /// Extracts leading and trailing trivia from a node. - /// For leaves, returns the leaf's trivia. - /// For blocks, returns the block's trivia. - /// For syntax nodes (containers), returns trivia from the first child (leading) and last child (trailing). + /// Gets the concatenated content of a range of siblings without leading/trailing trivia. + /// Leading trivia from first node and trailing trivia from last node are excluded. + /// Uses precomputed trivia widths from green nodes for efficiency. /// - private static (ImmutableArray Leading, ImmutableArray Trailing) GetTrivia(SyntaxNode node) + private static string GetContentWithoutTriviaForRange(SyntaxNode startNode, int count) { - if (node is SyntaxToken leaf) - { - var greenLeaf = (GreenLeaf)leaf.Green; - return (greenLeaf.LeadingTrivia, greenLeaf.TrailingTrivia); - } + if (count <= 0) + return string.Empty; - if (node is SyntaxBlock block) - { - return (block.GreenLeadingTrivia, block.GreenTrailingTrivia); - } + if (count == 1) + return GetContentWithoutTrivia(startNode); - // For syntax nodes and other containers, get trivia from first/last children - var leading = ImmutableArray.Empty; - var trailing = ImmutableArray.Empty; + // For multiple nodes, concatenate their content using pooled buffer + using var buffer = new ArrayPoolBufferWriter(); + var current = startNode; - // Get leading trivia from first child - var firstChild = node.Children.FirstOrDefault(); - if (firstChild != null) + for (int i = 0; i < count && current != null; i++) { - var (childLeading, _) = GetTrivia(firstChild); - leading = childLeading; + var content = current.ToText(); + + if (i == 0) + { + // First node: exclude leading trivia only (use precomputed width) + var leadingWidth = current.Green.GetLeadingTriviaWidth(); + var span = content.AsSpan(leadingWidth); + var dest = buffer.GetSpan(span.Length); + span.CopyTo(dest); + buffer.Advance(span.Length); + } + else if (i == count - 1) + { + // Last node: exclude trailing trivia only (use precomputed width) + var trailingWidth = current.Green.GetTrailingTriviaWidth(); + var span = content.AsSpan(0, content.Length - trailingWidth); + var dest = buffer.GetSpan(span.Length); + span.CopyTo(dest); + buffer.Advance(span.Length); + } + else + { + // Middle nodes: include everything + var span = content.AsSpan(); + var dest = buffer.GetSpan(span.Length); + span.CopyTo(dest); + buffer.Advance(span.Length); + } + + current = current.NextSibling(); } - // Get trailing trivia from last child - var lastChild = node.Children.LastOrDefault(); - if (lastChild != null && !ReferenceEquals(lastChild, firstChild)) - { - var (_, childTrailing) = GetTrivia(lastChild); - trailing = childTrailing; - } - else if (firstChild != null) - { - // Only one child - get trailing from that same child - var (_, childTrailing) = GetTrivia(firstChild); - trailing = childTrailing; - } + return buffer.WrittenSpan.ToString(); + } + + /// + /// Extracts leading and trailing trivia from a node. + /// Uses green node's GetFirstLeaf/GetLastLeaf for O(depth) access instead of recursive red node traversal. + /// + private static (ImmutableArray Leading, ImmutableArray Trailing) GetTrivia(SyntaxNode node) + { + // Delegate to green node which efficiently finds first/last leaf + var green = node.Green; + return (green.GetLeadingTrivia(), green.GetTrailingTrivia()); + } + + /// + /// Gets trivia for a query region. + /// For empty regions, returns empty trivia. + /// For non-empty regions, returns leading from first and trailing from last. + /// + private static (ImmutableArray Leading, ImmutableArray Trailing) GetTriviaForRegion(QueryRegion region) + { + if (region.IsEmpty) + return (ImmutableArray.Empty, ImmutableArray.Empty); + + var first = region.FirstNode; + var last = region.LastNode; + + var leading = first != null ? GetTrivia(first).Leading : ImmutableArray.Empty; + var trailing = last != null ? GetTrivia(last).Trailing : ImmutableArray.Empty; return (leading, trailing); } + /// + /// Gets the content of a region without leading/trailing trivia. + /// + private static string GetContentWithoutTriviaForRegion(QueryRegion region) + { + if (region.IsEmpty) + return string.Empty; + + var first = region.FirstNode; + if (first == null) + return string.Empty; + + return GetContentWithoutTriviaForRange(first, region.SlotCount); + } + /// /// Creates an InsertionPosition for inserting before or after a target node. /// @@ -737,94 +826,6 @@ private static InsertionPosition CreateInsertionPosition(SyntaxNode target, bool : new InsertionPosition(parentPath, childIndex + 1, target.EndPosition, targetPath, targetLeading, targetTrailing); } - /// - /// Creates an InsertionPosition for a boundary query (Start/End of a container). - /// Handles empty containers by computing position from container metadata. - /// - private static InsertionPosition? CreateBoundaryInsertionPosition(SyntaxNode container, BoundarySide side, bool before) - { - if (container is SyntaxBlock block) - { - // For blocks, the boundary nodes are the opener/closer - // Start + InsertAfter = insert at beginning of content (after opener) - // End + InsertBefore = insert at end of content (before closer) - var blockPath = NodePath.FromNode(block); - - if (side == BoundarySide.Start) - { - // Insert relative to opener - if (before) - { - // InsertBefore(Start) = insert before the opener (before the block content) - var parent = block.Parent; - if (parent == null) return null; - var parentPath = NodePath.FromNode(parent); - return new InsertionPosition(parentPath, block.SiblingIndex, block.Position, null, - ImmutableArray.Empty, ImmutableArray.Empty); - } - else - { - // InsertAfter(Start) = insert at beginning of block content (child index 0) - return new InsertionPosition(blockPath, 0, block.InnerStartPosition, null, - ImmutableArray.Empty, ImmutableArray.Empty); - } - } - else // BoundarySide.End - { - if (before) - { - // InsertBefore(End) = insert at end of block content (after last child) - return new InsertionPosition(blockPath, block.ChildCount, block.InnerEndPosition, null, - ImmutableArray.Empty, ImmutableArray.Empty); - } - else - { - // InsertAfter(End) = insert after the closer (after the block) - var parent = block.Parent; - if (parent == null) return null; - var parentPath = NodePath.FromNode(parent); - return new InsertionPosition(parentPath, block.SiblingIndex + 1, block.EndPosition, null, - ImmutableArray.Empty, ImmutableArray.Empty); - } - } - } - - // For non-block containers (lists, syntax nodes), use first/last child - var children = container.Children.ToList(); - var containerPath = NodePath.FromNode(container); - - if (side == BoundarySide.Start) - { - if (children.Count == 0) - { - // Empty container - insert at position 0 (child index 0) - return before - ? null // Can't insert before nothing - : new InsertionPosition(containerPath, 0, container.Position, null, - ImmutableArray.Empty, ImmutableArray.Empty); - } - - // Has children - delegate to first child - var first = children[0]; - return CreateInsertionPosition(first, before); - } - else // BoundarySide.End - { - if (children.Count == 0) - { - // Empty container - insert at position 0 (child index 0) - return before - ? new InsertionPosition(containerPath, 0, container.Position, null, - ImmutableArray.Empty, ImmutableArray.Empty) - : null; // Can't insert after nothing - } - - // Has children - delegate to last child - var last = children[^1]; - return CreateInsertionPosition(last, before); - } - } - /// /// Converts an enumerable of RedNodes to an ImmutableArray of their underlying GreenNodes. /// @@ -987,11 +988,13 @@ internal sealed class RemoveEdit : PendingEdit { private readonly NodePath _path; private readonly int _position; + private readonly int _count; - public RemoveEdit(NodePath path, int position) + public RemoveEdit(NodePath path, int position, int count = 1) { _path = path; _position = position; + _count = count; } public override int Position => _position; @@ -1002,7 +1005,7 @@ public override GreenNode Apply(GreenNode root, GreenTreeBuilder builder, Tokeni { var parentPath = _path.Parent(); var childIndex = _path.Depth > 0 ? _path[_path.Depth - 1] : 0; - return builder.RemoveAt(parentPath.ToArray(), childIndex, 1); + return builder.RemoveAt(parentPath.ToArray(), childIndex, _count); } } @@ -1011,16 +1014,19 @@ internal sealed class ReplaceEdit : PendingEdit private readonly NodePath _path; private readonly string _text; private readonly int _position; + private readonly int _count; private readonly ImmutableArray _leadingTrivia; private readonly ImmutableArray _trailingTrivia; public ReplaceEdit(NodePath path, string text, int position, ImmutableArray leadingTrivia = default, - ImmutableArray trailingTrivia = default) + ImmutableArray trailingTrivia = default, + int count = 1) { _path = path; _text = text; _position = position; + _count = count; _leadingTrivia = leadingTrivia.IsDefault ? ImmutableArray.Empty : leadingTrivia; _trailingTrivia = trailingTrivia.IsDefault ? ImmutableArray.Empty : trailingTrivia; } @@ -1043,7 +1049,7 @@ public override GreenNode Apply(GreenNode root, GreenTreeBuilder builder, Tokeni nodes = TransferTrivia(nodes, _leadingTrivia, _trailingTrivia); } - return builder.ReplaceAt(parentPath.ToArray(), childIndex, 1, nodes); + return builder.ReplaceAt(parentPath.ToArray(), childIndex, _count, nodes); } /// @@ -1086,16 +1092,19 @@ internal sealed class ReplaceNodesEdit : PendingEdit private readonly NodePath _path; private readonly ImmutableArray _nodes; private readonly int _position; + private readonly int _count; private readonly ImmutableArray _leadingTrivia; private readonly ImmutableArray _trailingTrivia; public ReplaceNodesEdit(NodePath path, ImmutableArray nodes, int position, ImmutableArray leadingTrivia = default, - ImmutableArray trailingTrivia = default) + ImmutableArray trailingTrivia = default, + int count = 1) { _path = path; _nodes = nodes; _position = position; + _count = count; _leadingTrivia = leadingTrivia.IsDefault ? ImmutableArray.Empty : leadingTrivia; _trailingTrivia = trailingTrivia.IsDefault ? ImmutableArray.Empty : trailingTrivia; } @@ -1117,7 +1126,7 @@ public override GreenNode Apply(GreenNode root, GreenTreeBuilder builder, Tokeni nodes = TransferTrivia(nodes, _leadingTrivia, _trailingTrivia); } - return builder.ReplaceAt(parentPath.ToArray(), childIndex, 1, nodes); + return builder.ReplaceAt(parentPath.ToArray(), childIndex, _count, nodes); } /// @@ -1155,6 +1164,171 @@ private static ImmutableArray TransferTrivia( } } +internal sealed class InsertAtSlotEdit : PendingEdit +{ + private readonly NodePath _parentPath; + private readonly int _slot; + private readonly int _position; + private readonly string _text; + + public InsertAtSlotEdit(NodePath parentPath, int slot, int position, string text) + { + _parentPath = parentPath; + _slot = slot; + _position = position; + _text = text; + } + + public override int Position => _position; + + public override NodePath AffectedPath => _parentPath; + + public override GreenNode Apply(GreenNode root, GreenTreeBuilder builder, TokenizerOptions options) + { + var lexer = new GreenLexer(options); + var newNodes = lexer.ParseToGreenNodes(_text); + + return builder.InsertAt(_parentPath.ToArray(), _slot, newNodes); + } +} + +internal sealed class ReplaceRegionEdit : PendingEdit +{ + private readonly QueryRegion _region; + private readonly string _text; + private readonly ImmutableArray _leadingTrivia; + private readonly ImmutableArray _trailingTrivia; + + public ReplaceRegionEdit(QueryRegion region, string text, + ImmutableArray leadingTrivia, + ImmutableArray trailingTrivia) + { + _region = region; + _text = text; + _leadingTrivia = leadingTrivia.IsDefault ? ImmutableArray.Empty : leadingTrivia; + _trailingTrivia = trailingTrivia.IsDefault ? ImmutableArray.Empty : trailingTrivia; + } + + public override int Position => _region.Position; + + public override NodePath AffectedPath => _region.ParentPath; + + public override GreenNode Apply(GreenNode root, GreenTreeBuilder builder, TokenizerOptions options) + { + var lexer = new GreenLexer(options); + var newNodes = lexer.ParseToGreenNodes(_text); + + if (newNodes.Length > 0) + { + newNodes = TransferTrivia(newNodes, _leadingTrivia, _trailingTrivia); + } + + return builder.ReplaceAt( + _region.ParentPath.ToArray(), + _region.StartSlot, + _region.SlotCount, + newNodes + ); + } + + private static ImmutableArray TransferTrivia( + ImmutableArray nodes, + ImmutableArray leading, + ImmutableArray trailing) + { + if (leading.IsEmpty && trailing.IsEmpty) + return nodes; + + var result = nodes.ToBuilder(); + + if (!leading.IsEmpty && result[0] is GreenLeaf firstLeaf) + { + var newLeading = firstLeaf.LeadingTrivia.IsEmpty + ? leading + : leading.AddRange(firstLeaf.LeadingTrivia); + result[0] = firstLeaf.WithLeadingTrivia(newLeading); + } + + if (!trailing.IsEmpty && result[^1] is GreenLeaf lastLeaf) + { + var newTrailing = lastLeaf.TrailingTrivia.IsEmpty + ? trailing + : lastLeaf.TrailingTrivia.AddRange(trailing); + result[^1] = lastLeaf.WithTrailingTrivia(newTrailing); + } + + return result.ToImmutable(); + } +} + +internal sealed class ReplaceNodesRegionEdit : PendingEdit +{ + private readonly QueryRegion _region; + private readonly ImmutableArray _nodes; + private readonly ImmutableArray _leadingTrivia; + private readonly ImmutableArray _trailingTrivia; + + public ReplaceNodesRegionEdit(QueryRegion region, ImmutableArray nodes, + ImmutableArray leadingTrivia, + ImmutableArray trailingTrivia) + { + _region = region; + _nodes = nodes; + _leadingTrivia = leadingTrivia.IsDefault ? ImmutableArray.Empty : leadingTrivia; + _trailingTrivia = trailingTrivia.IsDefault ? ImmutableArray.Empty : trailingTrivia; + } + + public override int Position => _region.Position; + + public override NodePath AffectedPath => _region.ParentPath; + + public override GreenNode Apply(GreenNode root, GreenTreeBuilder builder, TokenizerOptions options) + { + var nodes = _nodes; + + if (nodes.Length > 0 && (!_leadingTrivia.IsEmpty || !_trailingTrivia.IsEmpty)) + { + nodes = TransferTrivia(nodes, _leadingTrivia, _trailingTrivia); + } + + return builder.ReplaceAt( + _region.ParentPath.ToArray(), + _region.StartSlot, + _region.SlotCount, + nodes + ); + } + + private static ImmutableArray TransferTrivia( + ImmutableArray nodes, + ImmutableArray leading, + ImmutableArray trailing) + { + if (leading.IsEmpty && trailing.IsEmpty) + return nodes; + + var result = nodes.ToBuilder(); + + if (!leading.IsEmpty && result[0] is GreenLeaf firstLeaf) + { + var newLeading = firstLeaf.LeadingTrivia.IsEmpty + ? leading + : leading.AddRange(firstLeaf.LeadingTrivia); + result[0] = firstLeaf.WithLeadingTrivia(newLeading); + } + + if (!trailing.IsEmpty && result[^1] is GreenLeaf lastLeaf) + { + var newTrailing = lastLeaf.TrailingTrivia.IsEmpty + ? trailing + : lastLeaf.TrailingTrivia.AddRange(trailing); + result[^1] = lastLeaf.WithTrailingTrivia(newTrailing); + } + + return result.ToImmutable(); + } +} + /// /// Contains all information needed to perform an insertion. /// From 1a0e8162e0c83a09a9928e0354a1985b7a6cbe44 Mon Sep 17 00:00:00 2001 From: David Sisco Date: Tue, 6 Jan 2026 18:36:09 -0800 Subject: [PATCH 3/3] docs: update project todo --- project.todo | 371 --------------------------------------------------- 1 file changed, 371 deletions(-) diff --git a/project.todo b/project.todo index e0b3660..e69de29 100644 --- a/project.todo +++ b/project.todo @@ -1,371 +0,0 @@ -# TinyTokenizer Improvement Plan - Project Tasks -# Based on: docs/improvement-plan-2026.md -# Created: January 2, 2026 -# Target Completion: Q2 2026 - -═══════════════════════════════════════════════════════════════════════════════ -PHASE 1: Quick Wins (v0.6.6) -Timeline: 1-2 days | Breaking Changes: None -═══════════════════════════════════════════════════════════════════════════════ - -[x] 1.1 Pre-compute Comment Style Flags (30 min) ✓ DONE - File: TokenParser.cs - [x] Add _hasCSingleLineComment private readonly field - [x] Add _hasCMultiLineComment private readonly field - [x] Initialize fields in constructor from options.CommentStyles - [x] Update TryParseComment to use cached fields instead of LINQ - [x] Verify no LINQ allocation in TryParseComment - [x] Benchmark comment-heavy input to confirm improvement - -[x] 1.2 Add IFormattable to Token (1 hr) ✓ DONE - File: Token.cs - [x] Add IFormattable interface to base Token record - [x] Implement ToString(string?, IFormatProvider?) method - [x] "G" or null → ContentSpan.ToString() - [x] "T" → Type.ToString() - [x] "P" → Position.ToString() - [x] "R" → Range format "{Position}..{Position + Content.Length}" - [x] "D" → Debug format "{Type}[{Range}]" - [x] Add XML documentation for format specifiers - [x] Add unit tests for each format specifier - -[x] 1.3 Fix AppendToBuffer Inefficiency (15 min) ✓ DONE - File: TokenParser.cs - [x] Add EnsureCapacity call before loop - [x] Replace indexed access with foreach over span - [x] Verify single capacity allocation instead of multiple resizes - [x] Run all parsing tests to confirm no regressions - -[x] 1.4 Cache SiblingIndex in RedNode (1 hr) ✓ DONE - File: Ast/RedNode.cs - [x] Add private readonly int _siblingIndex field - [x] Update internal constructor to accept siblingIndex parameter (default -1) - [x] Update GetRedChild to pass slot index when creating red child - [x] Update CreateRed method signature to accept siblingIndex - [x] Change SiblingIndex property to return _siblingIndex directly - [x] Verify O(1) access time - [x] Test NextSibling() and PreviousSibling() still work correctly - -[ ] 1.5 Phase 1 Release - [ ] Run full test suite (dotnet test) - [ ] Run benchmarks and document results - [ ] Update CHANGELOG.md with changes - [ ] Bump version to 0.6.6 in TinyTokenizer.csproj - [ ] Commit and tag as v0.6.6 - [ ] Create GitHub release - [ ] Verify NuGet package published via CI - -═══════════════════════════════════════════════════════════════════════════════ -PHASE 2: Performance (v0.7.0) -Timeline: 1-2 weeks | Breaking Changes: Minor (internal APIs only) -═══════════════════════════════════════════════════════════════════════════════ - -[x] 2.1 Replace List with ArrayPoolBufferWriter (4 hrs) ✓ DONE - Files: TokenParser.cs, TinyTokenizer.csproj - [x] Add NuGet reference: CommunityToolkit.HighPerformance 8.2.2 - [x] Add using CommunityToolkit.HighPerformance.Buffers - [x] Update ParseBlock method - [x] Replace List with ArrayPoolBufferWriter - [x] Use buffer.Write(span) for appending - [x] Add using statement for proper disposal - [x] Update ParseString method - [x] Replace List with ArrayPoolBufferWriter - [x] Ensure disposal in all code paths - [x] Update ParseNumericFromDigits method - [x] Update ParseNumericFromDot method - [x] Update ParseSingleLineComment method - [x] Update ParseMultiLineComment method - [x] Update TryParseOperator method - [x] Update TryParseTaggedIdent method - [x] Remove AppendToBuffer helper method (replaced with WriteToBuffer) - [x] Run all parsing tests to confirm no regressions - -[x] 2.2 Build Operator Trie (6 hrs) ✓ DONE - Files: TokenParser.cs, new OperatorTrie.cs - [x] Create OperatorTrie.cs file - [x] Create internal sealed class OperatorTrie - [x] Create private sealed class TrieNode - [x] Dictionary? Children - [x] string? Operator (non-null if end of operator) - [x] Implement Add(string op) method - [x] Implement TryMatch(ReadOnlySpan) method - [x] Update TokenParser constructor - [x] Add private readonly OperatorTrie _operatorTrie field - [x] Build trie from options.Operators - [x] Update TryParseOperator to use trie lookup - [x] Verify O(k) matching where k = operator length - [x] Verify greedy matching still works (longest match first) - [x] Add unit tests for OperatorTrie - [x] Benchmark with 50+ operators to confirm improvement - -[x] 2.3 Standardize Position Types (2 hrs) ⚠️ BREAKING ✓ DONE - Files: SimpleToken.cs, Token.cs, all derived types - [x] Update SimpleToken.cs - [x] Change Position parameter: long → int - [x] Update Token.cs - [x] Change Position parameter: long → int - [x] Update all derived token types (if any explicit Position usage) - [x] Update Lexer.cs position tracking - [x] Update TokenParser.cs position handling - [x] Search codebase for all long position usages and update - [x] Add XML doc noting 2GB file size practical limit - [x] Update all tests with position assertions - [ ] Document breaking change in CHANGELOG - -[x] 2.4 Unify Operator Matching with OperatorTrie (1 hr) ✓ DONE - Files: Tokenizer.cs, Ast/GreenLexer.cs - [x] Update Tokenizer.cs to use OperatorTrie instead of sorted array - [x] Replace _sortedOperators field with _operatorTrie - [x] Build trie in constructor from options.Operators - [x] Update TryParseOperator to use trie-based O(k) matching - [x] Update GreenLexer.cs to use OperatorTrie - [x] Replace _sortedOperators field with _operatorTrie - [x] Build trie in constructor - [x] Update TryParseOperator to use trie-based matching - [x] Use stackalloc for character buffer (zero allocation) - [x] Keep TokenizerOptions.Operators as ImmutableHashSet (no breaking change) - [x] Verify all tests pass - -[ ] 2.5 Phase 2 Release - [ ] Run full test suite - [ ] Run before/after allocation benchmarks - [ ] Document benchmark results - [ ] Write migration guide for position type change (long → int) - [ ] Update CHANGELOG.md with all changes and breaking changes - [ ] Bump version to 0.7.0 in TinyTokenizer.csproj - [ ] Commit and tag as v0.7.0 - [ ] Create GitHub release with migration notes - [ ] Verify NuGet package published via CI - -═══════════════════════════════════════════════════════════════════════════════ -PHASE 3: API Cleanup (v0.8.0) -Timeline: 2-3 weeks | Breaking Changes: Yes (major cleanup) -═══════════════════════════════════════════════════════════════════════════════ - -[x] 3.1 Consolidate Duplicate Parsing Logic (4 hrs) ✓ DONE - Files: TokenizerCore.cs, TokenParser.cs, Ast/GreenLexer.cs - [x] Extend TokenizerCore.cs with shared helper methods - [x] Add IsOperatorCapableToken(SimpleTokenType) method - [x] Add GetOperatorChar(SimpleTokenType) method - [x] Add GetMatchingCloser(SimpleTokenType) method - [x] Add GetBlockTokenType(SimpleTokenType) method - [x] Add IsOpeningDelimiter(SimpleTokenType) overload - [x] Add IsClosingDelimiter(SimpleTokenType) overload - [x] Refactor TokenParser.cs to use TokenizerCore - [x] Remove duplicate IsOperatorCapableToken - [x] Remove duplicate GetTokenChar (use GetOperatorChar) - [x] Remove duplicate IsOpeningDelimiter/IsClosingDelimiter - [x] Remove duplicate GetMatchingCloser/GetBlockTokenType - [x] Refactor GreenLexer.cs to use TokenizerCore - [x] Remove duplicate GetOperatorChar - [x] Remove duplicate IsOpeningDelimiter/IsClosingDelimiter - [x] Remove duplicate GetMatchingCloser - [x] Verify all 1,165 tests pass - Note: Full parsing logic unification not practical due to different - data flow (char vs SimpleToken streams, different output types). - Focused on sharing classification/mapping helpers instead. - -[x] 3.2 Address Schema Nullability (4 hrs) ✓ DONE - File: Ast/SyntaxTree.cs - [x] Add HasSchema property - [x] public bool HasSchema => Schema != null; - [x] Add private RequireSchema() helper method - [x] Throws InvalidOperationException with clear message if null - [x] Returns Schema if non-null (for use in expressions) - [x] Add WithSchema method - [x] public SyntaxTree WithSchema(Schema schema) - [x] Creates new tree with schema attached - [x] Auto-applies syntax binding if schema has definitions - [x] Throws ArgumentNullException if schema is null - [x] Update all schema-dependent methods to use RequireSchema() - [x] Match(SemanticContext?) - uses RequireSchema() - [x] MatchAll(SemanticContext?) - uses RequireSchema() - [x] Add comprehensive XML documentation - [x] Document when schema is required - [x] Document how to attach schema (WithSchema) - [x] Document error behavior - [x] Add cross-references - [x] Add unit tests (9 new tests) - [x] HasSchema_WithoutSchema_ReturnsFalse - [x] HasSchema_WithSchema_ReturnsTrue - [x] WithSchema_AttachesSchemaToExistingTree - [x] WithSchema_DoesNotModifyOriginalTree - [x] WithSchema_ThrowsOnNull - [x] Match_WithoutSchema_ThrowsInvalidOperationException - [x] MatchAll_WithoutSchema_ThrowsInvalidOperationException - [x] Match_WithExplicitSchema_DoesNotRequireAttachedSchema - [x] MatchAll_WithExplicitSchema_DoesNotRequireAttachedSchema - [x] All 1,174 tests pass - -[x] 3.3 Make Trivia Types Public (3 hrs) ✓ DONE - Files: Ast/GreenTrivia.cs, new Trivia.cs, Ast/RedLeaf.cs, Ast/RedBlock.cs - [x] Create Trivia.cs file - [x] Create public readonly struct Trivia - [x] internal Trivia(GreenTrivia green) constructor - [x] TriviaKind Kind property - [x] string Text property - [x] int Width property - [x] bool IsWhitespace, IsNewline, IsComment helper properties - [x] IEquatable implementation - [x] override ToString() - [x] TriviaKind enum already public (in GreenTrivia.cs) - [x] Update RedLeaf.cs - [x] Add IEnumerable GetLeadingTrivia() method - [x] Add IEnumerable GetTrailingTrivia() method - [x] Add bool HasLeadingTrivia property - [x] Add bool HasTrailingTrivia property - [x] Update RedBlock.cs - [x] Add IEnumerable GetLeadingTrivia() method - [x] Add IEnumerable GetInnerTrivia() method - [x] Add IEnumerable GetTrailingTrivia() method - [x] Add bool HasLeadingTrivia, HasInnerTrivia, HasTrailingTrivia properties - [x] Add XML documentation to all new types - [x] Add unit tests (21 new tests in TriviaTests.cs) - [x] All 1,195 tests pass - -[x] 3.4 Comprehensive Documentation Pass (4 hrs) ✓ VERIFIED COMPLETE - Files: All public API files - Note: Documentation review shows codebase already has comprehensive XML docs: - [x] Query.cs - Has , , tags on all methods - [x] TreeWalker.cs - Has for NodeFilter, FilterResult, all methods - [x] NodePattern.cs - Has and (marked obsolete with guidance) - [x] SyntaxBinder.cs - Has and on class and methods - [x] Schema.cs - Has for all properties, SchemaBuilder documented - [x] SemanticNode.cs - Has for base class and all built-in nodes - [x] NodeQuery.cs/NodeQueryTypes.cs - Has full documentation - [x] QueryCombinators.cs/QueryExtensions.cs - Has and - [x] SemanticContext.cs - Has for all members - [x] SemanticNodeDefinition.cs - Has full interface/class documentation - [x] RedSyntaxNode.cs - Has code and - [x] Trivia.cs - Has comprehensive , , - Template used consistently: , , , - -[x] 3.5 Remove Obsolete APIs (30 min) ⚠️ BREAKING ✓ DONE - File: Ast/SyntaxTree.cs - [x] Remove [Obsolete] attribute from ToFullString() - [x] Remove ToFullString() method entirely - [x] Search for and update any internal usages (none found) - [x] All 1,195 tests pass - [ ] Document removal in CHANGELOG (at release time) - -[ ] 3.6 Phase 3 Release - [ ] Run full test suite - [ ] Verify API documentation is complete - [ ] Write migration guide for removed/changed APIs - [ ] Update CHANGELOG.md with all changes and breaking changes - [ ] Bump version to 0.8.0 in TinyTokenizer.csproj - [ ] Commit and tag as v0.8.0 - [ ] Create GitHub release with migration notes - [ ] Verify NuGet package published via CI - -═══════════════════════════════════════════════════════════════════════════════ -PHASE 4: Testing (Ongoing) -Timeline: Continuous -═══════════════════════════════════════════════════════════════════════════════ - -[ ] 4.1 Error Recovery Test Suite (4 hrs) - File: new TinyTokenizer.Tests/ErrorRecoveryTests.cs - [x] Create ErrorRecoveryTests.cs file - [x] Unclosed block tests - [x] Test "{" produces ErrorToken - [x] Test "[" produces ErrorToken - [x] Test "(" produces ErrorToken - [x] Test "{ { }" (nested unclosed) produces ErrorToken - [x] Mismatched delimiter tests - [x] Test "{]" produces ErrorToken - [x] Test "[)" produces ErrorToken - [x] Test "(}" produces ErrorToken - [x] Unclosed string tests - [x] Test "\"hello" produces ErrorToken or Symbol - [x] Test "'world" produces ErrorToken or Symbol - [x] Test "\"test\nmore\"" handles newline in string - [x] Recovery tests - [x] Test tokenizer continues after error token - -[ ] 4.2 Unicode Test Suite (3 hrs) - File: new TinyTokenizer.Tests/UnicodeTests.cs - [x] Create UnicodeTests.cs file - [x] Unicode identifier tests - [x] Test "変数" (Japanese) recognized as identifier - [x] Test "переменная" (Cyrillic) recognized as identifier - [x] Test "משתנה" (Hebrew RTL) recognized as identifier - [x] Emoji tests - [x] Test "🚀rocket" (emoji prefix) handled correctly - [x] Test "var_🎉" (emoji suffix) handled correctly - [x] Invisible character tests - [x] Test "\u200B" (zero-width space) handled - [x] Test "\uFEFF" (BOM) handled - -[x] 4.3 Numeric Edge Cases Tests (2 hrs) ✓ DONE - File: TinyTokenizer.Tests/LexerParserTests.cs (extend existing) - [x] Add numeric edge case theory tests - [x] Test "0" → Integer - [x] Test "0.0" → FloatingPoint - [x] Test ".0" → FloatingPoint - [x] Test "0." → Integer + Dot (trailing dot separate) - [x] Test "00123" → Integer (leading zeros) - [x] Test "1.2.3" → 1.2 FloatingPoint + .3 FloatingPoint (greedy parsing) - -[x] 4.4 Thread Safety Tests (3 hrs) ✓ DONE - File: new TinyTokenizer.Tests/ConcurrencyTests.cs - [x] Create ConcurrencyTests.cs file - [x] Concurrent RedNode access test - [x] Parse a tree once - [x] Spawn 100 tasks accessing Root.Children - [x] Each task accesses SiblingIndex, NextSibling() - [x] Verify no exceptions thrown - [x] Concurrent tree parsing test - [x] Spawn 100 tasks each parsing different input - [x] Await all tasks - [x] Verify all trees have valid Root - -[x] 4.5 Performance Benchmarks (4 hrs) ✓ DONE - File: TinyTokenizer.Benchmarks/ - [x] Create AllocationBenchmarks.cs - [x] Add [MemoryDiagnoser] attribute - [x] Setup small input (~1 KB) - [x] Setup medium input (~100 KB) - [x] Setup large input (~1 MB) - [x] Add ParseSmall benchmark - [x] Add ParseMedium benchmark - [x] Add ParseLarge benchmark - [x] Create OperatorMatchingBenchmarks.cs - [x] Add [MemoryDiagnoser] attribute - [x] Add [Params(10, 50, 100)] OperatorCount - [x] Add MatchOperators benchmark - [x] Create SyntaxTreeBenchmarks.cs - [x] Tree parsing benchmarks (small/medium/large/nested) - [x] Schema integration benchmarks - [x] Red node creation and traversal benchmarks - [x] TreeWalker benchmarks - [x] Query/filter benchmarks - [x] Sibling navigation benchmarks - [x] Edit/mutation benchmarks - -═══════════════════════════════════════════════════════════════════════════════ -DEPENDENCIES -═══════════════════════════════════════════════════════════════════════════════ - -[x] Add CommunityToolkit.HighPerformance 8.2.2 (Phase 2.1) ✓ DONE - Purpose: ArrayPoolBufferWriter for zero-allocation parsing - -═══════════════════════════════════════════════════════════════════════════════ -SUCCESS METRICS -═══════════════════════════════════════════════════════════════════════════════ - -[ ] Allocation reduction: >50% fewer allocations in parsing benchmarks -[ ] API consistency: All public types implement IFormattable where applicable -[ ] Test coverage: >85% line coverage on core parsing logic -[ ] Documentation: 100% XML doc coverage on public APIs -[ ] Performance: No regression in existing benchmarks - -═══════════════════════════════════════════════════════════════════════════════ -RISK ASSESSMENT -═══════════════════════════════════════════════════════════════════════════════ - -Risk | Likelihood | Impact | Mitigation -------------------------------|------------|--------|-------------------------------- -Breaking change regression | Medium | High | Comprehensive test suite -Performance regression | Low | Medium | Benchmark before/after each phase -Memory leak from pooling | Low | High | Memory profiler, dispose patterns -Thread safety issues | Medium | High | Dedicated concurrency tests