Skip to content

Commit 2b71b27

Browse files
committed
yeast: Add explicit locationless nodes WIP
These are needed for Python, where expression contexts (Store/Load/Param/Del) are AST nodes that are explicitly locationless. Thus, for these the default heuristic of giving them an empty range at the start of the enclosing node is not that useful (and the subsequent unioning of child ranges is quite detrimental).. We therefore need a way to specify that some nodes are locationless, and I ended up with the syntax `location: none`. I briefly considered writing this as `location: {None}` instead, but unfortunately `rustc` does not accept this as the type of `None` is not fully specified. It would have to be written as `None::<OutputId>` which is quite ugly. Finally, having explicit location overrides and `location: none` at the same time is an error (that we discover during parsing).
1 parent e931898 commit 2b71b27

8 files changed

Lines changed: 114 additions & 8 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,6 @@ pub(crate) enum Field {
136136
/// A Rust block yielding the node or nodes whose locations should be combined.
137137
block: syn::Block,
138138
},
139+
/// Explicitly omit source-location metadata: `location: none`.
140+
LocationNone,
139141
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ pub fn query(input: TokenStream) -> TokenStream {
6767
/// beneath it has a value (see below)
6868
/// location: {expr} - override this node's source range with the
6969
/// span of one or more nodes; may be repeated
70+
/// location: none - explicitly make this node locationless
7071
/// ```
7172
///
7273
/// # Optional fields

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,10 @@ impl Node {
388388

389389
let mut stmts = Vec::new();
390390
let mut field_args = Vec::new();
391+
let location_none = self
392+
.fields
393+
.iter()
394+
.any(|field| matches!(field, Field::LocationNone));
391395

392396
for (field_counter, field) in self.fields.iter().enumerate() {
393397
match field {
@@ -399,6 +403,7 @@ impl Node {
399403
#ctx.extend_location_ranges(__value, &mut __location_ranges);
400404
});
401405
}
406+
Field::LocationNone => {}
402407
Field::Named {
403408
name,
404409
value,
@@ -475,8 +480,13 @@ impl Node {
475480
#node_init
476481
#(#stmts)*
477482
#build_node
478-
let __source_range = __location_ranges.into_iter().reduce(yeast::Range::union);
479-
#ctx.set_node_source_range(__node, __source_range)
483+
if #location_none {
484+
#ctx.set_node_locationless(__node)
485+
} else {
486+
let __source_range =
487+
__location_ranges.into_iter().reduce(yeast::Range::union);
488+
#ctx.set_node_source_range(__node, __source_range)
489+
}
480490
}
481491
}
482492
}

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
6868
};
6969

7070
let mut fields = Vec::new();
71+
let mut has_location = false;
72+
let mut has_location_none = false;
7173
while peek_is_field(&mut tokens) {
7274
let name = expect_ident(&mut tokens, "expected field name")?;
7375
let is_location = name == "location";
@@ -79,20 +81,39 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
7981
}
8082
expect_punct(&mut tokens, ':', "expected `:` after field name")?;
8183
if is_location {
82-
if !peek_is_group(&mut tokens, Delimiter::Brace) {
84+
if matches!(tokens.peek(), Some(TokenTree::Ident(ident)) if ident == "none") {
85+
let none = tokens.next().unwrap();
86+
if has_location || has_location_none {
87+
return Err(syn::Error::new_spanned(
88+
none,
89+
"`location: none` cannot be combined with another `location` field",
90+
));
91+
}
92+
has_location_none = true;
93+
fields.push(Field::LocationNone);
94+
} else if peek_is_group(&mut tokens, Delimiter::Brace) {
95+
if has_location_none {
96+
return Err(syn::Error::new_spanned(
97+
tokens.peek().unwrap().clone(),
98+
"`location: none` cannot be combined with another `location` field",
99+
));
100+
}
101+
has_location = true;
102+
let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?;
103+
fields.push(Field::Location { block });
104+
} else {
83105
let error = match tokens.peek() {
84106
Some(token) => syn::Error::new_spanned(
85107
token.clone(),
86-
"`location` field value must be a Rust block",
108+
"`location` field value must be a Rust block or `none`",
87109
),
88110
None => syn::Error::new(
89111
Span::call_site(),
90-
"`location` field value must be a Rust block",
112+
"`location` field value must be a Rust block or `none`",
91113
),
92114
};
93115
return Err(error);
94116
}
95-
let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?;
96117
if let Some(TokenTree::Punct(punctuation)) = tokens.peek() {
97118
if punctuation.as_char() == '?' {
98119
return Err(syn::Error::new_spanned(
@@ -101,7 +122,6 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
101122
));
102123
}
103124
}
104-
fields.push(Field::Location { block });
105125
continue;
106126
}
107127

@@ -283,6 +303,19 @@ mod tests {
283303
assert!(matches!(node.fields.as_slice(), [Field::Location { .. }]));
284304
}
285305

306+
#[test]
307+
fn parses_location_none() {
308+
let template = parse_template(quote! {
309+
(identifier "name" location: none)
310+
})
311+
.unwrap();
312+
313+
let Template::Node(node) = template else {
314+
panic!("expected node template");
315+
};
316+
assert!(matches!(node.fields.as_slice(), [Field::LocationNone]));
317+
}
318+
286319
#[test]
287320
fn rejects_non_block_location_fields() {
288321
let result = parse_template(quote! {
@@ -293,7 +326,21 @@ mod tests {
293326
};
294327
assert_eq!(
295328
error.to_string(),
296-
"`location` field value must be a Rust block"
329+
"`location` field value must be a Rust block or `none`"
330+
);
331+
}
332+
333+
#[test]
334+
fn rejects_location_none_with_other_locations() {
335+
let result = parse_template(quote! {
336+
(identifier "name" location: none location: {source})
337+
});
338+
let Err(error) = result else {
339+
panic!("expected mixed location metadata to be rejected");
340+
};
341+
assert_eq!(
342+
error.to_string(),
343+
"`location: none` cannot be combined with another `location` field"
297344
);
298345
}
299346

‎shared/yeast/doc/yeast.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,14 @@ leaf nodes:
300300
The override applies only to the node carrying the `location` field; nested
301301
nodes still derive their locations normally.
302302

303+
Use `location: none` for a synthetic node that intentionally has no source
304+
location. This also prevents a returned rule result from inheriting the matched
305+
input range:
306+
307+
```rust
308+
(metadata_node location: none)
309+
```
310+
303311
For input fields whose leading or trailing syntax should never belong to rule
304312
results, configure them once with
305313
`DesugaringConfig::with_ignored_location_fields(...)`. For example, ignoring

‎shared/yeast/src/build.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,14 @@ impl<'a, C> BuildCtx<'a, C> {
187187
node
188188
}
189189

190+
/// Explicitly make a newly-built node locationless.
191+
#[doc(hidden)]
192+
pub fn set_node_locationless(&mut self, node: OutputId) -> OutputId {
193+
self.output.clear_source_range(node);
194+
self.created_nodes_without_source_range.remove(&node);
195+
node
196+
}
197+
190198
/// Return the immutable input AST. Available inside rule transforms.
191199
pub fn ast(&self) -> &Ast {
192200
self.ast

‎shared/yeast/src/lib.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,18 @@ impl<Id: TreeId> Ast<Id> {
815815
node.source_range = Some(source_range);
816816
}
817817

818+
/// Remove the source range from a synthetic node.
819+
pub(crate) fn clear_source_range(&mut self, id: Id) {
820+
let node = self
821+
.nodes
822+
.get_mut(id.index())
823+
.unwrap_or_else(|| panic!("clear_source_range: invalid node id {}", id.index()));
824+
if matches!(node.content, NodeContent::Range(_)) {
825+
panic!("clear_source_range: cannot modify a parsed node");
826+
}
827+
node.source_range = None;
828+
}
829+
818830
/// Register a named node kind, returning its id (idempotent). Lets callers
819831
/// build an AST in a single pass, registering kinds as nodes are created
820832
/// rather than pre-populating the schema.

‎shared/yeast/tests/test.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,6 +1705,24 @@ fn test_location_field_overrides_derived_location() {
17051705
assert_eq!(call.byte_range(), 4..5);
17061706
}
17071707

1708+
#[test]
1709+
fn test_location_none_makes_result_root_locationless() {
1710+
let rule: Rule = rule!(
1711+
(call method: (identifier) @method)
1712+
=>
1713+
(call method: {method} location: none)
1714+
);
1715+
1716+
let ast = run_and_ast("foo.bar()", vec![rule]);
1717+
let call = ast
1718+
.reachable_node_ids()
1719+
.into_iter()
1720+
.filter_map(|id| ast.get_node(id))
1721+
.find(|node| node.kind_name() == "call")
1722+
.expect("call exists");
1723+
assert_eq!(call.source_range(), None);
1724+
}
1725+
17081726
#[test]
17091727
fn test_multiple_location_fields_combine_iterable_locations() {
17101728
let rule: Rule = rule!(

0 commit comments

Comments
 (0)