@@ -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
2023impl 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+
427679fn node_content ( node : & Node , source : & str ) -> String {
428680 match & node. content {
429681 NodeContent :: DynamicString ( s) if !s. is_empty ( ) => s. clone ( ) ,
0 commit comments