Skip to content

Commit 3e88b36

Browse files
committed
yeast: Support optional guards in rules
Permits the use of guards of the form `where expr` (with `expr` being any Rust expression that evaluates to a boolean) inside of rules. The guard should come after the query itself, and before the `=>` that separates the query from the body, like so: ``` (foo bar: _? @bar) where bar.is_some() => (baz bar: {bar}) ``` If the guard is absent, it behaves as if it were `where true`. Inside of the guard body, all captures are treated as if they are raw. Translation of non-raw captures only happens if the guard succeeds. The guard can also access the user-defined context. This context is shared with the body if the guard succeeds, and discarded if the guard fails (just as it is currently discarded after running a rule body). (I think it's unlikely that we'll ever want to mutate the context from inside the guard, but you never know...)
1 parent f70e0b5 commit 3e88b36

5 files changed

Lines changed: 459 additions & 115 deletions

File tree

shared/yeast-macros/src/lib.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,15 @@ pub fn trees(input: TokenStream) -> TokenStream {
122122
/// (output_template field: {name} {repeated})
123123
/// )
124124
///
125+
/// // A guard filters a successful query match. Every capture is raw in the
126+
/// // guard because guards run before capture translation.
127+
/// rule!(
128+
/// (query_pattern field: _? @value)
129+
/// where value.is_none()
130+
/// =>
131+
/// (output_template)
132+
/// )
133+
///
125134
/// // Shorthand: captures become fields on the output node
126135
/// rule!((query ...) => output_kind)
127136
/// ```
@@ -131,6 +140,15 @@ pub fn trees(input: TokenStream) -> TokenStream {
131140
/// - `@name` (after `*`/`+`) → `name: Vec<Id>`
132141
/// - `@name` (after `?`) → `name: Option<Id>`
133142
///
143+
/// A guard is an optional Rust condition between the query and `=>`. It is
144+
/// evaluated after the query matches and before any `@` captures are
145+
/// translated. Returning false makes the driver try the next rule. All
146+
/// captures are therefore raw in the guard; `@` versus `@@` controls only
147+
/// whether the capture is translated for the transform. `ctx` provides
148+
/// mutable user-context access, and `ast` provides read-only AST access.
149+
/// Mutations to `ctx` are visible to the transform when the guard succeeds.
150+
/// Omitting the guard is equivalent to writing `where true`.
151+
///
134152
/// `tree!` and `trees!` can be used without explicit context inside `{...}`.
135153
#[proc_macro]
136154
pub fn rule(input: TokenStream) -> TokenStream {

shared/yeast-macros/src/parse.rs

Lines changed: 96 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,32 @@ enum CaptureMultiplicity {
645645
Repeated,
646646
}
647647

648+
fn capture_bindings<'a>(captures: impl Iterator<Item = &'a CaptureInfo>) -> Vec<TokenStream> {
649+
captures
650+
.map(|cap| {
651+
let name = Ident::new(&cap.name, Span::call_site());
652+
let name_str = &cap.name;
653+
match cap.multiplicity {
654+
CaptureMultiplicity::Repeated => {
655+
quote! {
656+
let #name: Vec<yeast::Id> = __captures.get_all(#name_str);
657+
}
658+
}
659+
CaptureMultiplicity::Optional => {
660+
quote! {
661+
let #name: Option<yeast::Id> = __captures.get_opt(#name_str);
662+
}
663+
}
664+
CaptureMultiplicity::Single => {
665+
quote! {
666+
let #name: yeast::Id = __captures.get_var(#name_str).unwrap();
667+
}
668+
}
669+
}
670+
})
671+
.collect()
672+
}
673+
648674
/// Walk a token stream and extract all `@name` captures, noting whether
649675
/// they appear after `*` or `+` (repeated) or not.
650676
fn extract_captures(stream: &TokenStream) -> Vec<CaptureInfo> {
@@ -806,7 +832,7 @@ fn try_consume_return_annotation(tokens: &mut Tokens) -> Result<Option<ReturnAnn
806832
pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
807833
let mut tokens = input.into_iter().peekable();
808834

809-
// Collect query tokens up to `=>`
835+
// Collect query and optional `where` guard tokens up to `=>`.
810836
let mut query_tokens = Vec::new();
811837
loop {
812838
match tokens.peek() {
@@ -830,48 +856,30 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
830856
}
831857
}
832858

833-
let query_stream: TokenStream = query_tokens.into_iter().collect();
859+
let (query_stream, guard) = split_rule_guard(query_tokens)?;
834860

835861
// Extract captures from query
836862
let captures = extract_captures(&query_stream);
837863

838864
// Parse query
839865
let query_code = parse_query_top(query_stream.clone())?;
840866

867+
let (raw_captures, translated_captures): (Vec<_>, Vec<_>) =
868+
captures.iter().partition(|capture| capture.raw);
869+
841870
// Capture names marked `@@name` (raw) — passed to the auto-translate
842871
// prefix as a skip list so those captures keep their input-schema ids.
843-
let raw_capture_names: Vec<&str> = captures
872+
let raw_capture_names: Vec<&str> = raw_captures
844873
.iter()
845-
.filter(|c| c.raw)
846-
.map(|c| c.name.as_str())
874+
.map(|capture| capture.name.as_str())
847875
.collect();
848876

849-
// Generate capture bindings
877+
// Both capture sets are bound raw in the guard, which runs before
878+
// auto-translation. In the transform, raw captures remain unchanged
879+
// while translated captures are bound after auto-translation.
850880
let ctx_ident = Ident::new(IMPLICIT_CTX, Span::call_site());
851-
let bindings: Vec<TokenStream> = captures
852-
.iter()
853-
.map(|cap| {
854-
let name = Ident::new(&cap.name, Span::call_site());
855-
let name_str = &cap.name;
856-
match cap.multiplicity {
857-
CaptureMultiplicity::Repeated => {
858-
quote! {
859-
let #name: Vec<yeast::Id> = __captures.get_all(#name_str);
860-
}
861-
}
862-
CaptureMultiplicity::Optional => {
863-
quote! {
864-
let #name: Option<yeast::Id> = __captures.get_opt(#name_str);
865-
}
866-
}
867-
CaptureMultiplicity::Single => {
868-
quote! {
869-
let #name: yeast::Id = __captures.get_var(#name_str).unwrap();
870-
}
871-
}
872-
}
873-
})
874-
.collect();
881+
let raw_bindings = capture_bindings(raw_captures.into_iter());
882+
let translated_bindings = capture_bindings(translated_captures.into_iter());
875883

876884
// Parse transform: the token(s) after `=>` fall into one of three
877885
// shapes, dispatched in order:
@@ -1015,24 +1023,36 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
10151023
}
10161024
};
10171025

1026+
let guard = guard.unwrap_or_else(|| syn::parse_quote!(true));
10181027
Ok(quote! {
10191028
{
10201029
let __query = #query_code;
1021-
yeast::Rule::new(__query, Box::new(|__ast: &mut yeast::Ast, mut __captures: yeast::captures::Captures, __fresh: &yeast::tree_builder::FreshScope, __source_range: Option<yeast::Range>, __user_ctx: &mut _, __translator: yeast::TranslatorHandle<'_, _>| {
1022-
// Auto-translation prefix: recursively translate every
1023-
// captured node before invoking the user's transform body,
1024-
// except for `@@name` captures listed in `__skip` which the
1025-
// body consumes raw.
1026-
// For OneShot rules this preserves the legacy behaviour
1027-
// (input-schema captures translated to output-schema
1028-
// nodes); for Repeating rules it is a no-op.
1029-
let __skip: &[&str] = &[#(#raw_capture_names),*];
1030-
__translator.auto_translate_captures(&mut __captures, __ast, __user_ctx, __skip)?;
1031-
#(#bindings)*
1032-
let mut #ctx_ident = yeast::build::BuildCtx::with_translator(__ast, &__captures, __fresh, __source_range, __user_ctx, __translator);
1033-
let __result: Vec<yeast::Id> = { #transform_body };
1034-
Ok(__result)
1035-
}))
1030+
yeast::Rule::guarded(
1031+
__query,
1032+
Box::new(|__ast: &yeast::Ast, __captures: &yeast::captures::Captures, __user_ctx: &mut _| {
1033+
#(#raw_bindings)*
1034+
#(#translated_bindings)*
1035+
let ast = __ast;
1036+
let #ctx_ident = __user_ctx;
1037+
Ok(#guard)
1038+
}),
1039+
Box::new(|__ast: &mut yeast::Ast, mut __captures: yeast::captures::Captures, __fresh: &yeast::tree_builder::FreshScope, __source_range: Option<yeast::Range>, __user_ctx: &mut _, __translator: yeast::TranslatorHandle<'_, _>| {
1040+
// Auto-translation prefix: recursively translate every
1041+
// captured node before invoking the user's transform body,
1042+
// except for `@@name` captures listed in `__skip` which the
1043+
// body consumes raw.
1044+
// For OneShot rules this preserves the legacy behaviour
1045+
// (input-schema captures translated to output-schema
1046+
// nodes); for Repeating rules it is a no-op.
1047+
let __skip: &[&str] = &[#(#raw_capture_names),*];
1048+
__translator.auto_translate_captures(&mut __captures, __ast, __user_ctx, __skip)?;
1049+
#(#raw_bindings)*
1050+
#(#translated_bindings)*
1051+
let mut #ctx_ident = yeast::build::BuildCtx::with_translator(__ast, &__captures, __fresh, __source_range, __user_ctx, __translator);
1052+
let __result: Vec<yeast::Id> = { #transform_body };
1053+
Ok(__result)
1054+
}),
1055+
)
10361056
}
10371057
})
10381058
}
@@ -1041,6 +1061,37 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
10411061
// Token utilities
10421062
// ---------------------------------------------------------------------------
10431063

1064+
/// Split the tokens before a rule's `=>` into its query and optional
1065+
/// top-level `where guard`. Groups are opaque `TokenTree`s, so a `where`
1066+
/// inside the query or guard expression is not mistaken for the separator.
1067+
fn split_rule_guard(tokens: Vec<TokenTree>) -> Result<(TokenStream, Option<syn::Expr>)> {
1068+
let guard_index = tokens
1069+
.iter()
1070+
.position(|tok| matches!(tok, TokenTree::Ident(ident) if ident == "where"));
1071+
1072+
let Some(guard_index) = guard_index else {
1073+
return Ok((tokens.into_iter().collect(), None));
1074+
};
1075+
1076+
let query: TokenStream = tokens[..guard_index].iter().cloned().collect();
1077+
if query.is_empty() {
1078+
return Err(syn::Error::new(
1079+
Span::call_site(),
1080+
"expected query before rule guard",
1081+
));
1082+
}
1083+
1084+
let guard_tokens: TokenStream = tokens[guard_index + 1..].iter().cloned().collect();
1085+
if guard_tokens.is_empty() {
1086+
return Err(syn::Error::new_spanned(
1087+
tokens[guard_index].clone(),
1088+
"expected expression after rule guard `where`",
1089+
));
1090+
}
1091+
let guard = syn::parse2::<syn::Expr>(guard_tokens)?;
1092+
Ok((query, Some(guard)))
1093+
}
1094+
10441095
fn peek_is_at(tokens: &mut Tokens) -> bool {
10451096
matches!(tokens.peek(), Some(TokenTree::Punct(p)) if p.as_char() == '@')
10461097
}

shared/yeast/doc/yeast.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,54 @@ yeast::rule!(
458458
The shorthand `=> kind` form auto-generates the template, mapping each
459459
capture name to a field of the same name on the output node.
460460

461+
### Guards
462+
463+
A rule may include a Rust guard between its query and `=>`. The guard runs
464+
after the query matches but before any captures are translated. If it returns
465+
`false`, the rule is treated as a non-match and the driver tries the next rule.
466+
Omitting the guard is equivalent to writing `where true`:
467+
468+
```rust
469+
rule!(
470+
(tupleExpr
471+
elements: (labeledExpr
472+
label: _? @label
473+
expression: @inner)
474+
elements: _* @rest)
475+
where label.is_none() && rest.is_empty()
476+
=>
477+
expr { inner }
478+
)
479+
```
480+
481+
Every capture is a raw input-schema id in the guard, regardless of whether it
482+
uses `@` or `@@`, because the guard runs before translation. The marker controls
483+
the transform binding only: `@inner` is translated after the guard accepts the
484+
rule, while a capture marked `@@` would remain raw in the transform as well.
485+
In this example `label` and `rest` are empty whenever the guard succeeds, so
486+
there is nothing to translate for those captures.
487+
488+
Guards receive the mutable user context as `ctx` and the raw AST as `ast`.
489+
The framework clones the user context before evaluating each guard. If the
490+
guard succeeds, its context mutations are visible to the rule transform and
491+
recursive translation; if it fails, the clone is discarded before the next
492+
rule is tried:
493+
494+
```rust
495+
rule!(
496+
(tupleExpr elements: _* @elements)
497+
where {
498+
ctx.in_pattern = true;
499+
elements
500+
.first()
501+
.and_then(|element| ast.get_node(*element))
502+
.is_some()
503+
}
504+
=>
505+
(tuple_pattern element: {elements})
506+
)
507+
```
508+
461509
### Annotation form
462510

463511
Rules that need imperative logic — mutating [`BuildCtx`] state per

0 commit comments

Comments
 (0)