Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions shared/yeast-macros/src/ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
1 change: 1 addition & 0 deletions shared/yeast-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions shared/yeast-macros/src/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -399,6 +403,7 @@ impl Node {
#ctx.extend_location_ranges(__value, &mut __location_ranges);
});
}
Field::LocationNone => {}
Field::Named {
name,
value,
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
59 changes: 53 additions & 6 deletions shared/yeast-macros/src/template_parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
};

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";
Expand All @@ -79,20 +81,39 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
}
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(
Expand All @@ -101,7 +122,6 @@ fn parse_node(group: proc_macro2::Group) -> Result<Node> {
));
}
}
fields.push(Field::Location { block });
continue;
}

Expand Down Expand Up @@ -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! {
Expand All @@ -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"
);
}

Expand Down
11 changes: 11 additions & 0 deletions shared/yeast/doc/yeast.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions shared/yeast/src/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions shared/yeast/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,18 @@ impl<Id: TreeId> Ast<Id> {
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.
Expand Down
54 changes: 54 additions & 0 deletions shared/yeast/tests/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
tausbn marked this conversation as resolved.
Resolved

/// 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!(
Expand Down
Loading