Skip to content

Commit c21bba7

Browse files
committed
yeast: Add location heuristic for synthetic nodes with no location set
For such nodes, we assign them the zero-length location at the beginning of the matched input node. Also adds a bunch of helpers for overriding this range, in particular source_range_of_all can be passed a selection of nodes, and produces a range encompassing all of them, whereas literal_at_start_of and literal_with_source_range_of create literals with the specified ranges. For now accessing this machinery can only happen inside of Rust blocks. If we find ourselves doing this kind of location massaging a lot, we may want to add a bespoke syntax for it.
1 parent c153984 commit c21bba7

5 files changed

Lines changed: 131 additions & 16 deletions

File tree

‎shared/yeast/doc/yeast.md‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,10 @@ yeast::trees!(ctx,
238238
### Source locations
239239

240240
Captured nodes keep the locations assigned by their own translations. New
241-
nodes in an output template derive their locations from their children. After
242-
the transform completes, the matched input node's range is added only to
243-
locally-created nodes returned as rule results:
241+
nodes in an output template derive their locations from their children. A
242+
source-less nested node receives an empty location at the start of the matched
243+
input node. After the transform completes, the full matched range is added only
244+
to locally-created nodes returned as rule results:
244245

245246
```rust
246247
rule!(
@@ -251,10 +252,12 @@ rule!(
251252
```
252253

253254
Here `inner` derives its range from `child`, while the returned `outer` node
254-
also includes the full `wrapper` range. This lets replacement roots include
255-
elided keywords or delimiters without assigning the same broad range to every
256-
synthetic descendant. A transform that simply returns a translated capture
257-
does not widen that capture to the wrapper's range.
255+
also includes the full `wrapper` range. A nested node with no located children
256+
would instead receive an empty range at the start of `wrapper`. This lets
257+
replacement roots include elided keywords or delimiters without assigning the
258+
same broad range to every synthetic descendant. A transform that simply
259+
returns a translated capture does not widen that capture to the wrapper's
260+
range.
258261

259262
Rust bodies can explicitly assign a default range to a synthetic subtree whose
260263
location cannot be derived from children:
@@ -265,6 +268,10 @@ let synthetic = ctx.with_node_source_range(ctx.matched_source_range, |ctx| {
265268
});
266269
```
267270

271+
For literals, `ctx.literal_with_source_range_of(...)` copies another node's
272+
exact range, while `ctx.literal_at_start_of(...)` creates an empty range at
273+
that node's start.
274+
268275
### Optional fields (`?`)
269276

270277
A `?` on a field's value makes that field fallible. If a `#{expr}` anywhere

‎shared/yeast/src/build.rs‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ pub struct BuildCtx<'a, C: 'a = ()> {
4040
///
4141
/// The `rule!` macro applies this range to locally-created result roots
4242
/// after the transform completes. Nested synthetic nodes derive their
43-
/// ranges from their children unless `source_range` is explicitly set.
43+
/// ranges from their children, falling back to an empty range at this
44+
/// range's start.
4445
pub matched_source_range: Option<Range>,
4546
/// User-supplied context, accessible directly via `ctx.field` (via Deref).
4647
pub user_ctx: &'a mut C,
@@ -128,6 +129,16 @@ impl<'a, C> BuildCtx<'a, C> {
128129
let id = self
129130
.ast
130131
.create_node_with_range(kind, content, fields, is_named, source_range);
132+
if self
133+
.ast
134+
.get_node(id)
135+
.is_some_and(|node| node.source_range().is_none())
136+
{
137+
if let Some(source_range) = self.matched_source_range {
138+
self.ast
139+
.extend_source_range(id, source_range.empty_at_start());
140+
}
141+
}
131142
if !has_explicit_source_range {
132143
self.created_nodes.insert(id);
133144
}
@@ -153,6 +164,10 @@ impl<'a, C> BuildCtx<'a, C> {
153164
source_range: Option<Range>,
154165
) -> Id {
155166
let has_explicit_source_range = source_range.is_some();
167+
let source_range = source_range.or_else(|| {
168+
self.matched_source_range
169+
.map(|range| range.empty_at_start())
170+
});
156171
let id = self
157172
.ast
158173
.create_named_token_with_range(kind, content, source_range);
@@ -219,6 +234,25 @@ impl<'a, C> BuildCtx<'a, C> {
219234
self.ast.source_text(id)
220235
}
221236

237+
/// Return the source range of a parsed or synthetic node.
238+
pub fn source_range_of(&self, id: Id) -> Option<Range> {
239+
self.ast.get_node(id).and_then(|node| node.source_range())
240+
}
241+
242+
/// Return the smallest source range containing all located nodes.
243+
pub fn source_range_of_all(&self, ids: impl IntoIterator<Item = Id>) -> Option<Range> {
244+
ids.into_iter()
245+
.filter_map(|id| self.source_range_of(id))
246+
.reduce(Range::union)
247+
}
248+
249+
/// Return an empty range between two non-overlapping nodes.
250+
pub fn empty_source_range_between(&self, left: Id, right: Id) -> Option<Range> {
251+
let left = self.source_range_of(left)?;
252+
let right = self.source_range_of(right)?;
253+
(left.end_byte <= right.start_byte).then(|| left.empty_at_end())
254+
}
255+
222256
/// Create a named AST node with the given kind and fields.
223257
pub fn node(&mut self, kind: &str, fields: Vec<(&str, Vec<Id>)>) -> Id {
224258
let kind_id = self
@@ -262,6 +296,23 @@ impl<'a, C> BuildCtx<'a, C> {
262296
)
263297
}
264298

299+
/// Create a literal using another node's exact source range.
300+
pub fn literal_with_source_range_of(
301+
&mut self,
302+
kind: &'static str,
303+
value: &str,
304+
source: Id,
305+
) -> Id {
306+
let source_range = self.source_range_of(source);
307+
self.literal_with_source_range(kind, value, source_range)
308+
}
309+
310+
/// Create a literal with an empty range at another node's start.
311+
pub fn literal_at_start_of(&mut self, kind: &'static str, value: &str, source: Id) -> Id {
312+
let source_range = self.source_range_of(source).map(Range::empty_at_start);
313+
self.literal_with_source_range(kind, value, source_range)
314+
}
315+
265316
/// Create a leaf node with an auto-generated unique name.
266317
pub fn fresh(&mut self, kind: &'static str, name: &str) -> Id {
267318
let generated = self.fresh.resolve(name);

‎shared/yeast/tests/test.rs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,34 @@ fn test_nested_synthetic_node_uses_child_location() {
17091709
assert_eq!(arguments.byte_range(), 0..3);
17101710
}
17111711

1712+
/// A nested node with no explicit range or located children gets an empty
1713+
/// location at the start of the rule's matched node.
1714+
#[test]
1715+
fn test_source_less_nested_node_uses_empty_match_start() {
1716+
let rule: Rule = rule!(
1717+
(call
1718+
method: (identifier) @name
1719+
receiver: (identifier) @recv
1720+
)
1721+
=>
1722+
(call
1723+
method: {name}
1724+
receiver: {recv}
1725+
arguments: (argument_list)
1726+
)
1727+
);
1728+
1729+
let ast = run_and_ast("foo.bar()", vec![rule]);
1730+
let arguments = ast
1731+
.reachable_node_ids()
1732+
.into_iter()
1733+
.filter_map(|id| ast.get_node(id))
1734+
.find(|node| node.kind_name() == "argument_list")
1735+
.expect("argument list exists");
1736+
let range = arguments.source_range().unwrap();
1737+
assert_eq!(range.start_byte..range.end_byte, 0..0);
1738+
}
1739+
17121740
/// An explicit empty range at byte zero is a real location, not the sentinel
17131741
/// for an absent location, and therefore contributes to parent ranges.
17141742
#[test]

‎unified/extractor/tests/location_tests.rs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ fn assert_has_span(ast: &Ast, source: &str, kind: &str, content: Option<&str>, e
5151
);
5252
}
5353

54+
fn assert_has_empty_span(ast: &Ast, kind: &str, content: Option<&str>, expected_offset: usize) {
55+
let ranges = ranges(ast, kind, content);
56+
assert!(
57+
ranges
58+
.iter()
59+
.any(|range| range.start == expected_offset && range.end == expected_offset),
60+
"expected {kind} {content:?} to have an empty span at {expected_offset}, got {ranges:?}"
61+
);
62+
}
63+
5464
#[test]
5565
fn generic_type_children_have_local_ranges() {
5666
let source = "let x = C<Foo>()";
@@ -61,6 +71,25 @@ fn generic_type_children_have_local_ranges() {
6171
assert_has_span(&ast, source, "identifier", Some("Foo"), "Foo");
6272
}
6373

74+
#[test]
75+
fn synthetic_type_and_modifier_nodes_use_empty_scope_start_ranges() {
76+
let source = "let array: [T]\nlet optional: T?\nenum E { case a, b }";
77+
let ast = desugar(source);
78+
79+
assert_has_empty_span(&ast, "identifier", Some("Array"), source.find('[').unwrap());
80+
assert_has_empty_span(
81+
&ast,
82+
"identifier",
83+
Some("Optional"),
84+
source.find("T?").unwrap(),
85+
);
86+
let case_a = source.find("a, b").unwrap();
87+
let case_b = case_a + "a, ".len();
88+
assert_has_empty_span(&ast, "modifier", Some("enum_case"), case_a);
89+
assert_has_empty_span(&ast, "modifier", Some("enum_case"), case_b);
90+
assert_has_empty_span(&ast, "modifier", Some("chained_declaration"), case_b);
91+
}
92+
6493
#[test]
6594
fn import_member_chain_excludes_import_keyword() {
6695
let source = "import Foundation.Networking.URLSession";

‎unified/ql/test/library-tests/BasicTest/test.expected‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,13 @@ identifier
22
| name_expr.swift:1:5:1:5 | x | x |
33
| name_expr.swift:1:9:1:9 | y | y |
44
| strings.swift:1:5:1:5 | x | x |
5-
| test.swift:1:1:1:0 | Array | Array |
6-
| test.swift:1:1:1:0 | Array | Array |
7-
| test.swift:1:1:1:0 | Array | Array |
8-
| test.swift:1:1:1:0 | Array | Array |
9-
| test.swift:1:1:1:0 | Optional | Optional |
10-
| test.swift:1:1:1:0 | Optional | Optional |
11-
| test.swift:1:1:1:0 | Optional | Optional |
125
| test.swift:1:8:1:17 | Foundation | Foundation |
136
| test.swift:1:8:1:17 | Foundation | Foundation |
147
| test.swift:4:8:4:16 | Container | Container |
158
| test.swift:4:18:4:18 | T | T |
169
| test.swift:4:21:4:29 | Equatable | Equatable |
1710
| test.swift:5:9:5:13 | items | items |
11+
| test.swift:5:16:5:15 | Array | Array |
1812
| test.swift:5:17:5:17 | T | T |
1913
| test.swift:7:19:7:21 | add | add |
2014
| test.swift:7:23:7:23 | _ | _ |
@@ -39,6 +33,7 @@ identifier
3933
| test.swift:20:15:20:16 | at | at |
4034
| test.swift:20:18:20:22 | index | index |
4135
| test.swift:20:25:20:27 | Int | Int |
36+
| test.swift:20:33:20:32 | Optional | Optional |
4237
| test.swift:20:33:20:39 | Element | Element |
4338
| test.swift:24:6:24:10 | merge | merge |
4439
| test.swift:24:12:24:12 | T | T |
@@ -49,6 +44,7 @@ identifier
4944
| test.swift:24:39:24:39 | _ | _ |
5045
| test.swift:24:41:24:46 | second | second |
5146
| test.swift:24:49:24:49 | T | T |
47+
| test.swift:24:55:24:54 | Array | Array |
5248
| test.swift:24:56:24:56 | T | T |
5349
| test.swift:24:58:24:64 | Element | Element |
5450
| test.swift:25:9:25:14 | result | result |
@@ -69,6 +65,7 @@ identifier
6965
| test.swift:36:15:36:21 | Element | Element |
7066
| test.swift:36:25:36:25 | T | T |
7167
| test.swift:37:17:37:20 | data | data |
68+
| test.swift:37:23:37:22 | Array | Array |
7269
| test.swift:37:24:37:24 | T | T |
7370
| test.swift:39:9:39:13 | count | count |
7471
| test.swift:39:16:39:18 | Int | Int |
@@ -82,6 +79,7 @@ identifier
8279
| test.swift:47:15:47:16 | at | at |
8380
| test.swift:47:18:47:22 | index | index |
8481
| test.swift:47:25:47:27 | Int | Int |
82+
| test.swift:47:33:47:32 | Optional | Optional |
8583
| test.swift:47:33:47:33 | T | T |
8684
| test.swift:48:15:48:19 | index | index |
8785
| test.swift:48:29:48:33 | index | index |
@@ -135,11 +133,13 @@ identifier
135133
| test.swift:85:14:85:14 | T | T |
136134
| test.swift:85:17:85:17 | _ | _ |
137135
| test.swift:85:19:85:24 | values | values |
136+
| test.swift:85:27:85:26 | Array | Array |
138137
| test.swift:85:28:85:28 | T | T |
139138
| test.swift:85:32:85:40 | transform | transform |
140139
| test.swift:85:44:85:44 | T | T |
141140
| test.swift:85:47:85:47 | T | T |
142141
| test.swift:85:53:85:53 | T | T |
142+
| test.swift:85:59:85:58 | Optional | Optional |
143143
| test.swift:85:59:85:59 | T | T |
144144
| test.swift:86:12:86:17 | values | values |
145145
| test.swift:86:19:86:25 | isEmpty | isEmpty |
@@ -149,7 +149,7 @@ identifier
149149
| test.swift:87:38:87:43 | values | values |
150150
| test.swift:87:49:87:57 | transform | transform |
151151
namedPattern
152-
| test.swift:1:8:1:17 | NamedPattern | Foundation |
152+
| test.swift:1:1:1:17 | NamedPattern | Foundation |
153153
unsupported
154154
stringValue
155155
| strings.swift:1:9:1:15 | "hello" | "hello" |

0 commit comments

Comments
 (0)