Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions shared/yeast-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,15 @@ pub fn trees(input: TokenStream) -> TokenStream {
/// (output_template field: {name} {repeated})
/// )
///
/// // A guard filters a successful query match. Every capture is raw in the
/// // guard because guards run before capture translation.
/// rule!(
/// (query_pattern field: _? @value)
/// where value.is_none()
/// =>
/// (output_template)
/// )
///
/// // Shorthand: captures become fields on the output node
/// rule!((query ...) => output_kind)
/// ```
Expand All @@ -131,6 +140,15 @@ pub fn trees(input: TokenStream) -> TokenStream {
/// - `@name` (after `*`/`+`) → `name: Vec<Id>`
/// - `@name` (after `?`) → `name: Option<Id>`
///
/// A guard is an optional Rust condition between the query and `=>`. It is
/// evaluated after the query matches and before any `@` captures are
/// translated. Returning false makes the driver try the next rule. All
/// captures are therefore raw in the guard; `@` versus `@@` controls only
/// whether the capture is translated for the transform. `ctx` provides
/// mutable user-context access, and `ast` provides read-only AST access.
/// Mutations to `ctx` are visible to the transform when the guard succeeds.
/// Omitting the guard is equivalent to writing `where true`.
///
/// `tree!` and `trees!` can be used without explicit context inside `{...}`.
#[proc_macro]
pub fn rule(input: TokenStream) -> TokenStream {
Expand Down
141 changes: 96 additions & 45 deletions shared/yeast-macros/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,32 @@ enum CaptureMultiplicity {
Repeated,
}

fn capture_bindings<'a>(captures: impl Iterator<Item = &'a CaptureInfo>) -> Vec<TokenStream> {
captures
.map(|cap| {
let name = Ident::new(&cap.name, Span::call_site());
let name_str = &cap.name;
match cap.multiplicity {
CaptureMultiplicity::Repeated => {
quote! {
let #name: Vec<yeast::Id> = __captures.get_all(#name_str);
}
}
CaptureMultiplicity::Optional => {
quote! {
let #name: Option<yeast::Id> = __captures.get_opt(#name_str);
}
}
CaptureMultiplicity::Single => {
quote! {
let #name: yeast::Id = __captures.get_var(#name_str).unwrap();
}
}
}
})
.collect()
}

/// Walk a token stream and extract all `@name` captures, noting whether
/// they appear after `*` or `+` (repeated) or not.
fn extract_captures(stream: &TokenStream) -> Vec<CaptureInfo> {
Expand Down Expand Up @@ -806,7 +832,7 @@ fn try_consume_return_annotation(tokens: &mut Tokens) -> Result<Option<ReturnAnn
pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
let mut tokens = input.into_iter().peekable();

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

let query_stream: TokenStream = query_tokens.into_iter().collect();
let (query_stream, guard) = split_rule_guard(query_tokens)?;

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

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

let (raw_captures, translated_captures): (Vec<_>, Vec<_>) =
captures.iter().partition(|capture| capture.raw);

// Capture names marked `@@name` (raw) — passed to the auto-translate
// prefix as a skip list so those captures keep their input-schema ids.
let raw_capture_names: Vec<&str> = captures
let raw_capture_names: Vec<&str> = raw_captures
.iter()
.filter(|c| c.raw)
.map(|c| c.name.as_str())
.map(|capture| capture.name.as_str())
.collect();

// Generate capture bindings
// Both capture sets are bound raw in the guard, which runs before
// auto-translation. In the transform, raw captures remain unchanged
// while translated captures are bound after auto-translation.
let ctx_ident = Ident::new(IMPLICIT_CTX, Span::call_site());
let bindings: Vec<TokenStream> = captures
.iter()
.map(|cap| {
let name = Ident::new(&cap.name, Span::call_site());
let name_str = &cap.name;
match cap.multiplicity {
CaptureMultiplicity::Repeated => {
quote! {
let #name: Vec<yeast::Id> = __captures.get_all(#name_str);
}
}
CaptureMultiplicity::Optional => {
quote! {
let #name: Option<yeast::Id> = __captures.get_opt(#name_str);
}
}
CaptureMultiplicity::Single => {
quote! {
let #name: yeast::Id = __captures.get_var(#name_str).unwrap();
}
}
}
})
.collect();
let raw_bindings = capture_bindings(raw_captures.into_iter());
let translated_bindings = capture_bindings(translated_captures.into_iter());

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

let guard = guard.unwrap_or_else(|| syn::parse_quote!(true));
Ok(quote! {
{
let __query = #query_code;
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<'_, _>| {
// Auto-translation prefix: recursively translate every
// captured node before invoking the user's transform body,
// except for `@@name` captures listed in `__skip` which the
// body consumes raw.
// For OneShot rules this preserves the legacy behaviour
// (input-schema captures translated to output-schema
// nodes); for Repeating rules it is a no-op.
let __skip: &[&str] = &[#(#raw_capture_names),*];
__translator.auto_translate_captures(&mut __captures, __ast, __user_ctx, __skip)?;
#(#bindings)*
let mut #ctx_ident = yeast::build::BuildCtx::with_translator(__ast, &__captures, __fresh, __source_range, __user_ctx, __translator);
let __result: Vec<yeast::Id> = { #transform_body };
Ok(__result)
}))
yeast::Rule::guarded(
__query,
Box::new(|__ast: &yeast::Ast, __captures: &yeast::captures::Captures, __user_ctx: &mut _| {
#(#raw_bindings)*
#(#translated_bindings)*
let ast = __ast;
let #ctx_ident = __user_ctx;
Ok(#guard)
}),
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<'_, _>| {
// Auto-translation prefix: recursively translate every
// captured node before invoking the user's transform body,
// except for `@@name` captures listed in `__skip` which the
// body consumes raw.
// For OneShot rules this preserves the legacy behaviour
// (input-schema captures translated to output-schema
// nodes); for Repeating rules it is a no-op.
let __skip: &[&str] = &[#(#raw_capture_names),*];
__translator.auto_translate_captures(&mut __captures, __ast, __user_ctx, __skip)?;
#(#raw_bindings)*
#(#translated_bindings)*
let mut #ctx_ident = yeast::build::BuildCtx::with_translator(__ast, &__captures, __fresh, __source_range, __user_ctx, __translator);
let __result: Vec<yeast::Id> = { #transform_body };
Ok(__result)
}),
)
}
})
}
Expand All @@ -1041,6 +1061,37 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
// Token utilities
// ---------------------------------------------------------------------------

/// Split the tokens before a rule's `=>` into its query and optional
/// top-level `where guard`. Groups are opaque `TokenTree`s, so a `where`
/// inside the query or guard expression is not mistaken for the separator.
fn split_rule_guard(tokens: Vec<TokenTree>) -> Result<(TokenStream, Option<syn::Expr>)> {
let guard_index = tokens
.iter()
.position(|tok| matches!(tok, TokenTree::Ident(ident) if ident == "where"));

let Some(guard_index) = guard_index else {
return Ok((tokens.into_iter().collect(), None));
};

let query: TokenStream = tokens[..guard_index].iter().cloned().collect();
if query.is_empty() {
return Err(syn::Error::new(
Span::call_site(),
"expected query before rule guard",
));
}

let guard_tokens: TokenStream = tokens[guard_index + 1..].iter().cloned().collect();
if guard_tokens.is_empty() {
return Err(syn::Error::new_spanned(
tokens[guard_index].clone(),
"expected expression after rule guard `where`",
));
}
let guard = syn::parse2::<syn::Expr>(guard_tokens)?;
Ok((query, Some(guard)))
}

fn peek_is_at(tokens: &mut Tokens) -> bool {
matches!(tokens.peek(), Some(TokenTree::Punct(p)) if p.as_char() == '@')
}
Expand Down
48 changes: 48 additions & 0 deletions shared/yeast/doc/yeast.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,54 @@ yeast::rule!(
The shorthand `=> kind` form auto-generates the template, mapping each
capture name to a field of the same name on the output node.

### Guards

A rule may include a Rust guard between its query and `=>`. The guard runs
after the query matches but before any captures are translated. If it returns
`false`, the rule is treated as a non-match and the driver tries the next rule.
Omitting the guard is equivalent to writing `where true`:

```rust
rule!(
(tupleExpr
elements: (labeledExpr
label: _? @label
expression: @inner)
elements: _* @rest)
where label.is_none() && rest.is_empty()
=>
expr { inner }
)
```

Every capture is a raw input-schema id in the guard, regardless of whether it
uses `@` or `@@`, because the guard runs before translation. The marker controls
the transform binding only: `@inner` is translated after the guard accepts the
rule, while a capture marked `@@` would remain raw in the transform as well.
In this example `label` and `rest` are empty whenever the guard succeeds, so
there is nothing to translate for those captures.

Guards receive the mutable user context as `ctx` and the raw AST as `ast`.
The framework clones the user context before evaluating each guard. If the
guard succeeds, its context mutations are visible to the rule transform and
recursive translation; if it fails, the clone is discarded before the next
rule is tried:

```rust
rule!(
(tupleExpr elements: _* @elements)
where {
ctx.in_pattern = true;
elements
.first()
.and_then(|element| ast.get_node(*element))
.is_some()
}
=>
(tuple_pattern element: {elements})
)
```

### Annotation form

Rules that need imperative logic — mutating [`BuildCtx`] state per
Expand Down
Loading
Loading