Yeast: Add shorthand rule! syntax for capture-to-field mapping

rule!(query => kind_name) is a shorthand for rules that simply gather
query captures into fields on a new node type. Each capture name
becomes a field: single captures produce single-valued fields, repeated
captures produce multi-valued fields.

    rule!((foo f: (boo (_) @blah) (_)* @blop) => bar)

is equivalent to:

    rule!((foo ...) => (bar blah: {blah} blop: {..blop}))

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Taus
2026-05-01 13:43:21 +00:00
parent 79f00b87a3
commit 83739f6eaf
2 changed files with 73 additions and 12 deletions

View File

@@ -562,23 +562,57 @@ pub fn parse_rule_top(input: TokenStream) -> Result<TokenStream> {
}
}).collect();
// Parse transform — could be single (tree!) or multiple (trees!)
// Try single first: one parenthesized group with nothing after
let transform_items = parse_direct_list(&mut tokens, &ctx_ident)?;
// Parse transform: either shorthand `=> kind_name` or full `=> (template ...)`
let transform_body = if peek_is_field(&mut tokens) && {
// Shorthand form: bare identifier = output node kind.
// Auto-generate template from captures.
let mut lookahead = tokens.clone();
lookahead.next(); // skip ident
lookahead.peek().is_none() // nothing after = shorthand
} {
let output_kind = expect_ident(&mut tokens, "expected output node kind")?;
let output_kind_str = output_kind.to_string();
if let Some(tok) = tokens.next() {
return Err(syn::Error::new_spanned(tok, "unexpected token after rule! transform"));
}
// Generate field assignments from captures
let field_stmts: Vec<TokenStream> = captures.iter().map(|cap| {
let name = Ident::new(&cap.name, Span::call_site());
let name_str = &cap.name;
if cap.repeated {
quote! {
let __field_id = #ctx_ident.ast.field_id_for_name(#name_str)
.unwrap_or_else(|| panic!("field '{}' not found", #name_str));
__fields.insert(__field_id, #name);
}
} else {
quote! {
let __field_id = #ctx_ident.ast.field_id_for_name(#name_str)
.unwrap_or_else(|| panic!("field '{}' not found", #name_str));
__fields.insert(__field_id, vec![#name]);
}
}
}).collect();
// Determine if single or multi result
let transform_body = if transform_items.len() == 1 {
// Could be single, but we always return Vec<Id> for Rule
quote! {
let mut __nodes: Vec<usize> = Vec::new();
#(#transform_items)*
__nodes
let __kind = #ctx_ident.ast.id_for_node_kind(#output_kind_str)
.unwrap_or_else(|| panic!("node kind '{}' not found", #output_kind_str));
let mut __fields = std::collections::BTreeMap::new();
#(#field_stmts)*
let __id = #ctx_ident.ast.create_node(
__kind,
yeast::NodeContent::DynamicString(String::new()),
__fields,
true,
);
vec![__id]
}
} else {
// Full template form
let transform_items = parse_direct_list(&mut tokens, &ctx_ident)?;
if let Some(tok) = tokens.next() {
return Err(syn::Error::new_spanned(tok, "unexpected token after rule! transform"));
}
quote! {
let mut __nodes: Vec<usize> = Vec::new();
#(#transform_items)*

View File

@@ -155,3 +155,30 @@ fn test_cursor() {
let mut printer = Printer {};
printer.visit(cursor);
}
#[test]
fn test_shorthand_rule() {
// Test the shorthand rule! syntax: captures become fields on a new node type.
// We'll rewrite (assignment left: X right: Y) into (assignment left: Y right: X)
// using the shorthand form where the output kind matches captures to fields.
let input = read_to_string("tests/fixtures/1.rb").unwrap();
// The shorthand maps @left and @right captures to left/right fields on "call"
// (using "call" as output kind since it also has named fields in Ruby's grammar)
let rule = yeast::rule!(
(assignment
left: (_) @method
right: (_) @receiver
)
=> call
);
let runner = Runner::new(tree_sitter_ruby::LANGUAGE.into(), vec![rule]);
let ast = runner.run(&input);
let output = serde_json::to_string_pretty(&ast.print(&input, ast.get_root())).unwrap();
// The assignment should have been rewritten into a call node
assert!(output.contains("\"call\""));
assert!(output.contains("\"method\""));
assert!(output.contains("\"receiver\""));
}