11import { afterEach , describe , expect , it , vi } from "vitest" ;
2- import type { Parser } from "web-tree-sitter" ;
2+ import type { Node as TreeSitterNode , Parser , Tree } from "web-tree-sitter" ;
33import { explainShellCommand } from "./extract.js" ;
44import {
55 getBashParserForCommandExplanation ,
@@ -15,6 +15,119 @@ function setParserLoaderForTest(loader: () => Promise<Parser>): void {
1515 setBashParserLoaderForCommandExplanationForTest ( loader ) ;
1616}
1717
18+ type FakeNodeInit = {
19+ type : string ;
20+ text : string ;
21+ startIndex : number ;
22+ endIndex : number ;
23+ startPosition : TreeSitterNode [ "startPosition" ] ;
24+ endPosition : TreeSitterNode [ "endPosition" ] ;
25+ namedChildren ?: TreeSitterNode [ ] ;
26+ fieldChildren ?: Record < string , TreeSitterNode > ;
27+ hasError ?: boolean ;
28+ } ;
29+
30+ function fakeNode ( init : FakeNodeInit ) : TreeSitterNode {
31+ const named = init . namedChildren ?? [ ] ;
32+ const children = named ;
33+ return {
34+ type : init . type ,
35+ text : init . text ,
36+ startIndex : init . startIndex ,
37+ endIndex : init . endIndex ,
38+ startPosition : init . startPosition ,
39+ endPosition : init . endPosition ,
40+ childCount : children . length ,
41+ namedChildCount : named . length ,
42+ hasError : init . hasError ?? false ,
43+ child ( index : number ) : TreeSitterNode | null {
44+ return children [ index ] ?? null ;
45+ } ,
46+ namedChild ( index : number ) : TreeSitterNode | null {
47+ return named [ index ] ?? null ;
48+ } ,
49+ childForFieldName ( name : string ) : TreeSitterNode | null {
50+ return init . fieldChildren ?. [ name ] ?? null ;
51+ } ,
52+ } as unknown as TreeSitterNode ;
53+ }
54+
55+ function createByteIndexedUnicodeCommandTree ( source : string ) : Tree {
56+ const firstCommand = "echo café" ;
57+ const separator = " && " ;
58+ const secondCommand = "echo ok" ;
59+ const firstCommandEnd = Buffer . byteLength ( firstCommand , "utf8" ) ;
60+ const secondCommandStart = Buffer . byteLength ( firstCommand + separator , "utf8" ) ;
61+ const sourceEnd = Buffer . byteLength ( source , "utf8" ) ;
62+
63+ const firstName = fakeNode ( {
64+ type : "command_name" ,
65+ text : "echo" ,
66+ startIndex : 0 ,
67+ endIndex : 4 ,
68+ startPosition : { row : 0 , column : 0 } ,
69+ endPosition : { row : 0 , column : 4 } ,
70+ } ) ;
71+ const firstArgument = fakeNode ( {
72+ type : "word" ,
73+ text : "café" ,
74+ startIndex : 5 ,
75+ endIndex : firstCommandEnd ,
76+ startPosition : { row : 0 , column : 5 } ,
77+ endPosition : { row : 0 , column : firstCommandEnd } ,
78+ } ) ;
79+ const first = fakeNode ( {
80+ type : "command" ,
81+ text : firstCommand ,
82+ startIndex : 0 ,
83+ endIndex : firstCommandEnd ,
84+ startPosition : { row : 0 , column : 0 } ,
85+ endPosition : { row : 0 , column : firstCommandEnd } ,
86+ namedChildren : [ firstName , firstArgument ] ,
87+ fieldChildren : { name : firstName } ,
88+ } ) ;
89+
90+ const secondName = fakeNode ( {
91+ type : "command_name" ,
92+ text : "echo" ,
93+ startIndex : secondCommandStart ,
94+ endIndex : secondCommandStart + 4 ,
95+ startPosition : { row : 0 , column : secondCommandStart } ,
96+ endPosition : { row : 0 , column : secondCommandStart + 4 } ,
97+ } ) ;
98+ const secondArgument = fakeNode ( {
99+ type : "word" ,
100+ text : "ok" ,
101+ startIndex : secondCommandStart + 5 ,
102+ endIndex : sourceEnd ,
103+ startPosition : { row : 0 , column : secondCommandStart + 5 } ,
104+ endPosition : { row : 0 , column : sourceEnd } ,
105+ } ) ;
106+ const second = fakeNode ( {
107+ type : "command" ,
108+ text : secondCommand ,
109+ startIndex : secondCommandStart ,
110+ endIndex : sourceEnd ,
111+ startPosition : { row : 0 , column : secondCommandStart } ,
112+ endPosition : { row : 0 , column : sourceEnd } ,
113+ namedChildren : [ secondName , secondArgument ] ,
114+ fieldChildren : { name : secondName } ,
115+ } ) ;
116+
117+ return {
118+ rootNode : fakeNode ( {
119+ type : "program" ,
120+ text : source ,
121+ startIndex : 0 ,
122+ endIndex : sourceEnd ,
123+ startPosition : { row : 0 , column : 0 } ,
124+ endPosition : { row : 0 , column : sourceEnd } ,
125+ namedChildren : [ first , second ] ,
126+ } ) ,
127+ delete : vi . fn ( ) ,
128+ } as unknown as Tree ;
129+ }
130+
18131afterEach ( ( ) => {
19132 if ( parserLoaderOverridden ) {
20133 setBashParserLoaderForCommandExplanationForTest ( ) ;
@@ -94,6 +207,34 @@ describe("command explainer tree-sitter runtime", () => {
94207 expect ( reset ) . toHaveBeenCalledOnce ( ) ;
95208 } ) ;
96209
210+ it ( "maps parser byte offsets to JavaScript string spans for Unicode source" , async ( ) => {
211+ const source = "echo café && echo ok" ;
212+ const parser = {
213+ parse : vi . fn ( ( ) => createByteIndexedUnicodeCommandTree ( source ) ) ,
214+ reset : vi . fn ( ) ,
215+ } ;
216+ setParserLoaderForTest ( async ( ) => parser as unknown as Parser ) ;
217+
218+ const explanation = await explainShellCommand ( source ) ;
219+
220+ expect ( explanation . topLevelCommands ) . toEqual ( [
221+ expect . objectContaining ( {
222+ executable : "echo" ,
223+ argv : [ "echo" , "café" ] ,
224+ span : expect . objectContaining ( { startIndex : 0 , endIndex : 9 } ) ,
225+ } ) ,
226+ expect . objectContaining ( {
227+ executable : "echo" ,
228+ argv : [ "echo" , "ok" ] ,
229+ span : expect . objectContaining ( { startIndex : 13 , endIndex : 20 } ) ,
230+ } ) ,
231+ ] ) ;
232+ for ( const command of explanation . topLevelCommands ) {
233+ expect ( source . slice ( command . span . startIndex , command . span . endIndex ) ) . toBe ( command . text ) ;
234+ expect ( command . span . endPosition . column ) . toBe ( command . span . endIndex ) ;
235+ }
236+ } ) ;
237+
97238 it ( "explains a pipeline with python inline eval" , async ( ) => {
98239 const explanation = await explainShellCommand ( 'ls | grep "stuff" | python -c \'print("hi")\'' ) ;
99240
@@ -566,7 +707,7 @@ describe("command explainer tree-sitter runtime", () => {
566707 'find . -name "*.ts" -exec grep -n TODO {} +' ,
567708 'bash -lc "echo hi | wc -c"' ,
568709 ] ;
569- const iterations = 10 ;
710+ const iterations = 3 ;
570711 for ( let index = 0 ; index < iterations ; index += 1 ) {
571712 for ( const command of corpus ) {
572713 const explanation = await explainShellCommand ( command ) ;
0 commit comments