Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add [GreenTreeBuilder::revert] to support backtracking parsers #67

Closed
wants to merge 1 commit into from

Conversation

jyn514
Copy link
Contributor

@jyn514 jyn514 commented Oct 18, 2024

Rowan, and hence CSTree, is designed around hand-written parsers. In particular, the APIs for building trees require that each token is recorded only once.

Some parsers, and especially parser combinators, use backtracking instead, where the same token may be seen multiple times. To support this, add a new revert function which discards all tokens seen since the last checkpoint.

see https://rust-lang.zulipchat.com/#narrow/channel/185405-t-compiler.2Frust-analyzer/topic/rewinding.20and.20rowan.3F for more context; i made this PR to CSTree instead of rowan because it actually has a test suite.

Rowan, and hence CSTree, is designed around hand-written parsers.
In particular, the APIs for *building* trees require that each token is recorded only once.

Some parsers, and especially parser combinators, use backtracking instead, where the same token may be seen multiple times.
To support this, add a new `revert` function which discards all tokens seen since the last checkpoint.
@domenicquirl
Copy link
Owner

Hey, thanks for taking the time to PR this. I definitely think that this is a useful thing to add!

While going through the new code, and in particular your (much appreciated) comments, it made me notice that checkpoints don't actually work across node boundaries. This is something that the doc example (which is originally from rowan) suggests you can do, but apparently no one actually needed this and tried to do it - or at least they didn't bother to report it.

I've pushed a slightly different version at https://github.com/domenicquirl/cstree/tree/checkpoint-across-nodes which extends the Checkpoint to track both the parent and the child index. This should allow the checkpoint to work across and wrap nodes (if they are finished before making use of the checkpoint), and also to revert them, for which I added some tests. Your tests all still pass, except that the misuse one now only panics if the time-travelling checkpoint contains at least one node: if there's only tokens, reverting "into the future" will do nothing because there's no tokens to drop (I've split up the test so both cases are covered).

Would you mind taking a look at the most recent commit on my branch and see how it compares? If the "extended" version still addresses your use case (I think so), I'd prefer to land this with the additional checkpoint functionality included. I've changed some nits as well (such as changing the name revert to revert_to to match start_node_at) and I think I have one or two comments to leave here, but feel free to run with those and my changes for your PR - the rest of the code is still yours, and I must say that that assert_tree_eq function is really handy ☺️

/// This is useful for backtracking parsers.
///
/// NOTE: this does *not* delete any unfinished nodes; you are responsible for only
/// pairing checkpoint/start_node_at. Using `start_node` combined with `revert` has unspecified behavior.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't quite get what you mean to say here about "pairing checkpoint/start_node_at". What exactly is this about?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

say you have the following call pattern:

let before = builder.checkpoint();
builder.start_node();
builder.token(EXAMPLE);
builder.revert(before);

one might expect, in an ideal world, that this is a noop. but in fact it leaves you with an unpaired start_node() that has no tokens.

i don't remember why i mentioned start_node_at specifically, i can remove that line.

Copy link
Owner

@domenicquirl domenicquirl Oct 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, yeah I get that originally this would only revert the token (I've changed in on the other branch, because the parent-tracking version does now indeed delete the node, which I think is better / more correct and should be covered by the unfinished_node test). I just don't understand what should be "paired" here, perhaps this read like "you should only follow-up a call to checkpoint with a call to start_node_at if you want to use it, without start_node in between"? Either way I think we don't need it anymore if the new behaviour is to delete both node and token :)

Comment on lines +63 to +64
builder.start_node(SyntaxKind(0));
builder.finish_node();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, I assume this was intended to contain the actual nested case? Since the first tree is the control and the second only has one checkpoint. I added some code in the other branch, but you might want to double-check if that does what you wanted to test here given that from the name it looks like this should test the case of creating multiple checkpoints back to back.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fascinating, apparently i forgot an entire test here lol. good catch ty, i meant to write this probably

        builder.start_node(SyntaxKind(0));
        // Add two tokens, then remove both.
        let initial = builder.checkpoint();
        builder.token(SyntaxKind(1), "hi");
        builder.start_node(SyntaxKind(3));
        builder.token(SyntaxKind(2), "hello");
        builder.finish_node();
        builder.revert(initial);

        builder.finish_node();

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, so not nesting checkpoints but nesting nodes? Then this would have caught the checkpoint not working across node boundaries, but I think testing using two checkpoints (in the correct order, not like the misuse test) is also something we should do.

@domenicquirl
Copy link
Owner

Not sure what's up with the sanitizers in CI btw. I ran them all locally (with rustc 1.83.0-nightly (8d6b88b16 2024-09-11) instead of rustc 1.84.0-nightly (da935398d 2024-10-19)) and everything passes (against my branch).

@jyn514
Copy link
Contributor Author

jyn514 commented Oct 21, 2024

Would you mind taking a look at the most recent commit on my branch and see how it compares? If the "extended" version still addresses your use case (I think so), I'd prefer to land this with the additional checkpoint functionality included.

i think it still meets my use case :) at least, my parser using revert() still passes all the test cases. i left a comment; my brain is kinda fried and i probably won't have another chance to look at this for at least a week, feel free to merge even if i don't get back to you.

@domenicquirl
Copy link
Owner

@jyn514 had some personal stuff come up as well, but spent some more time today going over the assert!s and expanding the test coverage for the different valid and invalid combinations of revert_to and start_node_at, as well as the documentation on those methods. I've landed this in #68, including your original commit, so I'll be closing this PR as done.

The new functionality should be available in v0.12.2, which is already up on crates.io. Thanks again for working on this!

@jyn514 jyn514 deleted the rollback branch November 16, 2024 20:09
@jyn514
Copy link
Contributor Author

jyn514 commented Nov 16, 2024

this is awesome, thanks for reviewing it and fixing the bugs i didn't think about ^^

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants