Skip to content

Commit 578d477

Browse files
committed
yeast: Add special location field
When specified on a tree template node, this field is used to override/set the location of the node it is attached to. The field itself can contain an iterable of nodes, in which case the location will be the smallest location spanning all of these nodes. The same behaviour applies if there are multiple `location` fields on the same node.
1 parent bc53c78 commit 578d477

6 files changed

Lines changed: 214 additions & 8 deletions

File tree

‎shared/yeast-macros/src/ast.rs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ pub(crate) struct Node {
108108
/// Optional leaf content such as `"foo"` or `#{name}` in
109109
/// `(identifier "foo")` or `(identifier #{name})`.
110110
pub(crate) content: Option<Content>,
111-
/// Named fields such as `method: {name}` in `(call method: {name})`.
111+
/// Named fields and source-location metadata.
112112
pub(crate) fields: Vec<Field>,
113113
}
114114

@@ -131,4 +131,9 @@ pub(crate) enum Field {
131131
/// Whether the field value has a trailing `?`.
132132
optional: bool,
133133
},
134+
/// Source-location metadata such as `location: {source}`.
135+
Location {
136+
/// A Rust block yielding the node or nodes whose locations should be combined.
137+
block: syn::Block,
138+
},
134139
}

‎shared/yeast-macros/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ pub fn query(input: TokenStream) -> TokenStream {
6565
/// field: {expr} - extend a named field with `{expr}`'s ids
6666
/// field: (kind ...)? - set the field only if every `#{expr}`
6767
/// beneath it has a value (see below)
68+
/// location: {expr} - override this node's source range with the
69+
/// span of one or more nodes; may be repeated
6870
/// ```
6971
///
7072
/// # Optional fields

‎shared/yeast-macros/src/lower.rs‎

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ impl Node {
320320
let kind_str = self.kind.to_string();
321321

322322
if let Some(content) = &self.content {
323-
return match content {
323+
let node = match content {
324324
Content::Static(lit) => quote! { #ctx.literal(#kind_str, #lit) },
325325
Content::Computed(block) => {
326326
let block = rust_block(block);
@@ -362,6 +362,7 @@ impl Node {
362362
}
363363
}
364364
};
365+
return self.with_location(node, &ctx);
365366
}
366367

367368
let mut stmts = Vec::new();
@@ -372,7 +373,10 @@ impl Node {
372373
name,
373374
value,
374375
optional,
375-
} = field;
376+
} = field
377+
else {
378+
continue;
379+
};
376380
let field_name = name.to_string();
377381
let field_str = field_name
378382
.strip_prefix("r#")
@@ -424,13 +428,42 @@ impl Node {
424428
}
425429
}
426430

427-
quote! {
431+
let node = quote! {
428432
{
429433
#(#stmts)*
430434
let mut __fields: Vec<(&str, Vec<yeast::Id>)> = Vec::new();
431435
#(#field_args)*
432436
#ctx.node(#kind_str, __fields)
433437
}
438+
};
439+
self.with_location(node, &ctx)
440+
}
441+
442+
fn with_location(&self, node: TokenStream, ctx: &Ident) -> TokenStream {
443+
let locations = self.fields.iter().filter_map(|field| match field {
444+
Field::Named { .. } => None,
445+
Field::Location { block } => Some(rust_block(block)),
446+
});
447+
let locations: Vec<_> = locations.collect();
448+
if locations.is_empty() {
449+
return node;
450+
}
451+
452+
quote! {
453+
{
454+
let mut __location_ids: Vec<yeast::Id> = Vec::new();
455+
#(yeast::IntoFieldIds::extend_into(#locations, &mut __location_ids);)*
456+
let __source_range = __location_ids
457+
.into_iter()
458+
.filter_map(|__source| {
459+
#ctx.ast
460+
.get_node(__source)
461+
.and_then(|__node| __node.source_range())
462+
})
463+
.reduce(yeast::Range::union);
464+
let __node: yeast::Id = #node;
465+
#ctx.set_node_source_range(__node, __source_range)
466+
}
434467
}
435468
}
436469
}

‎shared/yeast-macros/src/template_parse.rs‎

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,41 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
7070
let mut fields = Vec::new();
7171
while peek_is_field(&mut tokens) {
7272
let name = expect_ident(&mut tokens, "expected field name")?;
73-
if content.is_some() {
73+
let is_location = name == "location";
74+
if content.is_some() && !is_location {
7475
return Err(syn::Error::new_spanned(
7576
name,
76-
"literal nodes cannot have named fields",
77+
"literal nodes can only have the special `location` field",
7778
));
7879
}
7980
expect_punct(&mut tokens, ':', "expected `:` after field name")?;
81+
if is_location {
82+
if !peek_is_group(&mut tokens, Delimiter::Brace) {
83+
let error = match tokens.peek() {
84+
Some(token) => syn::Error::new_spanned(
85+
token.clone(),
86+
"`location` field value must be a Rust block",
87+
),
88+
None => syn::Error::new(
89+
Span::call_site(),
90+
"`location` field value must be a Rust block",
91+
),
92+
};
93+
return Err(error);
94+
}
95+
let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?;
96+
if let Some(TokenTree::Punct(punctuation)) = tokens.peek() {
97+
if punctuation.as_char() == '?' {
98+
return Err(syn::Error::new_spanned(
99+
punctuation.clone(),
100+
"`?` is not valid on a `location` field",
101+
));
102+
}
103+
}
104+
fields.push(Field::Location { block });
105+
continue;
106+
}
107+
80108
let value = parse_one(&mut tokens)?;
81109
let optional = if matches!(value, Template::Splice(_)) {
82110
reject_stray_optional(&mut tokens, true)?;
@@ -240,6 +268,46 @@ mod tests {
240268
assert_eq!(node.fields.len(), 2);
241269
}
242270

271+
#[test]
272+
fn parses_location_fields_on_literal_nodes() {
273+
let template = parse_template(quote! {
274+
(identifier "name" location: {source})
275+
})
276+
.unwrap();
277+
278+
let Template::Node(node) = template else {
279+
panic!("expected node template");
280+
};
281+
assert_eq!(node.kind, "identifier");
282+
assert!(node.content.is_some());
283+
assert!(matches!(node.fields.as_slice(), [Field::Location { .. }]));
284+
}
285+
286+
#[test]
287+
fn rejects_non_block_location_fields() {
288+
let result = parse_template(quote! {
289+
(identifier "name" location: (identifier "source"))
290+
});
291+
let Err(error) = result else {
292+
panic!("expected a non-block location value to be rejected");
293+
};
294+
assert_eq!(
295+
error.to_string(),
296+
"`location` field value must be a Rust block"
297+
);
298+
}
299+
300+
#[test]
301+
fn rejects_optional_location_fields() {
302+
let result = parse_template(quote! {
303+
(identifier "name" location: {source}?)
304+
});
305+
let Err(error) = result else {
306+
panic!("expected an optional location field to be rejected");
307+
};
308+
assert_eq!(error.to_string(), "`?` is not valid on a `location` field");
309+
}
310+
243311
#[test]
244312
fn parses_lists_and_skips_empty_nodes() {
245313
let templates = parse_templates(quote! {
@@ -254,13 +322,16 @@ mod tests {
254322
}
255323

256324
#[test]
257-
fn rejects_fields_on_literal_nodes() {
325+
fn rejects_named_fields_on_literal_nodes() {
258326
let result = parse_template(quote! {
259327
(identifier "name" child: (identifier "nested"))
260328
});
261329
let Err(error) = result else {
262330
panic!("expected literal node fields to be rejected");
263331
};
264-
assert_eq!(error.to_string(), "literal nodes cannot have named fields");
332+
assert_eq!(
333+
error.to_string(),
334+
"literal nodes can only have the special `location` field"
335+
);
265336
}
266337
}

‎shared/yeast/doc/yeast.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,32 @@ same broad range to every synthetic descendant. A transform that simply
271271
returns a translated capture does not widen that capture to the wrapper's
272272
range.
273273

274+
The special `location` field overrides the location of the node on which it
275+
appears. Its value must be a Rust block and uses the same node-splicing rules
276+
as an ordinary field block, but is metadata and is not added to the output AST:
277+
278+
```rust
279+
rule!(
280+
(wrapper
281+
first: (_) @first
282+
second: (_) @second
283+
child: (_) @child)
284+
=>
285+
(synthetic_node
286+
child: {child}
287+
location: {[first, second]})
288+
)
289+
```
290+
291+
If a `location` value yields several nodes, their locations are combined into
292+
the smallest range containing all of them. Multiple `location` fields on the
293+
same node are combined in the same way. The field can also be used on literal
294+
leaf nodes:
295+
296+
```rust
297+
(identifier "synthetic" location: {source_node})
298+
```
299+
274300
The following macros can be used to explicitly set the location associated
275301
with a newly-created node. They assign a location only to the root of their
276302
template; nested nodes still derive their locations normally.

‎shared/yeast/tests/test.rs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,6 +1708,75 @@ fn test_returned_capture_keeps_its_location() {
17081708
assert_eq!(identifier.byte_range(), 4..7);
17091709
}
17101710

1711+
#[test]
1712+
fn test_location_field_overrides_derived_location() {
1713+
let rule: Rule = rule!(
1714+
(assignment
1715+
left: (identifier) @left
1716+
right: (integer) @right)
1717+
=>
1718+
(call
1719+
method: {left}
1720+
location: {right})
1721+
);
1722+
1723+
let ast = run_and_ast("x = 1", vec![rule]);
1724+
let call = ast
1725+
.reachable_node_ids()
1726+
.into_iter()
1727+
.filter_map(|id| ast.get_node(id))
1728+
.find(|node| node.kind_name() == "call")
1729+
.expect("call exists");
1730+
1731+
assert_eq!(call.byte_range(), 4..5);
1732+
}
1733+
1734+
#[test]
1735+
fn test_multiple_location_fields_combine_iterable_locations() {
1736+
let rule: Rule = rule!(
1737+
(assignment
1738+
left: (identifier) @left
1739+
right: (integer) @right)
1740+
=>
1741+
(call
1742+
method: {left}
1743+
arguments: (argument_list
1744+
location: {[left]}
1745+
location: {[right, left]}))
1746+
);
1747+
1748+
let ast = run_and_ast("x = 1", vec![rule]);
1749+
let arguments = ast
1750+
.reachable_node_ids()
1751+
.into_iter()
1752+
.filter_map(|id| ast.get_node(id))
1753+
.find(|node| node.kind_name() == "argument_list")
1754+
.expect("argument list exists");
1755+
1756+
assert_eq!(arguments.byte_range(), 0..5);
1757+
}
1758+
1759+
#[test]
1760+
fn test_location_field_is_available_on_literal_nodes() {
1761+
let rule: Rule = rule!(
1762+
(assignment
1763+
left: (identifier) @left
1764+
right: (integer) @right)
1765+
=>
1766+
(identifier "synthetic" location: {right})
1767+
);
1768+
1769+
let ast = run_and_ast("x = 1", vec![rule]);
1770+
let identifier = ast
1771+
.reachable_node_ids()
1772+
.into_iter()
1773+
.filter_map(|id| ast.get_node(id))
1774+
.find(|node| node.kind_name() == "identifier")
1775+
.expect("identifier exists");
1776+
1777+
assert_eq!(identifier.byte_range(), 4..5);
1778+
}
1779+
17111780
#[test]
17121781
fn test_ignored_location_field_is_excluded_from_rule_result_location() {
17131782
let rule: Rule = rule!(

0 commit comments

Comments
 (0)