Skip to content

Commit ee04938

Browse files
committed
yeast: Require type annotations on root-level Rust interpolations
In order to facilitate static type checking of rules (and to make it easier for human readers as well), rust blocks at the root level (i.e. rules of the form `... => { ... }`) must now have a type annotation in front. All other forms are unaffected: if the right hand side of a rule is a tree, we can read the type of the root node directly. For interpolations that happen inside of such a tree, we can recover the type by looking at what field we're interpolating into, and consulting the output schema. All existing uses have been updated to have the appropriate type annotations, though these are of course not checked yet (and so could be wrong). Finally, this commit also removes the final catch-all rule `_ @node => {node}`. Because of the preceding rule that matches `(_) @node`, this rule would only ever match unnamed nodes, and I think in practice it did not match at all (at least not in our current set of tests). To give it a proper type we would have to add some notion of an "any" type, which I would like to avoid. If it _does_ turn out to be needed, we can easily add it back (ideally with a test-case that shows why it's still needed).
1 parent ea36f2d commit ee04938

4 files changed

Lines changed: 337 additions & 46 deletions

File tree

shared/yeast-macros/src/parse.rs

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,76 @@ fn extract_captures_inner(
617617
}
618618
}
619619

620+
/// A rule's return-type annotation, when the body is a Rust block. Written
621+
/// between `=>` and the block body using the schema's own vocabulary:
622+
///
623+
/// ```text
624+
/// => kind { … } // single node of that kind
625+
/// => kind? { … } // Option<KindId> (0 or 1)
626+
/// => kind* { … } // Vec<KindId> (0+)
627+
/// ```
628+
///
629+
/// Template bodies (`=> (kind …)`) never carry an annotation — the
630+
/// output kind is the template root. The shorthand `=> kind` (no
631+
/// body) also carries no annotation. See `parse_rule_top` for dispatch.
632+
#[derive(Clone, Debug)]
633+
struct ReturnAnnotation {
634+
kind: Ident,
635+
multiplicity: AnnotationMultiplicity,
636+
}
637+
638+
#[derive(Clone, Copy, Debug, PartialEq)]
639+
enum AnnotationMultiplicity {
640+
Single,
641+
Optional,
642+
Repeated,
643+
}
644+
645+
/// Peek at the token stream to decide whether the transform following
646+
/// `=>` is a **new** annotation form (`kind [? | *] { … }`). If so,
647+
/// consume the annotation and return it, leaving the `{ … }` body in
648+
/// the stream for the caller to parse. Otherwise leave the stream
649+
/// untouched and return `None`.
650+
///
651+
/// The lookahead distinguishes:
652+
/// `kind {` → annotation (single)
653+
/// `kind? {` → annotation (optional)
654+
/// `kind* {` → annotation (repeated)
655+
/// `kind` → shorthand form (no `{` follows) — NOT an annotation
656+
/// anything else → template or bare block — NOT an annotation
657+
fn try_consume_return_annotation(tokens: &mut Tokens) -> Result<Option<ReturnAnnotation>> {
658+
// Must start with an identifier (the kind name).
659+
let mut lookahead = tokens.clone();
660+
let Some(TokenTree::Ident(_)) = lookahead.next() else {
661+
return Ok(None);
662+
};
663+
// Then optionally `?` or `*`, then a `{` group.
664+
let after_suffix = match lookahead.peek() {
665+
Some(TokenTree::Punct(p)) if p.as_char() == '?' || p.as_char() == '*' => {
666+
lookahead.next();
667+
lookahead.peek()
668+
}
669+
other => other,
670+
};
671+
if !matches!(after_suffix, Some(TokenTree::Group(g)) if g.delimiter() == Delimiter::Brace) {
672+
return Ok(None);
673+
}
674+
// Commit: consume the ident + suffix from the real stream.
675+
let kind = expect_ident(tokens, "expected output-kind name in annotation")?;
676+
let multiplicity = match tokens.peek() {
677+
Some(TokenTree::Punct(p)) if p.as_char() == '?' => {
678+
tokens.next();
679+
AnnotationMultiplicity::Optional
680+
}
681+
Some(TokenTree::Punct(p)) if p.as_char() == '*' => {
682+
tokens.next();
683+
AnnotationMultiplicity::Repeated
684+
}
685+
_ => AnnotationMultiplicity::Single,
686+
};
687+
Ok(Some(ReturnAnnotation { kind, multiplicity }))
688+
}
689+
620690
/// Parse `rule!( query => transform )`.
621691
pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
622692
let mut tokens = input.into_iter().peekable();
@@ -688,8 +758,52 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
688758
})
689759
.collect();
690760

691-
// Parse transform: either shorthand `=> kind_name` or full `=> (template ...)`
692-
let transform_body = if peek_is_field(&mut tokens) && {
761+
// Parse transform: the token(s) after `=>` fall into one of three
762+
// shapes, dispatched in order:
763+
//
764+
// 1. `kind [? | *] { rust_body }` — annotated Rust body (NEW).
765+
// Static-analysis-ready: the annotation declares the output
766+
// kind and multiplicity in the schema's own vocabulary.
767+
// 2. `kind` alone — shorthand: emit `(kind field: {@cap})…` from
768+
// the query's captures.
769+
// 3. anything else — full template form (`(kind …)` or bare
770+
// `{ … }` splice via `parse_direct_list`).
771+
let annotation = try_consume_return_annotation(&mut tokens)?;
772+
773+
let transform_body = if let Some(annotation) = annotation {
774+
// Annotation form: `=> kind [? | *] { rust_body }`.
775+
let body_group = expect_group(&mut tokens, Delimiter::Brace)?;
776+
if let Some(tok) = tokens.next() {
777+
return Err(syn::Error::new_spanned(
778+
tok,
779+
"unexpected token after annotated rule body",
780+
));
781+
}
782+
let body = body_group.stream();
783+
// The annotation is not yet consumed by codegen — it will drive
784+
// typed handles once the schema-driven codegen lands. For now,
785+
// emit a self-documenting reference to the annotated kind and
786+
// preserve today's `Vec<yeast::Id>` closure return so behavior
787+
// is unchanged.
788+
let kind_str = annotation.kind.to_string();
789+
let mult_str = match annotation.multiplicity {
790+
AnnotationMultiplicity::Single => "single",
791+
AnnotationMultiplicity::Optional => "optional",
792+
AnnotationMultiplicity::Repeated => "repeated",
793+
};
794+
let _ = (kind_str, mult_str); // silence unused warnings until wired
795+
796+
// For now, adapt the user's typed return value to the framework's
797+
// `Vec<yeast::Id>` closure result. This uses `IntoFieldIds`, which
798+
// already accepts a bare `Id`, an iterable of ids, or `Option<Id>`
799+
// — matching the three annotation multiplicities.
800+
quote! {
801+
let __value = { #body };
802+
let mut __ids: Vec<yeast::Id> = Vec::new();
803+
yeast::IntoFieldIds::extend_into(__value, &mut __ids);
804+
__ids
805+
}
806+
} else if peek_is_field(&mut tokens) && {
693807
// Shorthand form: bare identifier = output node kind.
694808
// Auto-generate template from captures.
695809
let mut lookahead = tokens.clone();
@@ -749,6 +863,26 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
749863
vec![__id]
750864
}
751865
} else {
866+
// Reject bare `{ ... }` transforms — they used to be accepted
867+
// as either a Rust body producing a `Vec<Id>` or a template
868+
// consisting of a single `{cap}` splice. Both patterns lost
869+
// static-analysis information (no visible output kind), so we
870+
// now require rules with block bodies to use the annotation
871+
// form `=> kind [? | *] { ... }`. Templates must start with a
872+
// parenthesized node (e.g. `(if_expr ...)`).
873+
if let Some(TokenTree::Group(g)) = tokens.peek() {
874+
if g.delimiter() == Delimiter::Brace {
875+
let span = g.span();
876+
return Err(syn::Error::new(
877+
span,
878+
"bare `{...}` rule bodies are no longer accepted; \
879+
use the annotation form `=> kind [? | *] { ... }` \
880+
(where the kind names the output node's schema kind, \
881+
optionally suffixed with `?` or `*` for multiplicity)",
882+
));
883+
}
884+
}
885+
752886
// Full template form
753887
let transform_items = parse_direct_list(&mut tokens, &ctx_ident)?;
754888

@@ -1026,10 +1160,7 @@ struct NamedString {
10261160
}
10271161

10281162
fn parse_named_string_arg(tokens: &mut Tokens, expected_name: &str) -> Result<NamedString> {
1029-
let name = expect_ident(
1030-
tokens,
1031-
&format!("expected `{expected_name}:` argument"),
1032-
)?;
1163+
let name = expect_ident(tokens, &format!("expected `{expected_name}:` argument"))?;
10331164
if name != expected_name {
10341165
return Err(syn::Error::new_spanned(
10351166
name,

shared/yeast/doc/yeast.md

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -312,13 +312,15 @@ already conforms to the output schema.
312312
For rules that need the raw (input-schema) capture — typically to read
313313
its source text or to translate it explicitly with mutable context
314314
state between calls — use `@@name` instead. The body sees the original
315-
input-schema `Id`:
315+
input-schema `Id`. Because these rules always have a Rust block body,
316+
they use the annotation form (see [the `rule!` macro
317+
section](#the-rule-macro) for the full grammar):
316318

317319
```rust
318320
yeast::rule!(
319321
(assignment left: (_) @@raw_lhs right: (_) @rhs)
320322
=>
321-
{
323+
call {
322324
// raw_lhs is untranslated: read its original source text.
323325
let text = ctx.ast.source_text(raw_lhs);
324326
// rhs is already translated by the auto-translate prefix.
@@ -372,26 +374,79 @@ automatically: single captures bind as `Id`, repeated captures (after
372374

373375
## The `rule!` macro
374376

375-
`rule!` combines a query and a transform into a single declaration:
377+
`rule!` combines a query and a transform into a single declaration.
378+
There are three transform forms, each suited to a different level of
379+
rule complexity:
376380

377381
```rust
378-
// Full template form
382+
// 1. Template form — a tree literal describing the output.
379383
yeast::rule!(
380384
(query_pattern field: (_) @capture)
381385
=>
382386
(output_template field: {capture})
383387
)
384388

385-
// Shorthand form — captures become fields on the output node
389+
// 2. Shorthand form — captures become fields on a bare output kind.
386390
yeast::rule!(
387391
(query_pattern field: (_) @capture)
388392
=> output_kind
389393
)
394+
395+
// 3. Annotation form — a Rust block body preceded by the output kind.
396+
yeast::rule!(
397+
(query_pattern child: (_)+ @@children)
398+
=>
399+
output_kind* {
400+
// arbitrary Rust; must evaluate to a value compatible with the
401+
// declared multiplicity (see below).
402+
let mut result = Vec::new();
403+
for child in children {
404+
result.extend(ctx.translate(child)?);
405+
}
406+
result
407+
}
408+
)
390409
```
391410

392411
The shorthand `=> kind` form auto-generates the template, mapping each
393412
capture name to a field of the same name on the output node.
394413

414+
### Annotation form
415+
416+
Rules that need imperative logic — mutating [`BuildCtx`] state per
417+
iteration, computing intermediate values, or looping over captures —
418+
use the annotation form. It has three shapes distinguished by a suffix
419+
on the output-kind identifier:
420+
421+
| Syntax | Body must evaluate to | Meaning |
422+
|---------------------|-------------------------------------|--------------------------------|
423+
| `=> kind { ... }` | a single node id of `kind` | Emit exactly one node. |
424+
| `=> kind? { ... }` | an `Option` of a node id of `kind` | Emit 0 or 1 nodes (`None`/`Some`). |
425+
| `=> kind* { ... }` | an iterable of node ids of `kind` | Emit 0+ nodes; flattens into the enclosing splice slot. |
426+
427+
The suffix mirrors the `?` / `*` markers used elsewhere in the schema
428+
DSL (see [`ast_types.yml`](../../../unified/extractor/ast_types.yml)):
429+
bare identifier = required single, `?` = optional single, `*` =
430+
repeated.
431+
432+
The annotation names the schema kind of the output, giving the macro
433+
enough information for future static analysis (e.g. computing the
434+
static output type of translated captures at their consumer sites).
435+
436+
**Bare `=> { ... }` block bodies are rejected** — every Rust-block body
437+
must carry an annotation, so the output kind is always visible without
438+
having to inspect the block's expression.
439+
440+
### Choosing between the forms
441+
442+
Prefer the simplest form that fits:
443+
444+
- If the whole transform is a tree literal, use the **template form**.
445+
- If the transform is a template whose root matches a query capture
446+
1:1, use the **shorthand form**.
447+
- If the transform needs Rust logic (loops, `let` bindings, calls to
448+
`ctx.translate`, etc.), use the **annotation form**.
449+
395450
## Integration with the extractor
396451

397452
A YEAST desugaring pass is configured with a [`DesugaringConfig`], which

0 commit comments

Comments
 (0)