diff --git a/TinyTokenizer.E2ETests/GlslEditorTests.cs b/TinyTokenizer.E2ETests/GlslEditorTests.cs index fddd790..969023e 100644 --- a/TinyTokenizer.E2ETests/GlslEditorTests.cs +++ b/TinyTokenizer.E2ETests/GlslEditorTests.cs @@ -193,6 +193,23 @@ public void DumpTokenTree() _output.WriteLine(dump); Assert.NotNull(dump); } + + [Fact] + public void Editor_Between_MethodBodyStartEnd_Replace_WithNonDelimitedText() + { + var schema = CreateGlslSchema(); + var tree = SyntaxTree.Parse("void main() { return 0.0; }", schema); + + var editor = tree.CreateEditor(); + + var functionName = "main"; + var methodBody = Query.Syntax().Named(functionName).Block("body"); + var bodyContents = Query.Between(methodBody.Start(), methodBody.End()); + + editor.Replace(bodyContents, "\nreturn 1.0;"); + + editor.Commit(); + } [Fact] public void Parse_RecognizesGlslFunctions() diff --git a/TinyTokenizer.Tests/QueryCombinatorTests.cs b/TinyTokenizer.Tests/QueryCombinatorTests.cs index 02f2992..23117ad 100644 --- a/TinyTokenizer.Tests/QueryCombinatorTests.cs +++ b/TinyTokenizer.Tests/QueryCombinatorTests.cs @@ -1294,21 +1294,34 @@ public void BOF_MatchesOnlyAtRootLevel() #region Edge Case Tests - Between [Fact] - public void Between_Exclusive_DoesNotCountDelimiters() + public void Between_Default_IsExclusive_ForEditorRegions() { - var tree = Parse("< a b c >"); - var root = tree.Root; - var children = root.Children.ToList(); - var openAngle = children.First(c => c.ToText().Trim() == "<"); - - var queryInclusive = Query.Between(Query.Operator("<"), Query.Operator(">"), inclusive: true); - var queryExclusive = Query.Between(Query.Operator("<"), Query.Operator(">"), inclusive: false); - - Assert.True(queryInclusive.TryMatch(openAngle, out var consumedInclusive)); - Assert.True(queryExclusive.TryMatch(openAngle, out var consumedExclusive)); - - // Exclusive should report fewer consumed (just the content, not delimiters) - Assert.True(consumedExclusive < consumedInclusive); + var tree = Parse("before < a b c > after"); + + // Default Between should replace ONLY the content between delimiters. + tree.CreateEditor() + .Replace(Query.Between(Query.Operator("<"), Query.Operator(">")), " CONTENT ") + .Commit(); + + var text = tree.ToText(); + Assert.Contains("<", text); + Assert.Contains(">", text); + Assert.Matches(@"<\s*CONTENT\s*>", text); + } + + [Fact] + public void Between_Inclusive_ReplacesDelimitersToo() + { + var tree = Parse("before < a b c > after"); + + tree.CreateEditor() + .Replace(Query.Between(Query.Operator("<"), Query.Operator(">"), inclusive: true), " CONTENT ") + .Commit(); + + var text = tree.ToText(); + Assert.DoesNotContain("<", text); + Assert.DoesNotContain(">", text); + Assert.Contains("CONTENT", text); } [Fact] diff --git a/TinyTokenizer.Tests/SyntaxEditorTests.cs b/TinyTokenizer.Tests/SyntaxEditorTests.cs index 9a23d9b..4a0a41a 100644 --- a/TinyTokenizer.Tests/SyntaxEditorTests.cs +++ b/TinyTokenizer.Tests/SyntaxEditorTests.cs @@ -935,7 +935,7 @@ public void NamedBlockInner_Replace_EmptyBlock_InsertsContent() Assert.Equal("f{inserted}", tree.ToText()); } - + #endregion #region Function-Like Block Insertion Scenarios @@ -3006,7 +3006,7 @@ public void Replace_QueryBetween_ReplacesEntireRange() // Act: replace everything from 'a' to 'c' (inclusive) tree.CreateEditor() - .Replace(Q.Between(Q.Ident("a"), Q.Ident("c")), "REPLACED") + .Replace(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true), "REPLACED") .Commit(); // Assert: the range [a, b, c] should be replaced with REPLACED @@ -3022,7 +3022,7 @@ public void Remove_QueryBetween_RemovesEntireRange() var tree = SyntaxTree.Parse("start a b c end"); tree.CreateEditor() - .Remove(Q.Between(Q.Ident("a"), Q.Ident("c"))) + .Remove(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true)) .Commit(); // Trivia handling: leading trivia of 'a' (space) is removed with 'a', @@ -3039,7 +3039,7 @@ public void InsertBefore_QueryBetween_InsertsBeforeRange() var tree = SyntaxTree.Parse("x a b c y"); tree.CreateEditor() - .InsertBefore(Q.Between(Q.Ident("a"), Q.Ident("c")), "BEFORE ") + .InsertBefore(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true), "BEFORE ") .Commit(); Assert.Equal("x BEFORE a b c y", tree.ToText()); @@ -3061,7 +3061,7 @@ public void InsertAfter_QueryBetween_InsertsAfterRange() // - Insert "AFTER " (with trailing space for separation from y) // - y has no trivia -> "y" tree.CreateEditor() - .InsertAfter(Q.Between(Q.Ident("a"), Q.Ident("c")), "AFTER ") + .InsertAfter(Q.Between(Q.Ident("a"), Q.Ident("c"), inclusive: true), "AFTER ") .Commit(); Assert.Equal("x a b c AFTER y", tree.ToText()); @@ -3105,7 +3105,7 @@ public void Edit_QueryBetween_TransformsRangeContent() var tree = SyntaxTree.Parse("x abc def ghi y"); tree.CreateEditor() - .Edit(Q.Between(Q.Ident("abc"), Q.Ident("ghi")), content => content.ToUpper()) + .Edit(Q.Between(Q.Ident("abc"), Q.Ident("ghi"), inclusive: true), content => content.ToUpper()) .Commit(); // The content between abc and ghi (inclusive) should be uppercased @@ -3130,4 +3130,37 @@ public void Replace_QuerySequence_ReplacesAllMatchedNodes() } #endregion + + #region Query.Wrap / Query.Inner (BlockNode) + + [Fact] + public void Replace_QueryWrapBlock_Inner_ReplacesOnlyThatBlock() + { + var tree = SyntaxTree.Parse("{a}{b}"); + var blocks = tree.Select(Query.BraceBlock).OfType().ToList(); + Assert.Equal(2, blocks.Count); + + var second = blocks[1]; + + tree.CreateEditor() + .Replace(Query.Wrap(second).Inner(), "c") + .Commit(); + + Assert.Equal("{a}{c}", tree.ToText()); + } + + [Fact] + public void Replace_QueryInnerBlock_ReplacesOnlyThatBlock() + { + var tree = SyntaxTree.Parse("{a}{b}"); + var second = tree.Select(Query.BraceBlock).OfType().Skip(1).First(); + + tree.CreateEditor() + .Replace(Query.Inner(second), "c") + .Commit(); + + Assert.Equal("{a}{c}", tree.ToText()); + } + + #endregion } diff --git a/TinyTokenizer/Ast/Query.cs b/TinyTokenizer/Ast/Query.cs index 56e88e3..b9bee3d 100644 --- a/TinyTokenizer/Ast/Query.cs +++ b/TinyTokenizer/Ast/Query.cs @@ -189,11 +189,11 @@ public static class Query /// /// Creates a query that matches content between a start and end pattern. - /// Consumes all nodes from start through end (inclusive). + /// By default, the matched region excludes the start/end delimiters. /// /// The starting delimiter/pattern. /// The ending delimiter/pattern. - /// If true (default), includes start/end in consumed count. + /// If true, includes start/end delimiters in the matched region. /// A query matching the content between start and end. /// /// @@ -201,7 +201,7 @@ public static class Query /// Query.Between(Query.Symbol("("), Query.Symbol(")")) /// /// - public static BetweenQuery Between(INodeQuery start, INodeQuery end, bool inclusive = true) => + public static BetweenQuery Between(INodeQuery start, INodeQuery end, bool inclusive = false) => new BetweenQuery(start, end, inclusive); #endregion @@ -309,6 +309,51 @@ public static BetweenQuery Between(INodeQuery start, INodeQuery end, bool inclus /// public static ExactNodeQuery Exact(SyntaxNode node) => new ExactNodeQuery(node); + #endregion + + #region Wrap / Inner (Node-based) + + /// + /// Wraps an existing instance as a . + /// This is useful when you already have a block node and want to use block-specific query helpers + /// like , , and . + /// + /// + /// Like , this query is intended for immediate use within the current tree state. + /// Red nodes are recreated on tree mutations. + /// + public static BlockNodeQuery Wrap(SyntaxBlock block) + { + ArgumentNullException.ThrowIfNull(block); + + // Match the block by red-node equality (same green node + position). + // Also constrain by opener for fast rejection. + return new BlockNodeQuery(block.Opener).Where(n => n == block); + } + + /// + /// Wraps an existing node instance as a query. + /// If the node is a , returns a ; + /// otherwise returns an . + /// + /// + /// Like , this matches the specific node instance and is intended for immediate use. + /// + public static INodeQuery Wrap(SyntaxNode node) + { + ArgumentNullException.ThrowIfNull(node); + return node is SyntaxBlock block ? Wrap(block) : Exact(node); + } + + /// + /// Creates a query selecting the inner content region of an existing block node. + /// Equivalent to Query.Wrap(block).Inner(). + /// + /// + /// This query matches the current node instance and is intended for immediate use. + /// + public static InnerContentQuery Inner(SyntaxBlock block) => Wrap(block).Inner(); + #endregion #region Keyword Queries diff --git a/TinyTokenizer/Ast/QueryCombinators.cs b/TinyTokenizer/Ast/QueryCombinators.cs index 6744a35..097cb33 100644 --- a/TinyTokenizer/Ast/QueryCombinators.cs +++ b/TinyTokenizer/Ast/QueryCombinators.cs @@ -80,7 +80,8 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI /// /// Matches and captures content between a start and end query/delimiter. -/// Consumes all nodes from start through end (inclusive of delimiters). +/// Matches a contiguous region between a start and end pattern. +/// By default, the matched region excludes the start/end delimiters. /// /// /// Useful for extracting content between matching patterns: @@ -95,8 +96,8 @@ public sealed record BetweenQuery : INodeQuery, IGreenNodeQuery, IRegionQuery, I /// Creates a query matching content between start and end. /// The starting delimiter/pattern. /// The ending delimiter/pattern. - /// If true, includes start/end in consumed count; if false, only content between. - public BetweenQuery(INodeQuery start, INodeQuery end, bool inclusive = true) + /// If true, includes the start/end delimiters in the matched region. + public BetweenQuery(INodeQuery start, INodeQuery end, bool inclusive = false) { _start = start; _end = end; @@ -156,7 +157,11 @@ public bool TryMatch(SyntaxNode startNode, out int consumedCount) if (_end.TryMatch(current, out var endConsumed)) { totalConsumed += endConsumed; - consumedCount = _inclusive ? totalConsumed : totalConsumed - startConsumed - endConsumed; + // Always report the full span consumed (start..end, inclusive) so this query + // is safe to use in sequences and other sibling-consuming contexts. + // The inclusive/exclusive behavior is applied when translating this query + // into edit regions via IRegionQuery. + consumedCount = totalConsumed; return true; } @@ -196,7 +201,9 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI if (endGreen.TryMatchGreen(siblings, currentIndex, out var endConsumed)) { totalConsumed += endConsumed; - consumedCount = _inclusive ? totalConsumed : totalConsumed - startConsumed - endConsumed; + // Always report the full span consumed (start..end, inclusive). + // Inclusive/exclusive is handled at the region-translation layer. + consumedCount = totalConsumed; return true; } @@ -209,13 +216,69 @@ bool IGreenNodeQuery.TryMatchGreen(IReadOnlyList siblings, int startI } /// - IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) + IEnumerable IRegionQuery.SelectRegions(SyntaxTree tree) => ((IRegionQuery)this).SelectRegions(tree.Root); - - /// + IEnumerable IRegionQuery.SelectRegions(SyntaxNode root) { - return RegionTraversal.SelectRegions(root, TryMatch); + return RegionTraversal.SelectRegions(root, TryGetBetweenRegion); + + bool TryGetBetweenRegion( + SyntaxNode startNode, + out int startSlotOffset, + out int slotCount, + out SyntaxNode? firstNode, + out int position) + { + startSlotOffset = 0; + slotCount = 0; + firstNode = null; + position = 0; + + if (!_start.TryMatch(startNode, out var startConsumed)) + return false; + + // Navigate to the first node AFTER the start match. + var afterStart = startNode; + for (int i = 0; i < startConsumed && afterStart != null; i++) + afterStart = afterStart.NextSibling(); + + if (afterStart == null) + return false; + + // Scan for end starting at afterStart. + var current = afterStart; + int totalConsumed = startConsumed; + + while (current != null) + { + if (_end.TryMatch(current, out var endConsumed)) + { + totalConsumed += endConsumed; + + if (_inclusive) + { + startSlotOffset = 0; + slotCount = totalConsumed; + firstNode = startNode; + position = startNode.Position; + return true; + } + + // Exclusive: region starts after the start match and ends before the end delimiter. + startSlotOffset = startConsumed; + slotCount = totalConsumed - startConsumed - endConsumed; + position = afterStart.Position; + firstNode = slotCount > 0 ? afterStart : null; + return true; + } + + totalConsumed++; + current = current.NextSibling(); + } + + return false; + } } } diff --git a/TinyTokenizer/Ast/QueryExtensions.cs b/TinyTokenizer/Ast/QueryExtensions.cs index 73da281..057e54f 100644 --- a/TinyTokenizer/Ast/QueryExtensions.cs +++ b/TinyTokenizer/Ast/QueryExtensions.cs @@ -119,9 +119,9 @@ public static NoneOfQuery ExceptFor(this INodeQuery query, params INodeQuery[] o /// /// Creates a query matching content between this query (start) and the end query. - /// Consumes all nodes from start through end (inclusive). + /// By default, the matched region excludes the start/end delimiters. /// - public static BetweenQuery Between(this INodeQuery start, INodeQuery end, bool inclusive = true) => + public static BetweenQuery Between(this INodeQuery start, INodeQuery end, bool inclusive = false) => new(start, end, inclusive); #endregion diff --git a/TinyTokenizer/Ast/QueryRegion.cs b/TinyTokenizer/Ast/QueryRegion.cs index 616b2d2..469bb52 100644 --- a/TinyTokenizer/Ast/QueryRegion.cs +++ b/TinyTokenizer/Ast/QueryRegion.cs @@ -141,6 +141,13 @@ internal static class RegionTraversal { internal delegate bool TryGetRegionDelegate(SyntaxNode node, out int consumedCount); + internal delegate bool TryGetOffsetRegionDelegate( + SyntaxNode node, + out int startSlotOffset, + out int slotCount, + out SyntaxNode? firstNode, + out int position); + internal static IEnumerable SelectRegions(SyntaxNode root, TryGetRegionDelegate tryGetRegion) { ArgumentNullException.ThrowIfNull(tryGetRegion); @@ -172,6 +179,49 @@ internal static IEnumerable SelectRegions(SyntaxNode root, TryGetRe } } + internal static IEnumerable SelectRegions(SyntaxNode root, TryGetOffsetRegionDelegate tryGetRegion) + { + ArgumentNullException.ThrowIfNull(tryGetRegion); + + var pathStack = new List(8); + var current = root; + + while (true) + { + if (current.Parent != null && + tryGetRegion(current, out var startSlotOffset, out var slotCount, out var firstNode, out var position)) + { + var parent = current.Parent; + var startSlot = current.SiblingIndex; + + if (startSlot >= 0) + { + var regionStart = startSlot + startSlotOffset; + if (regionStart < 0) + regionStart = 0; + + if (slotCount < 0) + slotCount = 0; + + yield return new QueryRegion( + parentPath: CreateParentPath(pathStack), + parent: parent, + startSlot: regionStart, + endSlot: regionStart + slotCount, + firstNode: firstNode, + position: position + ); + } + } + + if (TryMoveToFirstChild(ref current, pathStack)) + continue; + + if (!TryMoveToNextSiblingOrAncestor(ref current, pathStack)) + break; + } + } + private static NodePath CreateParentPath(List pathStack) { // pathStack is the path to the CURRENT node; parent path is pathStack without the last element.