Skip to content

Commit d3c4a3b

Browse files
authored
Merge pull request #22959 from github/tausbn/yeast-explicit-locationless-nodes
yeast: Add explicit locationless nodes
2 parents 75faf4c + 3f79f52 commit d3c4a3b

8 files changed

Lines changed: 202 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
@@ -147,6 +147,8 @@ pub(crate) enum Field {
147147
/// A Rust block yielding the node or nodes whose locations should be combined.
148148
block: syn::Block,
149149
},
150+
/// Explicitly omit source-location metadata: `location: none`.
151+
LocationNone,
150152
}
151153

152154
impl Rule {

‎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(
@@ -517,6 +516,10 @@ impl Node {
517516

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

521524
for (field_counter, field) in self.fields.iter().enumerate() {
522525
match field {
@@ -528,6 +531,7 @@ impl Node {
528531
#ctx.extend_location_ranges(__value, &mut __location_ranges);
529532
});
530533
}
534+
Field::LocationNone => {}
531535
Field::Named { name, value } => {
532536
let field_name = name.to_string();
533537
let field_str = field_name
@@ -592,8 +596,13 @@ impl Node {
592596
#node_init
593597
#(#stmts)*
594598
#build_node
595-
let __source_range = __location_ranges.into_iter().reduce(yeast::Range::union);
596-
#ctx.set_node_source_range(__node, __source_range)
599+
if #location_none {
600+
#ctx.set_node_locationless(__node)
601+
} else {
602+
let __source_range =
603+
__location_ranges.into_iter().reduce(yeast::Range::union);
604+
#ctx.set_node_source_range(__node, __source_range)
605+
}
597606
}
598607
}
599608
}

‎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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,17 @@ leaf nodes:
300300
The override applies only to the node carrying the `location` field; nested
301301
nodes still derive their locations normally.
302302

303+
As an exception to the Rust-block requirement above, use `location: none` for a
304+
synthetic node that intentionally has no source location. The field-combination
305+
rules above apply only to block-valued fields: `location: none` cannot be
306+
combined with any other `location` field on the same node. A locationless node
307+
does not contribute to its parent's derived range, and a returned rule result
308+
marked this way does not inherit the matched input range:
309+
310+
```rust
311+
(metadata_node location: none)
312+
```
313+
303314
For input fields whose leading or trailing syntax should never belong to rule
304315
results, configure them once with
305316
`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: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1981,6 +1981,60 @@ fn test_location_field_overrides_derived_location() {
19811981
assert_eq!(call.byte_range(), 4..5);
19821982
}
19831983

1984+
#[test]
1985+
fn test_location_none_makes_result_root_locationless() {
1986+
let rule: Rule = rule!(
1987+
(call method: (identifier) @method)
1988+
=>
1989+
(call method: {method} location: none)
1990+
);
1991+
1992+
let ast = run_and_ast("foo.bar()", vec![rule]);
1993+
let call = ast
1994+
.reachable_node_ids()
1995+
.into_iter()
1996+
.filter_map(|id| ast.get_node(id))
1997+
.find(|node| node.kind_name() == "call")
1998+
.expect("call exists");
1999+
assert_eq!(call.source_range(), None);
2000+
}
2001+
2002+
/// A nested `location: none` node stays locationless and does not widen its
2003+
/// parent's derived range with an empty range at the match start.
2004+
#[test]
2005+
fn test_location_none_nested_node_does_not_widen_parent() {
2006+
let rule: Rule = rule!(
2007+
(call method: (identifier) @method)
2008+
=>
2009+
(call
2010+
method: (identifier "outer")
2011+
receiver: (call
2012+
method: {method}
2013+
arguments: (argument_list location: none)))
2014+
);
2015+
2016+
let ast = run_and_ast("foo.bar()", vec![rule]);
2017+
let nodes: Vec<_> = ast
2018+
.reachable_node_ids()
2019+
.into_iter()
2020+
.filter_map(|id| ast.get_node(id))
2021+
.collect();
2022+
let arguments = nodes
2023+
.iter()
2024+
.find(|node| node.kind_name() == "argument_list")
2025+
.expect("argument list exists");
2026+
assert_eq!(arguments.source_range(), None);
2027+
2028+
let mut call_ranges: Vec<_> = nodes
2029+
.iter()
2030+
.filter(|node| node.kind_name() == "call")
2031+
.map(|node| node.byte_range())
2032+
.collect();
2033+
call_ranges.sort_by_key(|range| (range.start, range.end));
2034+
// Without `location: none`, the inner call would be widened to 0..7.
2035+
assert_eq!(call_ranges, [0..9, 4..7]);
2036+
}
2037+
19842038
#[test]
19852039
fn test_multiple_location_fields_combine_iterable_locations() {
19862040
let rule: Rule = rule!(

0 commit comments

Comments
 (0)