Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e8b1d68
docs: add task list
dsisco11 Jan 11, 2026
3c4a69d
[phase 0]: Baseline + Safety Nets
dsisco11 Jan 11, 2026
d1e7785
docs: add sidequest task list
dsisco11 Jan 11, 2026
f61b1b4
[phase 0]: Baseline + Guardrails
dsisco11 Jan 11, 2026
ed46aa0
[phase 1]: Define Flag Semantics
dsisco11 Jan 11, 2026
e947bfa
[phase 2]: Fix Boundary Flag Propagation in Container Nodes
dsisco11 Jan 11, 2026
2f51de0
[phase 3]: Make Flags a Field
dsisco11 Jan 11, 2026
03797ec
[phase 4]: Update Newline Query to Use Flags First
dsisco11 Jan 11, 2026
c484681
[phase 5]: Validate
dsisco11 Jan 11, 2026
add76fe
[phase 5]: Perf tests
dsisco11 Jan 11, 2026
3473cae
chore: remove completed task list
dsisco11 Jan 11, 2026
988bbed
chore: add new phase
dsisco11 Jan 11, 2026
9f15304
[phase 1b]: SyntaxEditor Flag Mutation Tests
dsisco11 Jan 11, 2026
1db3891
[phase 2]: Remove LINQ from Selection Modes
dsisco11 Jan 11, 2026
eaa1e62
[phase 3]: Replace LINQ in the Hottest `Select(...)` Implementations
dsisco11 Jan 11, 2026
cc8921b
[phase 4]: Replace LINQ in the Hottest Combinators
dsisco11 Jan 11, 2026
46d0d14
[phase 5]: Optimize Region Resolution
dsisco11 Jan 11, 2026
dd562ea
[phase 6]: Documentation Sanity
dsisco11 Jan 11, 2026
119671a
chore: update task list
dsisco11 Jan 11, 2026
055242c
chore: unrestrict benchmarks
dsisco11 Jan 11, 2026
8c1841a
chore: remove completed task list
dsisco11 Jan 11, 2026
043925d
chore: add new task list for test coverage
dsisco11 Jan 11, 2026
b874c34
[phase 0]: Inventory + Harness
dsisco11 Jan 11, 2026
aba5043
[phase 1]: Boundary Flags After Replace
dsisco11 Jan 11, 2026
968685e
[phase 2]: Boundary Flags After InsertBefore / InsertAfter
dsisco11 Jan 11, 2026
56c12e7
[phase 3]: Contains Flags Correctness Under Partial Removal
dsisco11 Jan 11, 2026
007154a
[phase 4]: Multi-node Replacement Trivia Transfer
dsisco11 Jan 11, 2026
db19b25
[phase 5]: Undo/Redo Leaf-Level Flag Restoration
dsisco11 Jan 11, 2026
9937825
[phase 6]: Schema/Rebind Interaction
dsisco11 Jan 11, 2026
17451c8
[phase 7]: Oracle-Style Regression Test
dsisco11 Jan 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions TinyTokenizer.Benchmarks/NewlineQueryBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using TinyTokenizer.Ast;
using Q = TinyTokenizer.Ast.Query;

namespace TinyTokenizer.Benchmarks;

/// <summary>
/// Benchmarks for token-centric newline detection via Query.Newline.
/// Measures the cost of scanning a newline-heavy tree and matching nodes
/// that follow a newline (current leading newline OR previous sibling trailing newline).
/// </summary>
[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class NewlineQueryBenchmarks
{
[Params(1_000, 10_000)]
public int Lines { get; set; }

private SyntaxTree _tree = null!;

[GlobalSetup]
public void Setup()
{
var source = GenerateNewlineHeavyInput(Lines);
_tree = SyntaxTree.Parse(source);
}

[Benchmark(Description = "Select(Query.Newline) - Count")]
[BenchmarkCategory("Query", "Newline")]
public int SelectNewline_Count()
{
int count = 0;
foreach (var _ in _tree.Select(Q.Newline))
count++;
return count;
}

[Benchmark(Description = "Select(Query.Newline.First()) - First match")]
[BenchmarkCategory("Query", "Newline")]
public int SelectNewline_First()
{
foreach (var node in _tree.Select(Q.Newline.First()))
return node.Position;
return -1;
}

private static string GenerateNewlineHeavyInput(int lines)
{
// Intentionally mixes: trailing-newline ownership (end-of-line) and
// leading-newline ownership (own-line comments become leading trivia).
var builder = new StringBuilder(capacity: lines * 32);

for (int i = 0; i < lines; i++)
{
builder.Append("x");
builder.Append(i);
builder.Append(" = ");
builder.Append(i);
builder.Append(";\n");

if ((i & 7) == 0)
{
builder.Append("// comment\n");
}
}

return builder.ToString();
}
}
10 changes: 5 additions & 5 deletions TinyTokenizer.Tests/GreenNodeFlagsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,16 @@ public void GreenBlock_Flags_AggregateContains_AndUseOpenerLeadingPlusCloserTrai
}

[Fact]
public void GreenList_Flags_UseFirstLeadingAndLastTrailingAsBoundary()
public void GreenList_Flags_DoNotPropagateBoundaryFlags_FromChildren()
{
var first = new GreenLeaf(NodeKind.Ident, "a", leadingTrivia: ImmutableArray.Create(GreenTrivia.Whitespace(" ")));
var middle = new GreenLeaf(NodeKind.Ident, "b", trailingTrivia: ImmutableArray.Create(GreenTrivia.SingleLineComment("// c")));
var last = new GreenLeaf(NodeKind.Ident, "c", trailingTrivia: ImmutableArray.Create(GreenTrivia.Newline("\n")));

var list = new GreenList(ImmutableArray.Create<GreenNode>(first, middle, last));

AssertHas(list.Flags, GreenNodeFlags.HasLeadingWhitespaceTrivia);
AssertHas(list.Flags, GreenNodeFlags.HasTrailingNewlineTrivia);
// Token-centric boundary semantics: lists do not own boundary trivia.
AssertNotHas(list.Flags, GreenNodeFlagMasks.Boundary);

AssertHas(list.Flags, GreenNodeFlags.ContainsWhitespaceTrivia);
AssertHas(list.Flags, GreenNodeFlags.ContainsCommentTrivia);
Expand All @@ -165,8 +165,8 @@ public void GreenSyntaxNode_Flags_AggregateLikeList()

var node = new GreenSyntaxNode(kind, first, last);

AssertHas(node.Flags, GreenNodeFlags.HasLeadingWhitespaceTrivia);
AssertHas(node.Flags, GreenNodeFlags.HasTrailingNewlineTrivia);
// Token-centric boundary semantics: syntax containers do not own boundary trivia.
AssertNotHas(node.Flags, GreenNodeFlagMasks.Boundary);
AssertHas(node.Flags, GreenNodeFlags.ContainsWhitespaceTrivia);
AssertHas(node.Flags, GreenNodeFlags.ContainsNewlineTrivia);
}
Expand Down
121 changes: 121 additions & 0 deletions TinyTokenizer.Tests/NewlineQueryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.Linq;
using TinyTokenizer.Ast;
using Xunit;

namespace TinyTokenizer.Tests;

[Trait("Category", "Query")]
public sealed class NewlineQueryTests
{
private static Schema CreateSyntaxBindingSchema()
{
return Schema.Create()
.DefineSyntax(Syntax.Define<FunctionCallSyntax>("FunctionCall")
.Match(Query.AnyIdent, Query.ParenBlock)
.Build())
.Build();
}

[Fact]
public void Newline_MatchesNodeWithLeadingTriviaNewline_TopLevelFirstSibling()
{
var tree = SyntaxTree.Parse("\nfoo");
var foo = tree.Root.Children.OfType<SyntaxToken>().First(n => n.Kind == NodeKind.Ident);

Assert.True(Query.Newline.Matches(foo));
Assert.False(Query.NotNewline.Matches(foo));
}

[Fact]
public void Newline_MatchesNodeAfterNewlineViaPreviousSiblingTrailingTrivia_TopLevel()
{
var tree = SyntaxTree.Parse("x\ny");
var idents = tree.Root.Children.OfType<SyntaxToken>().Where(n => n.Kind == NodeKind.Ident).ToList();

Assert.Equal(2, idents.Count);
Assert.False(Query.Newline.Matches(idents[0]));
Assert.True(Query.Newline.Matches(idents[1]));
}

[Fact]
public void Newline_MatchesFirstInnerNodeAfterOpenerViaPreviousSiblingTrailingTrivia_InBlock()
{
var tree = SyntaxTree.Parse("{\na}");
var block = tree.Root.Children.OfType<SyntaxBlock>().Single();
var a = block.InnerChildren.OfType<SyntaxToken>().Single(n => n.Kind == NodeKind.Ident);

Assert.True(Query.Newline.Matches(a));
Assert.False(Query.NotNewline.Matches(a));
}

[Fact]
public void Newline_MatchesInnerNodeAfterNewlineBetweenSiblings_InBlock()
{
var tree = SyntaxTree.Parse("{a\nb}");
var block = tree.Root.Children.OfType<SyntaxBlock>().Single();
var idents = block.InnerChildren.OfType<SyntaxToken>().Where(n => n.Kind == NodeKind.Ident).ToList();

Assert.Equal(2, idents.Count);
Assert.False(Query.Newline.Matches(idents[0]));
Assert.True(Query.Newline.Matches(idents[1]));
}

[Fact]
public void NotNewline_IsExactNegationOfNewline_ForIdentifiersInSameTree()
{
var tree = SyntaxTree.Parse("a b\nc\n\nd");

var allIdents = tree.Select(Query.AnyIdent).ToList();
var newlineIdents = tree.Select(Query.AnyIdent & Query.Newline).ToList();
var notNewlineIdents = tree.Select(Query.AnyIdent & Query.NotNewline).ToList();

Assert.All(allIdents, n => Assert.True(newlineIdents.Contains(n) ^ notNewlineIdents.Contains(n)));
Assert.Empty(newlineIdents.Intersect(notNewlineIdents));
Assert.Equal(allIdents.Count, newlineIdents.Count + notNewlineIdents.Count);
}

[Fact]
public void Newline_DoesNotThrow_OnEmptyBlockOrEmptyTree()
{
var emptyTree = SyntaxTree.Parse(string.Empty);
Assert.Empty(emptyTree.Select(Query.Newline));

var emptyBlockTree = SyntaxTree.Parse("{}");
Assert.Empty(emptyBlockTree.Select(Query.Newline));
}

[Fact]
public void Newline_MatchesCloserAfterNewlineInEmptyBlock()
{
var tree = SyntaxTree.Parse("{\n}");
var block = tree.Root.Children.OfType<SyntaxBlock>().Single();

Assert.True(Query.Newline.Matches(block.CloserNode));
Assert.False(Query.Newline.Matches(block.OpenerNode));
}

[Fact]
public void Newline_DoesNotMatchRootContainerNode()
{
var tree = SyntaxTree.Parse("\nfoo");

Assert.False(Query.Newline.Matches(tree.Root));
Assert.DoesNotContain(tree.Root, tree.Select(Query.Newline));
}

[Fact]
public void Newline_DoesNotMatchBoundSyntaxContainerNode()
{
var schema = CreateSyntaxBindingSchema();
var tree = SyntaxTree.Parse("\nfoo()", schema);

var funcCall = tree.Root.Children.OfType<FunctionCallSyntax>().First();

// Token-centric newline: matches tokens, not the bound syntax container.
Assert.False(Query.Newline.Matches(funcCall));
Assert.DoesNotContain(funcCall, tree.Select(Query.Newline));

// Still matches the first token after newline.
Assert.True(Query.Newline.Matches(funcCall.NameNode));
}
}
Loading
Loading