Skip to content

Commit 6c74cd3

Browse files
committed
Yeast: use child locations instead of rule target
Previously, when a node was synthesized it would always take the location from the node that matched the current rule. This resulted in overly broad locations however. For (foo #{bar}) we now take the location of the 'bar' node. For non-leaf nodes we merge all its child node locations.
1 parent 166406a commit 6c74cd3

4 files changed

Lines changed: 162 additions & 2 deletions

File tree

shared/yeast-macros/src/parse.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,10 @@ fn parse_direct_node_inner(tokens: &mut Tokens, ctx: &Ident) -> Result<TokenStre
396396
let expr = group.stream();
397397
return Ok(quote! {
398398
{
399-
let __value = yeast::YeastDisplay::yeast_to_string(&(#expr), &*#ctx.ast);
400-
#ctx.literal(#kind_str, &__value)
399+
let __expr = (#expr);
400+
let __value = yeast::YeastDisplay::yeast_to_string(&__expr, &*#ctx.ast);
401+
let __source_range = yeast::YeastSourceRange::yeast_source_range(&__expr, &*#ctx.ast);
402+
#ctx.literal_with_source_range(#kind_str, &__value, __source_range)
401403
}
402404
});
403405
}

shared/yeast/src/build.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ impl<'a> BuildCtx<'a> {
8282
.create_named_token_with_range(kind, value.to_string(), self.source_range)
8383
}
8484

85+
/// Create a leaf node with fixed content and an optional preferred source range.
86+
/// If `source_range` is `None`, falls back to this context's inherited range.
87+
pub fn literal_with_source_range(
88+
&mut self,
89+
kind: &'static str,
90+
value: &str,
91+
source_range: Option<tree_sitter::Range>,
92+
) -> Id {
93+
self.ast.create_named_token_with_range(
94+
kind,
95+
value.to_string(),
96+
source_range.or(self.source_range),
97+
)
98+
}
99+
85100
/// Create a leaf node with an auto-generated unique name.
86101
pub fn fresh(&mut self, kind: &'static str, name: &str) -> Id {
87102
let generated = self.fresh.resolve(name);

shared/yeast/src/lib.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,30 @@ pub trait YeastDisplay {
5858
fn yeast_to_string(&self, ast: &Ast) -> String;
5959
}
6060

61+
/// Optional source range for values used in `#{expr}` interpolations.
62+
///
63+
/// By default this returns `None`, so synthesized leaves inherit the matched
64+
/// rule's source range. `NodeRef` returns the referenced node's range, letting
65+
/// `(kind #{capture})` carry the captured node's location.
66+
pub trait YeastSourceRange {
67+
fn yeast_source_range(&self, ast: &Ast) -> Option<tree_sitter::Range>;
68+
}
69+
6170
impl YeastDisplay for NodeRef {
6271
fn yeast_to_string(&self, ast: &Ast) -> String {
6372
ast.source_text(self.0)
6473
}
6574
}
6675

76+
impl YeastSourceRange for NodeRef {
77+
fn yeast_source_range(&self, ast: &Ast) -> Option<tree_sitter::Range> {
78+
ast.get_node(self.0).and_then(|n| match &n.content {
79+
NodeContent::Range(r) => Some(r.clone()),
80+
_ => n.source_range,
81+
})
82+
}
83+
}
84+
6785
macro_rules! impl_yeast_display_via_display {
6886
($($t:ty),* $(,)?) => {
6987
$(
@@ -72,6 +90,12 @@ macro_rules! impl_yeast_display_via_display {
7290
::std::string::ToString::to_string(self)
7391
}
7492
}
93+
94+
impl YeastSourceRange for $t {
95+
fn yeast_source_range(&self, _ast: &Ast) -> Option<tree_sitter::Range> {
96+
None
97+
}
98+
}
7599
)*
76100
};
77101
}
@@ -90,6 +114,12 @@ impl<T: YeastDisplay + ?Sized> YeastDisplay for &T {
90114
}
91115
}
92116

117+
impl<T: YeastSourceRange + ?Sized> YeastSourceRange for &T {
118+
fn yeast_source_range(&self, ast: &Ast) -> Option<tree_sitter::Range> {
119+
(**self).yeast_source_range(ast)
120+
}
121+
}
122+
93123
pub const CHILD_FIELD: u16 = u16::MAX;
94124

95125
#[derive(Debug)]
@@ -368,6 +398,15 @@ impl Ast {
368398
is_named: bool,
369399
source_range: Option<tree_sitter::Range>,
370400
) -> Id {
401+
let source_range = match &content {
402+
// Parsed nodes already carry an exact source range in their content.
403+
NodeContent::Range(_) => source_range,
404+
// Synthesized nodes derive location from children when possible,
405+
// and fall back to the inherited rule-match range otherwise.
406+
_ => self
407+
.union_source_range_of_children(&fields)
408+
.or(source_range),
409+
};
371410
let id = self.nodes.len();
372411
self.nodes.push(Node {
373412
kind,
@@ -383,6 +422,66 @@ impl Ast {
383422
id
384423
}
385424

425+
fn union_source_range_of_children(
426+
&self,
427+
fields: &BTreeMap<FieldId, Vec<Id>>,
428+
) -> Option<tree_sitter::Range> {
429+
let mut start_byte: Option<usize> = None;
430+
let mut end_byte: Option<usize> = None;
431+
let mut start_point = tree_sitter::Point { row: 0, column: 0 };
432+
let mut end_point = tree_sitter::Point { row: 0, column: 0 };
433+
434+
for child_ids in fields.values() {
435+
for &child_id in child_ids {
436+
let Some(child) = self.get_node(child_id) else {
437+
continue;
438+
};
439+
440+
let child_start_byte = child.start_byte();
441+
let child_end_byte = child.end_byte();
442+
443+
// Skip children that carry no usable location.
444+
if child_start_byte == 0 && child_end_byte == 0 {
445+
continue;
446+
}
447+
448+
match start_byte {
449+
None => {
450+
start_byte = Some(child_start_byte);
451+
start_point = child.start_position();
452+
}
453+
Some(current_start) if child_start_byte < current_start => {
454+
start_byte = Some(child_start_byte);
455+
start_point = child.start_position();
456+
}
457+
_ => {}
458+
}
459+
460+
match end_byte {
461+
None => {
462+
end_byte = Some(child_end_byte);
463+
end_point = child.end_position();
464+
}
465+
Some(current_end) if child_end_byte > current_end => {
466+
end_byte = Some(child_end_byte);
467+
end_point = child.end_position();
468+
}
469+
_ => {}
470+
}
471+
}
472+
}
473+
474+
match (start_byte, end_byte) {
475+
(Some(start_byte), Some(end_byte)) => Some(tree_sitter::Range {
476+
start_byte,
477+
end_byte,
478+
start_point,
479+
end_point,
480+
}),
481+
_ => None,
482+
}
483+
}
484+
386485
pub fn create_named_token(&mut self, kind: &'static str, content: String) -> Id {
387486
self.create_named_token_with_range(kind, content, None)
388487
}

shared/yeast/tests/test.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ fn run_and_dump(input: &str, rules: Vec<Rule>) -> String {
1818
run_phased_and_dump(input, vec![Phase::new("test", PhaseKind::Repeating, rules)])
1919
}
2020

21+
/// Helper: parse Ruby source with custom rules and return the transformed AST.
22+
fn run_and_ast(input: &str, rules: Vec<Rule>) -> Ast {
23+
let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
24+
let schema =
25+
yeast::node_types_yaml::schema_from_yaml_with_language(OUTPUT_SCHEMA_YAML, &lang).unwrap();
26+
let phases = vec![Phase::new("test", PhaseKind::Repeating, rules)];
27+
let runner = Runner::with_schema(lang, &schema, &phases);
28+
runner.run(input).unwrap()
29+
}
30+
2131
/// Helper: parse Ruby source with a custom output schema and multiple
2232
/// rule phases, return dump.
2333
fn run_phased_and_dump(input: &str, phases: Vec<Phase>) -> String {
@@ -1172,3 +1182,37 @@ fn test_hash_brace_renders_integer_expression() {
11721182
"#,
11731183
);
11741184
}
1185+
1186+
/// Regression test: `(kind #{capture})` should inherit the captured node's
1187+
/// source location, not the full source range of the matched rule root.
1188+
#[test]
1189+
fn test_hash_brace_uses_capture_location_for_leaf() {
1190+
let rule = rule!(
1191+
(call
1192+
method: (identifier) @name
1193+
receiver: (identifier) @recv
1194+
)
1195+
=>
1196+
(call
1197+
method: (identifier #{name})
1198+
receiver: (identifier #{recv})
1199+
arguments: (argument_list)
1200+
)
1201+
);
1202+
1203+
let ast = run_and_ast("foo.bar()", vec![rule]);
1204+
1205+
let mut bar_ids: Vec<usize> = Vec::new();
1206+
for id in ast.reachable_node_ids() {
1207+
let Some(node) = ast.get_node(id) else { continue; };
1208+
if node.kind() == "identifier" && ast.source_text(id) == "bar" {
1209+
bar_ids.push(id);
1210+
}
1211+
}
1212+
1213+
assert_eq!(bar_ids.len(), 1, "expected exactly one identifier 'bar'");
1214+
let bar = ast.get_node(bar_ids[0]).unwrap();
1215+
1216+
assert_eq!(bar.start_byte(), 4);
1217+
assert_eq!(bar.end_byte(), 7);
1218+
}

0 commit comments

Comments
 (0)