From dae1d887d5e3f1b77acb65f566b6bd88028e0c88 Mon Sep 17 00:00:00 2001 From: Taus Date: Wed, 28 Oct 2026 13:43:51 +0000 Subject: [PATCH 1/2] 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::` which is quite ugly. Finally, having explicit location overrides and `location: none` at the same time is an error (that we discover during parsing). --- shared/yeast-macros/src/ast.rs | 2 + shared/yeast-macros/src/lib.rs | 1 + shared/yeast-macros/src/lower.rs | 14 +++++- shared/yeast-macros/src/template_parse.rs | 59 ++++++++++++++++++++--- shared/yeast/doc/yeast.md | 8 +++ shared/yeast/src/build.rs | 8 +++ shared/yeast/src/lib.rs | 12 +++++ shared/yeast/tests/test.rs | 18 +++++++ 8 files changed, 114 insertions(+), 8 deletions(-) diff --git a/shared/yeast-macros/src/ast.rs b/shared/yeast-macros/src/ast.rs index f400257fd1d4..518fa92e19cc 100644 --- a/shared/yeast-macros/src/ast.rs +++ b/shared/yeast-macros/src/ast.rs @@ -136,4 +136,6 @@ pub(crate) enum Field { /// A Rust block yielding the node or nodes whose locations should be combined. block: syn::Block, }, + /// Explicitly omit source-location metadata: `location: none`. + LocationNone, } diff --git a/shared/yeast-macros/src/lib.rs b/shared/yeast-macros/src/lib.rs index 7e7521a8b76f..de586f100f31 100644 --- a/shared/yeast-macros/src/lib.rs +++ b/shared/yeast-macros/src/lib.rs @@ -67,6 +67,7 @@ pub fn query(input: TokenStream) -> TokenStream { /// beneath it has a value (see below) /// location: {expr} - override this node's source range with the /// span of one or more nodes; may be repeated +/// location: none - explicitly make this node locationless /// ``` /// /// # Optional fields diff --git a/shared/yeast-macros/src/lower.rs b/shared/yeast-macros/src/lower.rs index e127423910d5..ad9435904774 100644 --- a/shared/yeast-macros/src/lower.rs +++ b/shared/yeast-macros/src/lower.rs @@ -388,6 +388,10 @@ impl Node { let mut stmts = Vec::new(); let mut field_args = Vec::new(); + let location_none = self + .fields + .iter() + .any(|field| matches!(field, Field::LocationNone)); for (field_counter, field) in self.fields.iter().enumerate() { match field { @@ -399,6 +403,7 @@ impl Node { #ctx.extend_location_ranges(__value, &mut __location_ranges); }); } + Field::LocationNone => {} Field::Named { name, value, @@ -475,8 +480,13 @@ impl Node { #node_init #(#stmts)* #build_node - let __source_range = __location_ranges.into_iter().reduce(yeast::Range::union); - #ctx.set_node_source_range(__node, __source_range) + if #location_none { + #ctx.set_node_locationless(__node) + } else { + let __source_range = + __location_ranges.into_iter().reduce(yeast::Range::union); + #ctx.set_node_source_range(__node, __source_range) + } } } } diff --git a/shared/yeast-macros/src/template_parse.rs b/shared/yeast-macros/src/template_parse.rs index 782407466a46..a4b857811203 100644 --- a/shared/yeast-macros/src/template_parse.rs +++ b/shared/yeast-macros/src/template_parse.rs @@ -68,6 +68,8 @@ fn parse_node(group: proc_macro2::Group) -> Result { }; let mut fields = Vec::new(); + let mut has_location = false; + let mut has_location_none = false; while peek_is_field(&mut tokens) { let name = expect_ident(&mut tokens, "expected field name")?; let is_location = name == "location"; @@ -79,20 +81,39 @@ fn parse_node(group: proc_macro2::Group) -> Result { } expect_punct(&mut tokens, ':', "expected `:` after field name")?; if is_location { - if !peek_is_group(&mut tokens, Delimiter::Brace) { + if matches!(tokens.peek(), Some(TokenTree::Ident(ident)) if ident == "none") { + let none = tokens.next().unwrap(); + if has_location || has_location_none { + return Err(syn::Error::new_spanned( + none, + "`location: none` cannot be combined with another `location` field", + )); + } + has_location_none = true; + fields.push(Field::LocationNone); + } else if peek_is_group(&mut tokens, Delimiter::Brace) { + if has_location_none { + return Err(syn::Error::new_spanned( + tokens.peek().unwrap().clone(), + "`location: none` cannot be combined with another `location` field", + )); + } + has_location = true; + let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?; + fields.push(Field::Location { block }); + } else { let error = match tokens.peek() { Some(token) => syn::Error::new_spanned( token.clone(), - "`location` field value must be a Rust block", + "`location` field value must be a Rust block or `none`", ), None => syn::Error::new( Span::call_site(), - "`location` field value must be a Rust block", + "`location` field value must be a Rust block or `none`", ), }; return Err(error); } - let block = parse_block(expect_group(&mut tokens, Delimiter::Brace)?)?; if let Some(TokenTree::Punct(punctuation)) = tokens.peek() { if punctuation.as_char() == '?' { return Err(syn::Error::new_spanned( @@ -101,7 +122,6 @@ fn parse_node(group: proc_macro2::Group) -> Result { )); } } - fields.push(Field::Location { block }); continue; } @@ -283,6 +303,19 @@ mod tests { assert!(matches!(node.fields.as_slice(), [Field::Location { .. }])); } + #[test] + fn parses_location_none() { + let template = parse_template(quote! { + (identifier "name" location: none) + }) + .unwrap(); + + let Template::Node(node) = template else { + panic!("expected node template"); + }; + assert!(matches!(node.fields.as_slice(), [Field::LocationNone])); + } + #[test] fn rejects_non_block_location_fields() { let result = parse_template(quote! { @@ -293,7 +326,21 @@ mod tests { }; assert_eq!( error.to_string(), - "`location` field value must be a Rust block" + "`location` field value must be a Rust block or `none`" + ); + } + + #[test] + fn rejects_location_none_with_other_locations() { + let result = parse_template(quote! { + (identifier "name" location: none location: {source}) + }); + let Err(error) = result else { + panic!("expected mixed location metadata to be rejected"); + }; + assert_eq!( + error.to_string(), + "`location: none` cannot be combined with another `location` field" ); } diff --git a/shared/yeast/doc/yeast.md b/shared/yeast/doc/yeast.md index b9ba08f57c56..01a21190cc5b 100644 --- a/shared/yeast/doc/yeast.md +++ b/shared/yeast/doc/yeast.md @@ -300,6 +300,14 @@ leaf nodes: The override applies only to the node carrying the `location` field; nested nodes still derive their locations normally. +Use `location: none` for a synthetic node that intentionally has no source +location. This also prevents a returned rule result from inheriting the matched +input range: + +```rust +(metadata_node location: none) +``` + For input fields whose leading or trailing syntax should never belong to rule results, configure them once with `DesugaringConfig::with_ignored_location_fields(...)`. For example, ignoring diff --git a/shared/yeast/src/build.rs b/shared/yeast/src/build.rs index f3075973be41..ef820224f3aa 100644 --- a/shared/yeast/src/build.rs +++ b/shared/yeast/src/build.rs @@ -187,6 +187,14 @@ impl<'a, C> BuildCtx<'a, C> { node } + /// Explicitly make a newly-built node locationless. + #[doc(hidden)] + pub fn set_node_locationless(&mut self, node: OutputId) -> OutputId { + self.output.clear_source_range(node); + self.created_nodes_without_source_range.remove(&node); + node + } + /// Return the immutable input AST. Available inside rule transforms. pub fn ast(&self) -> &Ast { self.ast diff --git a/shared/yeast/src/lib.rs b/shared/yeast/src/lib.rs index 142bbc7b6254..815115d928f0 100644 --- a/shared/yeast/src/lib.rs +++ b/shared/yeast/src/lib.rs @@ -815,6 +815,18 @@ impl Ast { node.source_range = Some(source_range); } + /// Remove the source range from a synthetic node. + pub(crate) fn clear_source_range(&mut self, id: Id) { + let node = self + .nodes + .get_mut(id.index()) + .unwrap_or_else(|| panic!("clear_source_range: invalid node id {}", id.index())); + if matches!(node.content, NodeContent::Range(_)) { + panic!("clear_source_range: cannot modify a parsed node"); + } + node.source_range = None; + } + /// Register a named node kind, returning its id (idempotent). Lets callers /// build an AST in a single pass, registering kinds as nodes are created /// rather than pre-populating the schema. diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index f53b3e22615a..813e4f5d7d91 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1705,6 +1705,24 @@ fn test_location_field_overrides_derived_location() { assert_eq!(call.byte_range(), 4..5); } +#[test] +fn test_location_none_makes_result_root_locationless() { + let rule: Rule = rule!( + (call method: (identifier) @method) + => + (call method: {method} location: none) + ); + + let ast = run_and_ast("foo.bar()", vec![rule]); + let call = ast + .reachable_node_ids() + .into_iter() + .filter_map(|id| ast.get_node(id)) + .find(|node| node.kind_name() == "call") + .expect("call exists"); + assert_eq!(call.source_range(), None); +} + #[test] fn test_multiple_location_fields_combine_iterable_locations() { let rule: Rule = rule!( From 6f3c8c05c3220aebf5f6f7ec16f599cae8c087ef Mon Sep 17 00:00:00 2001 From: Taus Date: Fri, 30 Oct 2026 16:55:38 +0100 Subject: [PATCH 2/2] yeast: Update documentation on `location: none` usage and add test Clarified usage of `location: none` for synthetic nodes and its effects on parent node ranges. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- shared/yeast/doc/yeast.md | 9 ++++++--- shared/yeast/tests/test.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/shared/yeast/doc/yeast.md b/shared/yeast/doc/yeast.md index 01a21190cc5b..e3f29480475e 100644 --- a/shared/yeast/doc/yeast.md +++ b/shared/yeast/doc/yeast.md @@ -300,9 +300,12 @@ leaf nodes: The override applies only to the node carrying the `location` field; nested nodes still derive their locations normally. -Use `location: none` for a synthetic node that intentionally has no source -location. This also prevents a returned rule result from inheriting the matched -input range: +As an exception to the Rust-block requirement above, use `location: none` for a +synthetic node that intentionally has no source location. The field-combination +rules above apply only to block-valued fields: `location: none` cannot be +combined with any other `location` field on the same node. A locationless node +does not contribute to its parent's derived range, and a returned rule result +marked this way does not inherit the matched input range: ```rust (metadata_node location: none) diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index 813e4f5d7d91..647c8652ae39 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1723,6 +1723,42 @@ fn test_location_none_makes_result_root_locationless() { assert_eq!(call.source_range(), None); } +/// A nested `location: none` node stays locationless and does not widen its +/// parent's derived range with an empty range at the match start. +#[test] +fn test_location_none_nested_node_does_not_widen_parent() { + let rule: Rule = rule!( + (call method: (identifier) @method) + => + (call + method: (identifier "outer") + receiver: (call + method: {method} + arguments: (argument_list location: none))) + ); + + let ast = run_and_ast("foo.bar()", vec![rule]); + let nodes: Vec<_> = ast + .reachable_node_ids() + .into_iter() + .filter_map(|id| ast.get_node(id)) + .collect(); + let arguments = nodes + .iter() + .find(|node| node.kind_name() == "argument_list") + .expect("argument list exists"); + assert_eq!(arguments.source_range(), None); + + let mut call_ranges: Vec<_> = nodes + .iter() + .filter(|node| node.kind_name() == "call") + .map(|node| node.byte_range()) + .collect(); + call_ranges.sort_by_key(|range| (range.start, range.end)); + // Without `location: none`, the inner call would be widened to 0..7. + assert_eq!(call_ranges, [0..9, 4..7]); +} + #[test] fn test_multiple_location_fields_combine_iterable_locations() { let rule: Rule = rule!(