Skip to content

Commit 41e5eff

Browse files
committed
yeast: Add explicit locationless nodes
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 8fbcfb1 commit 41e5eff

8 files changed

Lines changed: 163 additions & 50 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,6 @@ pub(crate) enum Field {
143143
/// A Rust block yielding the node or nodes whose locations should be combined.
144144
block: syn::Block,
145145
},
146+
/// Explicitly omit source-location metadata: `location: none`.
147+
LocationNone,
146148
}

‎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
/// field: {expr} - extend a named field with `{expr}`'s ids
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
/// field: [(kind ...)] - set the field only if the alternative can
7172
/// be constructed (see below)
7273
/// field: [(a) (b)] - use the first constructible alternative

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

Lines changed: 53 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,7 @@ impl Template {
367367
fn lower_choice(alternatives: &[Alternative]) -> TokenStream {
368368
let pick = alternatives.iter().rev().fold(
369369
quote! { ::std::option::Option::None },
370-
|fallback, alternative| {
371-
alternative.lower_candidate(fallback)
372-
},
370+
|fallback, alternative| alternative.lower_candidate(fallback),
373371
);
374372
quote! {{ #pick }}
375373
}
@@ -402,25 +400,26 @@ impl Alternative {
402400
// evaluated in source order. Construction happens only after the
403401
// resulting option has been matched, keeping it in a separate branch
404402
// from the fallback.
405-
let prepared = interpolations.into_iter().rev().fold(
406-
prepared,
407-
|candidate, interpolation| {
408-
let GuardingInterpolation {
409-
value,
410-
source_range,
411-
expression,
412-
} = interpolation;
413-
// Do not use `map`/`and_then` here: `candidate` may contain
414-
// embedded user Rust, and putting it in a closure would change
415-
// the target of `return` and the result type seen by `?`.
416-
quote! {
417-
match #expression {
418-
::std::option::Option::Some((#value, #source_range)) => #candidate,
419-
::std::option::Option::None => ::std::option::Option::None,
403+
let prepared =
404+
interpolations
405+
.into_iter()
406+
.rev()
407+
.fold(prepared, |candidate, interpolation| {
408+
let GuardingInterpolation {
409+
value,
410+
source_range,
411+
expression,
412+
} = interpolation;
413+
// Do not use `map`/`and_then` here: `candidate` may contain
414+
// embedded user Rust, and putting it in a closure would change
415+
// the target of `return` and the result type seen by `?`.
416+
quote! {
417+
match #expression {
418+
::std::option::Option::Some((#value, #source_range)) => #candidate,
419+
::std::option::Option::None => ::std::option::Option::None,
420+
}
420421
}
421-
}
422-
},
423-
);
422+
});
424423
let prepared = if let Some(guard) = &self.guard {
425424
let guard = rust_block(guard);
426425
quote! {
@@ -462,27 +461,27 @@ impl Node {
462461
.interpolations
463462
.borrow_mut()
464463
.push(GuardingInterpolation {
465-
value: value.clone(),
466-
source_range: source_range.clone(),
467-
expression: quote! {
468-
{
469-
let __expr = #block;
470-
yeast::MaybeYeastValue::maybe_yeast_value(&__expr).map(
471-
|__value_ref| {
472-
(
473-
yeast::YeastDisplay::yeast_to_string(
474-
__value_ref,
475-
&#ctx,
476-
),
477-
yeast::YeastSourceRange::yeast_source_range(
478-
__value_ref,
479-
&#ctx,
480-
),
481-
)
482-
},
483-
)
484-
}
485-
},
464+
value: value.clone(),
465+
source_range: source_range.clone(),
466+
expression: quote! {
467+
{
468+
let __expr = #block;
469+
yeast::MaybeYeastValue::maybe_yeast_value(&__expr).map(
470+
|__value_ref| {
471+
(
472+
yeast::YeastDisplay::yeast_to_string(
473+
__value_ref,
474+
&#ctx,
475+
),
476+
yeast::YeastSourceRange::yeast_source_range(
477+
__value_ref,
478+
&#ctx,
479+
),
480+
)
481+
},
482+
)
483+
}
484+
},
486485
});
487486
quote! {
488487
#ctx.literal_with_source_range(
@@ -516,6 +515,10 @@ impl Node {
516515

517516
let mut stmts = Vec::new();
518517
let mut field_args = Vec::new();
518+
let location_none = self
519+
.fields
520+
.iter()
521+
.any(|field| matches!(field, Field::LocationNone));
519522

520523
for (field_counter, field) in self.fields.iter().enumerate() {
521524
match field {
@@ -527,6 +530,7 @@ impl Node {
527530
#ctx.extend_location_ranges(__value, &mut __location_ranges);
528531
});
529532
}
533+
Field::LocationNone => {}
530534
Field::Named { name, value } => {
531535
let field_name = name.to_string();
532536
let field_str = field_name
@@ -591,8 +595,13 @@ impl Node {
591595
#node_init
592596
#(#stmts)*
593597
#build_node
594-
let __source_range = __location_ranges.into_iter().reduce(yeast::Range::union);
595-
#ctx.set_node_source_range(__node, __source_range)
598+
if #location_none {
599+
#ctx.set_node_locationless(__node)
600+
} else {
601+
let __source_range =
602+
__location_ranges.into_iter().reduce(yeast::Range::union);
603+
#ctx.set_node_source_range(__node, __source_range)
604+
}
596605
}
597606
}
598607
}

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

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
7171
};
7272

7373
let mut fields = Vec::new();
74+
let mut has_location = false;
75+
let mut has_location_none = false;
7476
while peek_is_field(&mut tokens) {
7577
let name = expect_ident(&mut tokens, "expected field name")?;
7678
let is_location = name == "location";
@@ -82,21 +84,47 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
8284
}
8385
expect_punct(&mut tokens, ':', "expected `:` after field name")?;
8486
if is_location {
85-
if !peek_is_group(&mut tokens, Delimiter::Brace) {
87+
if matches!(tokens.peek(), Some(TokenTree::Ident(ident)) if ident == "none") {
88+
let none = tokens.next().unwrap();
89+
if has_location || has_location_none {
90+
return Err(syn::Error::new_spanned(
91+
none,
92+
"`location: none` cannot be combined with another `location` field",
93+
));
94+
}
95+
has_location_none = true;
96+
fields.push(Field::LocationNone);
97+
} else if peek_is_group(&mut tokens, Delimiter::Brace) {
98+
if has_location_none {
99+
return Err(syn::Error::new_spanned(
100+
tokens.peek().unwrap().clone(),
101+
"`location: none` cannot be combined with another `location` field",
102+
));
103+
}
104+
has_location = true;
105+
let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?;
106+
fields.push(Field::Location { block });
107+
} else {
86108
let error = match tokens.peek() {
87109
Some(token) => syn::Error::new_spanned(
88110
token.clone(),
89-
"`location` field value must be a Rust block",
111+
"`location` field value must be a Rust block or `none`",
90112
),
91113
None => syn::Error::new(
92114
Span::call_site(),
93-
"`location` field value must be a Rust block",
115+
"`location` field value must be a Rust block or `none`",
94116
),
95117
};
96118
return Err(error);
97119
}
98-
let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?;
99-
fields.push(Field::Location { block });
120+
if let Some(TokenTree::Punct(punctuation)) = tokens.peek() {
121+
if punctuation.as_char() == '?' {
122+
return Err(syn::Error::new_spanned(
123+
punctuation.clone(),
124+
"`?` is not valid on a `location` field",
125+
));
126+
}
127+
}
100128
continue;
101129
}
102130

@@ -290,6 +318,19 @@ mod tests {
290318
assert!(matches!(node.fields.as_slice(), [Field::Location { .. }]));
291319
}
292320

321+
#[test]
322+
fn parses_location_none() {
323+
let template = parse_template(quote! {
324+
(identifier "name" location: none)
325+
})
326+
.unwrap();
327+
328+
let Template::Node(node) = template else {
329+
panic!("expected node template");
330+
};
331+
assert!(matches!(node.fields.as_slice(), [Field::LocationNone]));
332+
}
333+
293334
#[test]
294335
fn rejects_non_block_location_fields() {
295336
let result = parse_template(quote! {
@@ -300,7 +341,21 @@ mod tests {
300341
};
301342
assert_eq!(
302343
error.to_string(),
303-
"`location` field value must be a Rust block"
344+
"`location` field value must be a Rust block or `none`"
345+
);
346+
}
347+
348+
#[test]
349+
fn rejects_location_none_with_other_locations() {
350+
let result = parse_template(quote! {
351+
(identifier "name" location: none location: {source})
352+
});
353+
let Err(error) = result else {
354+
panic!("expected mixed location metadata to be rejected");
355+
};
356+
assert_eq!(
357+
error.to_string(),
358+
"`location: none` cannot be combined with another `location` field"
304359
);
305360
}
306361

‎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
@@ -816,6 +816,18 @@ impl<Id: TreeId> Ast<Id> {
816816
node.source_range = Some(source_range);
817817
}
818818

819+
/// Remove the source range from a synthetic node.
820+
pub(crate) fn clear_source_range(&mut self, id: Id) {
821+
let node = self
822+
.nodes
823+
.get_mut(id.index())
824+
.unwrap_or_else(|| panic!("clear_source_range: invalid node id {}", id.index()));
825+
if matches!(node.content, NodeContent::Range(_)) {
826+
panic!("clear_source_range: cannot modify a parsed node");
827+
}
828+
node.source_range = None;
829+
}
830+
819831
/// Register a named node kind, returning its id (idempotent). Lets callers
820832
/// build an AST in a single pass, registering kinds as nodes are created
821833
/// 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
@@ -1898,6 +1898,24 @@ fn test_location_field_overrides_derived_location() {
18981898
assert_eq!(call.byte_range(), 4..5);
18991899
}
19001900

1901+
#[test]
1902+
fn test_location_none_makes_result_root_locationless() {
1903+
let rule: Rule = rule!(
1904+
(call method: (identifier) @method)
1905+
=>
1906+
(call method: {method} location: none)
1907+
);
1908+
1909+
let ast = run_and_ast("foo.bar()", vec![rule]);
1910+
let call = ast
1911+
.reachable_node_ids()
1912+
.into_iter()
1913+
.filter_map(|id| ast.get_node(id))
1914+
.find(|node| node.kind_name() == "call")
1915+
.expect("call exists");
1916+
assert_eq!(call.source_range(), None);
1917+
}
1918+
19011919
#[test]
19021920
fn test_multiple_location_fields_combine_iterable_locations() {
19031921
let rule: Rule = rule!(

0 commit comments

Comments
 (0)