Repository navigation
Conversation
This will be superseded by the next commit, which generalised the mechanism and also makes the implementation simpler.
There was a problem hiding this comment.
🟡 Changes recommended
Two critical compilation failures remain: missing OutputId support breaks translated captures, and premature candidate construction causes moved-value errors when alternatives consume the same repeated capture.
2 open issues
What changed in this PR
Adds ordered, guarded choices to Yeast tree templates, replacing postfix ?. The new syntax lets rewrite rules express fallbacks and check interpolation dependencies before constructing template nodes.
Changes:
- Add choice parsing, validation, and lowering with guard and dependency checks.
- Update documentation and tests for fallback selection, nested choices, and orphan-node prevention.
- Migrate Swift translation rules to bracketed choices.
| File | Description |
|---|---|
unified/extractor/src/languages/swift/swift.rs |
Migrates optional nodes and conditional rewrites to choices. |
shared/yeast/tests/test.rs |
Covers guards, fallbacks, nested choices, and construction preflight. |
shared/yeast/src/lib.rs |
Updates interpolation diagnostics and optional-value implementations. |
shared/yeast/doc/yeast.md |
Documents choice syntax, behavior, and examples. |
shared/yeast-macros/src/template_parse.rs |
Parses and validates choices and guards; removes postfix ? parsing. |
shared/yeast-macros/src/lower.rs |
Generates dependency checks and alternative-selection code. |
shared/yeast-macros/src/lib.rs |
Updates public macro documentation for choices and return types. |
shared/yeast-macros/src/ast.rs |
Adds choice alternatives and removes the optional-field flag. |
Review effort: Max
Adds a new syntactic form
```
[tree1 if {cond1}
tree2
tree3 if {cond3}
]
```
to the language, the intended semantics being that we output the first
tree that is usable. Two things can prevent a tree from being used:
- the guard condition (a Rust expression evaluating to a boolean) may
fail;
- a string interpolation (e.g. `#{name}`) may fail to have a value.
If all of the alternatives fail, then the result is `None`. This makes
the syntax a generalisation of the `?` postfix operator that was removed
in the previous commit. `(foo)?` can be replaced with a single-element
alternative `[(foo)]`.
As the changes to swift.rs show, this can clean up a bunch of
constructions where we currently rely on helper functions or somewhat
complicated Rust blocks.
In terms of the implementation, we go through the alternatives in turn,
first evaluating the guard condition. If this succeeds, we evaluate (and
temporarily store) all of the `#{...}` interpolations, and if those all
return `Some(...)` values, we instantiate the template itself. A benefit
of this implementation compared to the one for `?` (which used named
labels and `break`s) is that if either requirement fails, no AST nodes
are constructed. (The `?` implementation would eagerly construct the
tree, and then abort if any of the interpolations failed. This could
leave orphaned nodes in the AST arena.)
A choice cannot contain another choice as an immediate child (as that
choice could simply have its branches inlined in the parent), but
nesting further down is allow. In that case, the inner choice takes care
of handling its internal "failures", and these do not bubble up to the
outer choice.
bb7eb03 to
7027a76
Compare
asgerf
left a comment
There was a problem hiding this comment.
Some comments after a partial review
| preparations: &'a RefCell<Vec<DependencyPreparation>>, | ||
| } | ||
|
|
||
| struct DependencyPreparation { |
There was a problem hiding this comment.
Could you add a comment explaining what these preparations are? I tried to understand it from the code and I honestly can't see it.
There was a problem hiding this comment.
Admittedly, the name is a bit terrible (Copilot came up with it, and I couldn't think of a better name at the time).
Basically, this is the bit that handles the "and temporarily store" bit mentioned in the commit message. Basically, when we know a choice depends on #{foo} (where foo can be any Rust expression), we evaluate foo before attempting to build the relevant tree. If it fails, we go on to the next choice (if any), but if it succeeds, then we construct the tree, which means we eventually reach a place where we want to use #{foo}. Because we don't want to evaluate foo twice, we store the result of the initial (guarding) evaluation, and reuse it when we construct the template.
Thus, the value field contains the name of an identifier in the lowered Rust code (which contains the result of evaluating foo), source_range is a different identifier that (optionally) contains the source range of the foo interpolation, and finally expression is a small bit of Rust code that evaluates foo and produces the two other values as an Option<(String,Option<Range>)> value. Thus, as part of the guard we end up evaluating roughly the following rust code for each DependencyPreparation for a particular choice branch:
match expression {
Some((value, source_range)) => goto_next_guard_or_build_tree
None => try_next_choice
}I'll update the code to have some comments explaining this (and maybe try to come up with a better name for the struct).
| quote! { ::std::convert::Into::<yeast::OutputId>::into(#block) } | ||
| } | ||
| Template::Choice(_) => { | ||
| unreachable!("choices are lowered through Template::lower_choice") |
There was a problem hiding this comment.
Could we add a test with nested alternatives? Both directly-nested and indirectly-nested. It seems suspicious that the context from the outer call is not passed on in lower_choice. It would make more sense to me if we only had one lower and it somehow wired up the two contexts here.
There was a problem hiding this comment.
If I understand your comment correctly, I think the current behaviour is actually by design.
The new choice operator always succeeds, in that it can never cause a tree to fail being built. At worst all of its alternatives fail, and None is returned by default (which means the field in which this takes place remains unset). This means that an inner choice can never affect an outer one -- whatever interpolations it inspects will be handled by the inner choice, and the outer choice is none the wiser.
Conversely, there's no reason for the inner choice to know about the outer choice's assignments. The only way this could be relevant is if the same interpolation #{foo} is present in both the inner and outer choice, but in that case we really are evaluating foo twice (or at least, that would match the semantics we have elsewhere), so the inner choice can get its own foo to evaluate. We do not try to do any clever deduplication here.
Now, we might want to also add support for choices that can fail as a whole (and not default to None). Just off the top of my head, my suggestion would be to support the form
[
tree1 if {cond1}
tree2
fail
]
for the cases where we want failure if no alternatives succeed. In this case, we might want to pass some of the outer context in.
As for your test request, here's the test that inner (indirect) choices don't affect the outer ones:
codeql/shared/yeast/tests/test.rs
Lines 965 to 991 in 7027a76
And here's the test that shows that direct choice nesting is disallowed by the parser:
codeql/shared/yeast-macros/src/template_parse.rs
Lines 337 to 355 in 7027a76
Adds comments (hopefully) making the purpose of the relevant structs
more clear.
Also, `DependencyScope` is now `ChoiceAlternativeScope` (that is, it's
the scope associated with one of the alternatives in a choice), and
`DependencyPreparation` is `GuardingInterpolation` (since it is
essentially a representation of the `#{...}` interpolations that are
used as guards.

When creating the target tree for some rewrite rule, we quite often want to make the construction conditional on the presence of certain captures, or on the current state of the context.
Previously we added the postfix
?operator for this purpose. This made it possible to write a tree template likewherein the entire
baznode disappears (because of the postfix?) if thenameinterpolation fails (e.g. ifnameisNone).There are a few drawbacks with this approach:
Nonealternative.#{...}interpolations.(This also made the implementation somewhat complicated, requiring the use of labels and
breaks in the lowered Rust code.)Instead of the above, this PR implements a generalisation of this concept. Taking inspiration from the
tree-sitterquery language, we use square brackets to indicate a list of alternatives that should be attempted in turn. The first alternative that succeeds is the tree that is returned from this choice operator. Moreover, individual choices can have abool-returning Rust-block as a guard.Thus, something like
would have the following interpretation
cond1holds, and all#{...}interpolations intree1are valid (e.g. different fromNone), then returntree1#{...}interpolations intree2are valid, return thatcond3holds and all#{...}interpolations intree3are valid, return thatNoneThis fully supersedes the existing
?syntax (which we therefore remove first):(...)?can be rewritten to just[(...)].Before constructing any trees, the implementation first checks that the relevant condition (if present) holds, and that all
#{...}interpolations have usable values. Only if both of these are true is the tree construction attempted. This prevents any orphaned nodes from being constructed.