@@ -149,6 +149,9 @@ interface GrammarJsContext {
149149 * `template_chars` token. `null` when no template token exists.
150150 */
151151 templatePlan : TemplatePlan | null ;
152+ /** String tokens carrying highlight-only interpolation regions, each re-expressed as a rule
153+ * backed by an external `<rule>_chars` token (parallel to `templatePlan`). Empty if none. */
154+ interpolationPlans : InterpolationPlan [ ] ;
152155 /**
153156 * Ref nodes (the identifier right after a definition keyword) that should be
154157 * wrapped in `field('name', …)` so highlights.scm can target them with the
@@ -358,6 +361,8 @@ function buildTokenBody(name: string, ctx: GrammarJsContext): string | null {
358361 // The interpolated-template token is re-expressed as a `template` RULE (with
359362 // `${ … }` holes that re-enter the expression grammar), emitted separately.
360363 if ( ctx . templatePlan && ctx . templatePlan . tokenName === name ) return null ;
364+ // A string token with interpolation regions is likewise re-expressed as a rule (emitted separately).
365+ if ( ctx . interpolationPlans . some ( ip => ip . tokenName === name ) ) return null ;
361366 // Skip-flagged tokens (comments, whitespace) go in `extras`, not as a named
362367 // rule reference — but we still emit them so highlights can capture comments.
363368 // tree-sitter's token() DFA rejects zero-width assertions, so strip them first.
@@ -538,6 +543,43 @@ function planTemplate(grammar: CstGrammar): TemplatePlan | null {
538543 } ;
539544}
540545
546+ /**
547+ * A string token carrying highlight-only interpolation regions (e.g. env-spec `${…}` / `$(…)`),
548+ * re-expressed as a tree-sitter RULE (open delim + chars/interpolation runs + close delim) — the
549+ * same shape a template literal gets. The literal text between regions is an external
550+ * `<rule>_chars` token (the scanner stops it at the close delim or any region opener).
551+ */
552+ interface InterpolationPlan {
553+ tokenName : string ; // original token name (e.g. 'DQ') — now emitted as a rule, not a token
554+ ruleSnake : string ; // snake rule name (e.g. 'dq') — keeps `$.dq` references valid
555+ charsSnake : string ; // external scanner symbol for the literal text (e.g. 'dq_chars')
556+ open : string ; // opening delimiter (e.g. '"')
557+ close : string ; // closing delimiter (same as open for a string token)
558+ regions : { ruleSnake : string ; open : string ; close : string } [ ] ; // one sub-rule per interpolation entry
559+ }
560+
561+ function planInterpolations ( grammar : CstGrammar ) : InterpolationPlan [ ] {
562+ const plans : InterpolationPlan [ ] = [ ] ;
563+ for ( const tok of grammar . tokens ) {
564+ if ( ! tok . interpolation ?. length ) continue ;
565+ const open = tokenPatternStringDelimiters ( tok ) [ 0 ] ?? '"' ;
566+ const ruleSnake = toSnake ( tok . name ) ;
567+ plans . push ( {
568+ tokenName : tok . name ,
569+ ruleSnake,
570+ charsSnake : ruleSnake + '_chars' ,
571+ open,
572+ close : open ,
573+ regions : tok . interpolation . map ( ( interp , i ) => ( {
574+ ruleSnake : `${ ruleSnake } _interpolation_${ i + 1 } ` ,
575+ open : interp . begin ,
576+ close : interp . end ,
577+ } ) ) ,
578+ } ) ;
579+ }
580+ return plans ;
581+ }
582+
541583/** Determine which tokens the external scanner must provide. */
542584function planScannerTokens ( grammar : CstGrammar ) : Map < string , string > {
543585 const map = new Map < string , string > ( ) ;
@@ -560,6 +602,7 @@ function planScannerTokens(grammar: CstGrammar): Map<string, string> {
560602function externalSymbols ( ctx : GrammarJsContext ) : string [ ] {
561603 const syms = [ ...ctx . scannerTokenFor . values ( ) ] ;
562604 if ( ctx . templatePlan ) syms . push ( ctx . templatePlan . charsSnake ) ;
605+ for ( const ip of ctx . interpolationPlans ) syms . push ( ip . charsSnake ) ;
563606 return syms ;
564607}
565608
@@ -725,8 +768,10 @@ export function generateTreeSitter(grammar: CstGrammar, langName?: string): Tree
725768
726769 const scannerTokenFor = planScannerTokens ( grammar ) ;
727770 const templatePlan = planTemplate ( grammar ) ;
771+ const interpolationPlans = planInterpolations ( grammar ) ;
728772 const externalSnake = new Set ( [ ...scannerTokenFor . values ( ) ] ) ;
729773 if ( templatePlan ) externalSnake . add ( templatePlan . charsSnake ) ;
774+ for ( const ip of interpolationPlans ) externalSnake . add ( ip . charsSnake ) ;
730775
731776 // Find the identifier nodes that follow a declaration keyword, so we can wrap
732777 // them in `field('name', …)` in grammar.js AND emit standard `name:` highlight
@@ -736,6 +781,7 @@ export function generateTreeSitter(grammar: CstGrammar, langName?: string): Tree
736781 const ctx : GrammarJsContext = {
737782 grammar, tokenNames, ruleSnake, tokenSnake, prattRules, externalSnake, scannerTokenFor,
738783 templatePlan,
784+ interpolationPlans,
739785 nameFieldNodes : nameFields . nodes ,
740786 } ;
741787
@@ -859,6 +905,27 @@ function buildGrammarJs(ctx: GrammarJsContext, grammarName: string): string {
859905 ) ;
860906 }
861907
908+ // String-interpolation tokens: re-expressed as a rule (open + chars/interpolation runs + close);
909+ // each interpolation region is a sub-rule whose hole re-enters the expression grammar (like a template).
910+ const interpExprName = [ ...ctx . prattRules ] [ 0 ] ;
911+ const interpExprSnake = interpExprName ? ctx . ruleSnake . get ( interpExprName ) ! : null ;
912+ const interpHole = interpExprSnake ? `optional($.${ interpExprSnake } )` : 'blank()' ;
913+ for ( const ip of ctx . interpolationPlans ) {
914+ const choices = [ `$.${ ip . charsSnake } ` , ...ip . regions . map ( r => `$.${ r . ruleSnake } ` ) ] . join ( ', ' ) ;
915+ ruleEntries . push (
916+ ` ${ ip . ruleSnake } : $ => seq(\n` +
917+ ` ${ jsString ( ip . open ) } ,\n` +
918+ ` repeat(choice(${ choices } )),\n` +
919+ ` ${ jsString ( ip . close ) } \n` +
920+ ` )` ,
921+ ) ;
922+ for ( const r of ip . regions ) {
923+ ruleEntries . push (
924+ ` ${ r . ruleSnake } : $ => seq(${ jsString ( r . open ) } , ${ interpHole } , ${ jsString ( r . close ) } )` ,
925+ ) ;
926+ }
927+ }
928+
862929 lines . push ( ruleEntries . join ( ',\n\n' ) ) ;
863930 lines . push ( ' }' ) ;
864931 lines . push ( '});' ) ;
@@ -1087,6 +1154,15 @@ function buildHighlightsScm(
10871154 tokenNodeCaptures . push ( { query : `(${ tpl . substRuleSnake } ${ jsString ( tpl . interpOpen ) } )` , capture : '@punctuation.special' } ) ;
10881155 tokenNodeCaptures . push ( { query : `(${ tpl . substRuleSnake } ${ jsString ( tpl . interpClose ) } )` , capture : '@punctuation.special' } ) ;
10891156 }
1157+ // String-interpolation regions: the literal text reads as string; the region delimiters as
1158+ // punctuation — same treatment as a template hole, derived from the interpolation metadata.
1159+ for ( const ip of ctx . interpolationPlans ) {
1160+ tokenNodeCaptures . push ( { query : `(${ ip . charsSnake } )` , capture : '@string' } ) ;
1161+ for ( const r of ip . regions ) {
1162+ tokenNodeCaptures . push ( { query : `(${ r . ruleSnake } ${ jsString ( r . open ) } )` , capture : '@punctuation.special' } ) ;
1163+ tokenNodeCaptures . push ( { query : `(${ r . ruleSnake } ${ jsString ( r . close ) } )` , capture : '@punctuation.special' } ) ;
1164+ }
1165+ }
10901166
10911167 // ── D. Contextual node captures via emitted fields ──
10921168 // Operators carry an `operator` field in Pratt rules; they're already covered by
@@ -1753,6 +1829,49 @@ function buildScannerC(
17531829 L . push ( '' ) ;
17541830 }
17551831
1832+ // ── Interpolated-string char scanners (one per string token carrying interpolation) ──
1833+ // Each scans the literal run inside the string, stopping before the close delimiter or any
1834+ // interpolation opener (so the opener re-enters the expression grammar via its sub-rule). The
1835+ // openers are DATA from the interpolation metadata (decoded literals, length 1–2).
1836+ {
1837+ const cChar = ( ch : string ) => ch === '\\' ? "'\\\\'" : ch === "'" ? "'\\''" : `'${ ch } '` ;
1838+ for ( const ip of ctx . interpolationPlans ) {
1839+ const charsSym = ip . charsSnake . toUpperCase ( ) ;
1840+ const up = ip . ruleSnake . toUpperCase ( ) ;
1841+ const openerInit = ip . regions . map ( r => jsString ( r . open ) ) . join ( ', ' ) ;
1842+ L . push ( `// ── Interpolated-string scan (${ ip . tokenName } ): literal text up to the close delim or an opener ──` ) ;
1843+ L . push ( `static const char *${ up } _OPENERS[] = { ${ openerInit } };` ) ;
1844+ L . push ( `static const unsigned ${ up } _OPENER_COUNT = ${ ip . regions . length } ;` ) ;
1845+ L . push ( `static bool scan_${ ip . ruleSnake } _chars(TSLexer *lexer) {` ) ;
1846+ L . push ( ' bool has_content = false;' ) ;
1847+ L . push ( ' for (;;) {' ) ;
1848+ L . push ( ' lexer->mark_end(lexer);' ) ;
1849+ L . push ( ' int32_t c = lexer->lookahead;' ) ;
1850+ L . push ( ' if (c == 0) return false; // EOF — let the CFG report the unterminated string' ) ;
1851+ L . push ( ` if (c == ${ cChar ( ip . close ) } ) break; // closing delimiter` ) ;
1852+ L . push ( ' bool first_match = false;' ) ;
1853+ L . push ( ` for (unsigned i = 0; i < ${ up } _OPENER_COUNT; i++) if ((int32_t)${ up } _OPENERS[i][0] == c) { first_match = true; break; }` ) ;
1854+ L . push ( ' if (first_match) {' ) ;
1855+ L . push ( ' advance(lexer); // peek past the opener\'s first char' ) ;
1856+ L . push ( ' int32_t c2 = lexer->lookahead;' ) ;
1857+ L . push ( ' bool real = false;' ) ;
1858+ L . push ( ` for (unsigned i = 0; i < ${ up } _OPENER_COUNT; i++)` ) ;
1859+ L . push ( ` if ((int32_t)${ up } _OPENERS[i][0] == c && (${ up } _OPENERS[i][1] == 0 || (int32_t)${ up } _OPENERS[i][1] == c2)) { real = true; break; }` ) ;
1860+ L . push ( ' if (real) break; // a real opener — token ends before it (mark_end frozen above)' ) ;
1861+ L . push ( ' has_content = true; continue; // lone first char → literal content' ) ;
1862+ L . push ( ' }' ) ;
1863+ L . push ( ' if (c == \'\\\\\') { advance(lexer); if (lexer->lookahead != 0) advance(lexer); has_content = true; continue; }' ) ;
1864+ L . push ( ' advance(lexer);' ) ;
1865+ L . push ( ' has_content = true;' ) ;
1866+ L . push ( ' }' ) ;
1867+ L . push ( ' if (!has_content) return false;' ) ;
1868+ L . push ( ` lexer->result_symbol = ${ charsSym } ;` ) ;
1869+ L . push ( ' return true;' ) ;
1870+ L . push ( '}' ) ;
1871+ L . push ( '' ) ;
1872+ }
1873+ }
1874+
17561875 // ── scan() entry ──
17571876 L . push ( 'bool tree_sitter_' + grammarName + '_external_scanner_scan(void *payload, TSLexer *lexer,' ) ;
17581877 L . push ( ' const bool *valid_symbols) {' ) ;
@@ -1797,6 +1916,14 @@ function buildScannerC(
17971916 L . push ( ' }' ) ;
17981917 L . push ( '' ) ;
17991918 }
1919+ for ( const ip of ctx . interpolationPlans ) {
1920+ const charsSym = ip . charsSnake . toUpperCase ( ) ;
1921+ L . push ( ` // ${ ip . tokenName } interpolated-string literal text (whitespace inside is content, not skipped).` ) ;
1922+ L . push ( ` if (valid_symbols[${ charsSym } ]) {` ) ;
1923+ L . push ( ` if (scan_${ ip . ruleSnake } _chars(lexer)) return true;` ) ;
1924+ L . push ( ' }' ) ;
1925+ L . push ( '' ) ;
1926+ }
18001927 L . push ( ' return false;' ) ;
18011928 L . push ( '}' ) ;
18021929 L . push ( '' ) ;
0 commit comments