Skip to content

Commit 2f0f02b

Browse files
committed
yeast: Add source skeletons to AST dumps
With this change, output AST nodes (with locations that span part of the source code) now get an additional `source=...` annotation that compactly expresses how much of the source code is included in the location. For example, a variable declaration might look like this: ``` variable_declaration source="⟨modifier⟩ ⟨pattern⟩ = ⟨value⟩" ``` Here, the bits inside Unicode angle brackets correspond to sub-ranges of the input that are owned by direct children of the node in question. The string inside the brackets gives the name of the corresponding field. (I chose Unicode angle brackets because they are unlikely to occur naturally in source code.) This provides us with (hopefully) an intuitive and compact way of representing locations, without needing to list particular row and column offsets (which are impossible to inspect manually anyway).
1 parent fe7571d commit 2f0f02b

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

‎shared/yeast/doc/yeast.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,17 @@ yeast::trees!(ctx,
235235
(identifier #{name}) // an identifier from a Rust variable
236236
```
237237

238+
For reviewing locations, `DumpOptions::show_abridged_source` prints each node's
239+
source range with every direct child replaced by its field name in Unicode
240+
angle brackets. This keeps delimiters and other parent-owned syntax visible
241+
without repeating entire subtrees:
242+
243+
```text
244+
return_expr source="return ⟨value⟩"
245+
value:
246+
call_expr source="⟨callee⟩(⟨argument⟩)"
247+
```
248+
238249
### Optional fields (`?`)
239250

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

‎shared/yeast/src/dump.rs‎

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@ pub struct DumpOptions {
1515
pub show_locations: bool,
1616
/// Whether to include source text for leaf nodes.
1717
pub show_content: bool,
18+
/// Whether to include each node's source range with direct-child ranges
19+
/// replaced by their field names in `⟨angle brackets⟩`.
20+
pub show_abridged_source: bool,
1821
}
1922

2023
impl Default for DumpOptions {
2124
fn default() -> Self {
2225
Self {
2326
show_locations: false,
2427
show_content: true,
28+
show_abridged_source: false,
2529
}
2630
}
2731
}
@@ -226,6 +230,10 @@ fn dump_node(
226230
}
227231
}
228232

233+
if options.show_abridged_source {
234+
write_source_skeleton(ast, node, source, out);
235+
}
236+
229237
if let Some(context) = type_check {
230238
if let Some(err) =
231239
type_error_for_node(context.schema, node, context.expected, context.parent_field)
@@ -409,6 +417,10 @@ fn dump_node_inline(
409417
}
410418
}
411419

420+
if options.show_abridged_source {
421+
write_source_skeleton(ast, node, source, out);
422+
}
423+
412424
if let Some(context) = type_check {
413425
if let Some(err) =
414426
type_error_for_node(context.schema, node, context.expected, context.parent_field)
@@ -424,6 +436,246 @@ fn is_leaf(node: &Node) -> bool {
424436
node.fields.is_empty()
425437
}
426438

439+
enum SourceSkeleton {
440+
Missing,
441+
Text(String),
442+
Invalid(String),
443+
}
444+
445+
fn write_source_skeleton(ast: &Ast, node: &Node, source: &str, out: &mut String) {
446+
match source_skeleton(ast, node, source) {
447+
SourceSkeleton::Missing => write!(out, " source=<no location>").unwrap(),
448+
SourceSkeleton::Text(text) => write!(out, " source={text:?}").unwrap(),
449+
SourceSkeleton::Invalid(error) => write!(out, " source=<invalid: {error}>").unwrap(),
450+
}
451+
}
452+
453+
fn node_source_range(node: &Node) -> Option<crate::Range> {
454+
match node.content {
455+
NodeContent::Range(range) => Some(range),
456+
_ => node.source_range,
457+
}
458+
}
459+
460+
fn source_skeleton(ast: &Ast, node: &Node, source: &str) -> SourceSkeleton {
461+
let Some(parent) = node_source_range(node) else {
462+
return SourceSkeleton::Missing;
463+
};
464+
let parent = parent.start_byte..parent.end_byte;
465+
if parent.start > parent.end || source.get(parent.clone()).is_none() {
466+
return SourceSkeleton::Invalid(format!(
467+
"node range {}..{} is outside the source or not on UTF-8 boundaries",
468+
parent.start, parent.end
469+
));
470+
}
471+
472+
let mut children = Vec::new();
473+
for (field_id, child_ids) in &node.fields {
474+
let field_name = if *field_id == CHILD_FIELD {
475+
"child"
476+
} else {
477+
ast.field_name_for_id(*field_id).unwrap_or("?")
478+
};
479+
for child_id in child_ids {
480+
let Some(child) = ast.get_node(*child_id) else {
481+
continue;
482+
};
483+
if !child.is_named() {
484+
continue;
485+
}
486+
let Some(child) = node_source_range(child) else {
487+
continue;
488+
};
489+
let child = child.start_byte..child.end_byte;
490+
if child.start > child.end || source.get(child.clone()).is_none() {
491+
return SourceSkeleton::Invalid(format!(
492+
"child range {}..{} is outside the source or not on UTF-8 boundaries",
493+
child.start, child.end
494+
));
495+
}
496+
if child.start < parent.start || child.end > parent.end {
497+
return SourceSkeleton::Invalid(format!(
498+
"child range {}..{} is outside node range {}..{}",
499+
child.start, child.end, parent.start, parent.end
500+
));
501+
}
502+
if child.start == child.end {
503+
continue;
504+
}
505+
children.push((child, field_name));
506+
}
507+
}
508+
children.sort_by_key(|(range, field_name)| (range.start, range.end, *field_name));
509+
510+
let mut result = String::new();
511+
let mut cursor = parent.start;
512+
for (range, field_name) in children {
513+
if cursor < range.start {
514+
result.push_str(source.get(cursor..range.start).unwrap());
515+
}
516+
result.push('⟨');
517+
result.push_str(field_name);
518+
result.push('⟩');
519+
cursor = cursor.max(range.end);
520+
}
521+
result.push_str(source.get(cursor..parent.end).unwrap());
522+
SourceSkeleton::Text(result)
523+
}
524+
525+
#[cfg(test)]
526+
mod tests {
527+
use super::*;
528+
use crate::{NodeContent, Point, Range};
529+
use std::collections::BTreeMap;
530+
531+
fn range(start: usize, end: usize) -> Range {
532+
Range {
533+
start_byte: start,
534+
end_byte: end,
535+
start_point: Point::new(0, start),
536+
end_point: Point::new(0, end),
537+
}
538+
}
539+
540+
fn dump_with_children(source: &str, parent_range: Range, children: &[(&str, Range)]) -> String {
541+
let mut ast = Ast::with_schema(crate::schema::Schema::new());
542+
let parent_kind = ast.register_kind("parent");
543+
let child_kind = ast.register_kind("child");
544+
let mut fields = BTreeMap::new();
545+
for (field_name, range) in children {
546+
let field = ast.register_field(field_name);
547+
let child = ast.create_node_with_range(
548+
child_kind,
549+
NodeContent::Range(*range),
550+
BTreeMap::new(),
551+
true,
552+
None,
553+
);
554+
fields.entry(field).or_insert_with(Vec::new).push(child);
555+
}
556+
let parent = ast.create_node_with_range(
557+
parent_kind,
558+
NodeContent::Range(parent_range),
559+
fields,
560+
true,
561+
None,
562+
);
563+
ast.set_root(parent);
564+
565+
dump_ast_with_options(
566+
&ast,
567+
parent,
568+
source,
569+
&DumpOptions {
570+
show_locations: false,
571+
show_content: false,
572+
show_abridged_source: true,
573+
},
574+
)
575+
}
576+
577+
#[test]
578+
fn source_skeleton_elides_direct_children_and_ignores_empty_ranges() {
579+
let source = "αbefore(child)afterω";
580+
let child_start = source.find("child").unwrap();
581+
let child_end = child_start + "child".len();
582+
let dump = dump_with_children(
583+
source,
584+
range(0, source.len()),
585+
&[
586+
("value", range(child_start, child_end)),
587+
("marker", range(child_start, child_start)),
588+
],
589+
);
590+
591+
assert!(dump.starts_with("parent source=\"αbefore(⟨value⟩)afterω\"\n"));
592+
}
593+
594+
#[test]
595+
fn source_skeleton_preserves_unnamed_tokens() {
596+
let source = "x = 1";
597+
let runner: crate::Runner = crate::Runner::new(tree_sitter_ruby::LANGUAGE.into(), &[]);
598+
let ast = runner.run(source).unwrap();
599+
let dump = dump_ast_with_options(
600+
&ast,
601+
ast.get_root(),
602+
source,
603+
&DumpOptions {
604+
show_locations: false,
605+
show_content: false,
606+
show_abridged_source: true,
607+
},
608+
);
609+
610+
assert!(dump.contains("assignment source=\"⟨left⟩ = ⟨right⟩\""));
611+
}
612+
613+
#[test]
614+
fn source_skeleton_validates_empty_child_ranges() {
615+
let cases = [
616+
(
617+
"abcdef",
618+
range(0, 3),
619+
range(4, 4),
620+
"child range 4..4 is outside node range 0..3",
621+
),
622+
(
623+
"abcdef",
624+
range(0, 6),
625+
range(7, 7),
626+
"child range 7..7 is outside the source or not on UTF-8 boundaries",
627+
),
628+
(
629+
"αbc",
630+
range(0, 4),
631+
range(1, 1),
632+
"child range 1..1 is outside the source or not on UTF-8 boundaries",
633+
),
634+
];
635+
636+
for (source, parent, child, error) in cases {
637+
let dump = dump_with_children(source, parent, &[("marker", child)]);
638+
assert!(
639+
dump.starts_with(&format!("parent source=<invalid: {error}>\n")),
640+
"unexpected dump: {dump}"
641+
);
642+
}
643+
}
644+
645+
#[test]
646+
fn source_skeleton_keeps_adjacent_child_fields_separate() {
647+
let source = "abcdef";
648+
let dump = dump_with_children(
649+
source,
650+
range(0, source.len()),
651+
&[("left", range(1, 3)), ("right", range(3, 5))],
652+
);
653+
654+
assert!(dump.starts_with("parent source=\"a⟨left⟩⟨right⟩f\"\n"));
655+
}
656+
657+
#[test]
658+
fn source_skeleton_keeps_overlapping_child_fields_separate() {
659+
let source = "abcdef";
660+
let dump = dump_with_children(
661+
source,
662+
range(0, source.len()),
663+
&[("left", range(1, 4)), ("right", range(3, 5))],
664+
);
665+
666+
assert!(dump.starts_with("parent source=\"a⟨left⟩⟨right⟩f\"\n"));
667+
}
668+
669+
#[test]
670+
fn source_skeleton_reports_children_outside_the_parent() {
671+
let source = "abcdefghi";
672+
let dump = dump_with_children(source, range(0, 6), &[("child", range(7, 9))]);
673+
674+
assert!(dump
675+
.starts_with("parent source=<invalid: child range 7..9 is outside node range 0..6>\n"));
676+
}
677+
}
678+
427679
fn node_content(node: &Node, source: &str) -> String {
428680
match &node.content {
429681
NodeContent::DynamicString(s) if !s.is_empty() => s.clone(),

0 commit comments

Comments
 (0)