Skip to content

Commit ad0415c

Browse files
feat(generators): token-level string interpolation metadata + string fixes (#9)
The original PR was written against the pre-IR master (escape: RegExp, pattern.source); the codebase has since moved tokens to an algebra/IR, so this reimplements the same three behaviors on current master and keeps the regression tests as the contract. - gen-tm: infer string-region delimiters generically, so an escaped backtick string keeps backtick delimiters instead of falling back to `"` - gen-lexer: gate the YAML multiline quoted-scalar continuation check behind `indent.blockScalar`, so a plain indentation grammar accepts `KEY="a\nb"` - types/api: a `string` token may declare `interpolation` regions - gen-tm / gen-monarch / gen-treesitter: consume `interpolation` — nested TextMate regions, Monarch interpolation states, and a tree-sitter rule + an external `<tok>_chars` scanner + highlight captures - tests: env-spec-regressions + interpolation-metadata, ported to the IR API All seven existing grammars regenerate byte-identically; TS conformance, the agnostic gate, and the tree-sitter accuracy gate are unchanged. Co-authored-by: Johnson Chu <johnsoncodehk@gmail.com> Co-authored-by: Theo Ephraim <theoephraim@users.noreply.github.com>
1 parent 2e664dd commit ad0415c

8 files changed

Lines changed: 433 additions & 8 deletions

File tree

src/api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CstGrammar, TokenDecl, PrecLevel, PrecOperator, RuleDecl, RuleExpr, MarkupConfig, IndentConfig, NewlineConfig, TokenPattern } from './types.ts';
1+
import type { CstGrammar, TokenDecl, PrecLevel, PrecOperator, RuleDecl, RuleExpr, MarkupConfig, IndentConfig, NewlineConfig, StringInterpolation, TokenPattern } from './types.ts';
22
import {
33
altPattern, anyChar, followedBy, isTokenPattern, lit, never, noneOf, notFollowedBy,
44
notPrecededBy, oneOf, optPattern, plus, precededBy, range, repeat,
@@ -17,6 +17,9 @@ interface TokenOptions {
1717
skip?: boolean;
1818
scope?: string;
1919
escape?: TokenPatternInput;
20+
// Highlight-only interpolation regions for ordinary string tokens (e.g. env-spec `${…}` / `$(…)`).
21+
// The parser/lexer stay token-based; generators re-express these as nested regions.
22+
interpolation?: StringInterpolation | StringInterpolation[];
2023
// A regex matching exactly one well-formed escape sequence. Engine-scanned tokens
2124
// (templates) validate each `\`-escape against it and reject any that don't match —
2225
// unlike `escape` (highlight-only), this drives tokenization. Skipped in tag
@@ -414,6 +417,9 @@ export function defineGrammar(config: GrammarConfig): CstGrammar & { name: strin
414417
flags,
415418
scope: tok.opts.scope,
416419
escapePattern: tok.opts.escape,
420+
interpolation: tok.opts.interpolation
421+
? (Array.isArray(tok.opts.interpolation) ? tok.opts.interpolation : [tok.opts.interpolation]).map((i) => ({ ...i }))
422+
: undefined,
417423
escapeValidPattern: tok.opts.escapeValid,
418424
embed: tok.opts.embed,
419425
identifier: tok.opts.identifier,

src/gen-lexer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ export function createLexer(grammar: CstGrammar) {
966966
// • LINE-LEAD at the document root (a bare top-level `"a\nb`, or `---\n"a\nb`) → -1.
967967
// Blank (whitespace-only) continuation lines are skipped — they are folded line breaks, legal
968968
// at any column. Flow is exempt (indentation suspended). yaml-test-suite DK95[1] / QB6E.
969-
if (tm.isString && indent && flowDepth === 0 && m[0].includes('\n')) {
969+
if (tm.isString && indent?.blockScalar && flowDepth === 0 && m[0].includes('\n')) {
970970
const prevT = tokens[tokens.length - 1];
971971
const prevIsDocMarker = !!prevT && blockScalarDocMarkers.includes(prevT.text);
972972
let parentCol: number;

src/gen-monarch.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,11 @@ export function generateMonarch(grammar: CstGrammar): MonarchLanguage {
488488

489489
const stringTopRules: MonarchRule[] = []; // entered from root/value
490490
const stringNestedRules: MonarchRule[] = []; // entered from interpolation holes
491+
// Highlight-only string interpolation regions (e.g. env-spec `${…}` / `$(…)`): per region we add a
492+
// begin rule into the string body and build a dedicated interp state (re-enter the expression body,
493+
// pop on the region's end). Specs are collected here; the states are built after templates, once the
494+
// nested string/template rules they include are populated.
495+
const interpStateSpecs: { name: string; end: string }[] = [];
491496

492497
for (const t of grammar.tokens) {
493498
if (t.flags.includes('skip') || t.flags.includes('regex') || t.template) continue;
@@ -505,7 +510,19 @@ export function generateMonarch(grammar: CstGrammar): MonarchLanguage {
505510
const body: MonarchRule[] = [];
506511
const escapePattern = tokenEscapePatternSource(t);
507512
if (escapePattern) body.push([anchoredSource(escapePattern), 'string.escape']);
508-
body.push([`[^${escapeForCharClass(delim[0])}\\\\]+`, tok]);
513+
// Interpolation openers come BEFORE the content run so they win; the content run then excludes
514+
// any position that begins an interpolation (negative lookahead) so it can't swallow `${`.
515+
const interps = t.interpolation ?? [];
516+
interps.forEach((interp, i) => {
517+
const name = `string_interp_${suffix}_${i + 1}`;
518+
body.push([escapeRegex(interp.begin), { token: 'delimiter.bracket', next: `@${name}` }]);
519+
interpStateSpecs.push({ name, end: interp.end });
520+
});
521+
const dc = escapeForCharClass(delim[0]);
522+
const content = interps.length
523+
? `(?:(?!${interps.map(p => escapeRegex(p.begin)).join('|')})[^${dc}\\\\])+`
524+
: `[^${dc}\\\\]+`;
525+
body.push([content, tok]);
509526
body.push(['\\\\.', 'string.escape']);
510527
tokenizer[bodyState] = body;
511528
}
@@ -591,6 +608,28 @@ export function generateMonarch(grammar: CstGrammar): MonarchLanguage {
591608
];
592609
}
593610

611+
// String-interpolation states (collected in the string loop above). Built here, after templates,
612+
// so the nested string/template rules they include are populated; `@interpExprBody` is a lazy
613+
// include resolved by Monarch. A bare `{` pushes a brace-counting frame (shared with templates).
614+
if (interpStateSpecs.length) {
615+
if (!tokenizer['bracketCounting']) {
616+
tokenizer['bracketCounting'] = [
617+
wsRule, ...commentRules, ...stringNestedRules, ...templateNestedRules,
618+
['\\{', { token: 'delimiter.bracket', next: '@bracketCounting' }],
619+
['\\}', { token: 'delimiter.bracket', next: '@pop' }],
620+
{ include: '@interpExprBody' },
621+
];
622+
}
623+
for (const spec of interpStateSpecs) {
624+
tokenizer[spec.name] = [
625+
wsRule, ...commentRules, ...stringNestedRules, ...templateNestedRules,
626+
['\\{', { token: 'delimiter.bracket', next: '@bracketCounting' }],
627+
[escapeRegex(spec.end), { token: 'delimiter.bracket', next: '@pop' }],
628+
{ include: '@interpExprBody' },
629+
];
630+
}
631+
}
632+
594633
// ── Numbers (most-specific first; token decl order encodes specificity) ──
595634
const numberRules: MonarchRule[] = [];
596635
for (const t of grammar.tokens) {

src/gen-tm.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4464,12 +4464,26 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
44644464
} else if (tokenEscapePatternSource(tok) && scope.startsWith('string.')) {
44654465
// String with escape sequences: generate begin/end for each delimiter
44664466
const escapePat: TmPattern = { match: tokenEscapePatternSource(tok)!, name: `constant.character.escape.${langName}` };
4467+
// Highlight-only interpolation regions (e.g. env-spec `${…}` / `$(…)`): each becomes a nested
4468+
// begin/end region — the same shape a template literal's hole gets. `begin`/`end` are
4469+
// author-supplied regex SOURCES (not literals), so they are NOT re-escaped here.
4470+
const interpPats: TmPattern[] = (tok.interpolation ?? []).map((interp) => {
4471+
const p: TmPattern = { begin: escapeRegex(interp.begin), end: escapeRegex(interp.end), patterns: [{ include: interp.include ?? '$self' }] };
4472+
if (interp.beginScope) p.beginCaptures = { '0': { name: `${interp.beginScope}.${langName}` } };
4473+
if (interp.endScope) p.endCaptures = { '0': { name: `${interp.endScope}.${langName}` } };
4474+
if (interp.contentScope) p.name = `${interp.contentScope}.${langName}`;
4475+
return p;
4476+
});
4477+
const stringPats: (TmPattern | { include: string })[] = [escapePat, ...interpPats];
44674478
const delimiters: [string, string][] = [];
4479+
// Drive the delimiter scope off the EXTRACTED delimiter generically: `"`/`'` keep their
4480+
// canonical scopes; any other delimiter (e.g. a backtick string) takes the token's own scope
4481+
// instead of the old loop's `"`-fallback (which mis-delimited backtick strings).
4482+
const scopeForDelim = (d: string) => d === '"' ? 'string.quoted.double' : d === "'" ? 'string.quoted.single' : scope;
44684483
for (const delim of tokenPatternStringDelimiters(tok)) {
4469-
if (delim === '"') delimiters.push(['"', 'string.quoted.double']);
4470-
else if (delim === "'") delimiters.push(["'", 'string.quoted.single']);
4484+
delimiters.push([delim, scopeForDelim(delim)]);
44714485
}
4472-
if (delimiters.length === 0) delimiters.push(['"', scope]); // fallback
4486+
if (delimiters.length === 0) delimiters.push(['"', scope]); // fallback: no delimiter extractable
44734487

44744488
if (delimiters.length === 1) {
44754489
const [delim, delimScope] = delimiters[0];
@@ -4479,7 +4493,7 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
44794493
beginCaptures: { '0': { name: `punctuation.definition.string.begin.${langName}` } },
44804494
end: `${escapeRegex(delim)}|$`,
44814495
endCaptures: { '0': { name: `punctuation.definition.string.end.${langName}` } },
4482-
patterns: [escapePat],
4496+
patterns: stringPats,
44834497
};
44844498
topPatterns.push({ include: `#${key}` });
44854499
rememberLiteralKey(delimScope, key, tok.name);
@@ -4493,7 +4507,7 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
44934507
beginCaptures: { '0': { name: `punctuation.definition.string.begin.${langName}` } },
44944508
end: `${escapeRegex(delim)}|$`,
44954509
endCaptures: { '0': { name: `punctuation.definition.string.end.${langName}` } },
4496-
patterns: [escapePat],
4510+
patterns: stringPats,
44974511
};
44984512
topPatterns.push({ include: `#${subKey}` });
44994513
rememberLiteralKey(delimScope, subKey, tok.name);

src/gen-treesitter.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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. */
542584
function planScannerTokens(grammar: CstGrammar): Map<string, string> {
543585
const map = new Map<string, string>();
@@ -560,6 +602,7 @@ function planScannerTokens(grammar: CstGrammar): Map<string, string> {
560602
function 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

Comments
 (0)