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..e3f29480475e 100644 --- a/shared/yeast/doc/yeast.md +++ b/shared/yeast/doc/yeast.md @@ -300,6 +300,17 @@ leaf nodes: The override applies only to the node carrying the `location` field; nested nodes still derive their locations normally. +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) +``` + 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..647c8652ae39 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -1705,6 +1705,60 @@ 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); +} + +/// 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!(