Skip to content

Commit f3d5ca1

Browse files
committed
Reject malformed/out-of-range \u and \x escapes in untagged templates
Templates tokenize via the engine's scanTemplateSpan, not the token regex, so the string-side fix (token regex) doesn't reach them. Add an opt-in token option escapeValid: the lexer validates each \-escape in a template against it and throws on a malformed one. Crucially this is gated on tag position: a TAGGED template legally carries invalid escapes (cooked = undefined, since ES2018), e.g. String.raw`\u{110000}`, which TS accepts — only an *untagged* literal rejects them. "Is this template tagged?" is the same "is the previous token a value?" question the lexer already answers for regex-vs-division, so that predicate is factored into prevIsValue() and shared. Interpolation middle/tail spans skip validation (tagged-ness isn't re-derivable there) — conservative, no regression. Rejects untagged `\u{110000}`, `\u{r}`, `\u{}`; still accepts tag`\u{110000}`. FP 108->103 (5 unicodeExtendedEscapesInTemplates cases), FN stays 0 (3376/3376), TP unchanged. Engine stays language-agnostic (agnostic 5/5, refactor-guard 112/112, highlighter 99.3% unchanged); the \u/\x rule lives entirely in examples/typescript.ts.
1 parent 6a92674 commit f3d5ca1

4 files changed

Lines changed: 48 additions & 15 deletions

File tree

examples/typescript.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ const String_ = token(new RegExp(`"(?:[^"\\\\]|${escape})*"|'(?:[^'\\\\]|${
3535
});
3636
const Template = token(/`(?:[^`\\$]|\\.|\$(?!\{))*`/, {
3737
escape: /\\(?:[nrtbfv0'"\\`$]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|u\{[0-9a-fA-F]+\})/,
38+
// Same well-formed-escape rule as strings; the lexer rejects a malformed `\u`/`\x`
39+
// in an *untagged* template (`\u{110000}`, `\u{r}`), but allows it when tagged.
40+
escapeValid: new RegExp(escape),
3841
template: { open: '`', interpOpen: '${', interpClose: '}' },
3942
});
4043
const Regex_ = token(/\/(?:[^\/\\\[\n]|\\.|\[(?:[^\]\\\n]|\\.)*\])+\/[gimsuydv]*/, {

src/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ interface TokenOptions {
66
skip?: boolean;
77
scope?: string;
88
escape?: RegExp;
9+
// A regex matching exactly one well-formed escape sequence. Engine-scanned tokens
10+
// (templates) validate each `\`-escape against it and reject any that don't match —
11+
// unlike `escape` (highlight-only), this drives tokenization. Skipped in tag
12+
// position, where invalid escapes are legal (cooked = undefined). Optional.
13+
escapeValid?: RegExp;
914
regex?: boolean;
1015
embed?: string;
1116
// ── Lexer hints (keep gen-parser language-agnostic; all optional) ──
@@ -326,6 +331,7 @@ export function defineGrammar(config: GrammarConfig): CstGrammar & { name: strin
326331
flags,
327332
scope: tok.opts.scope,
328333
escapePattern: tok.opts.escape?.source,
334+
escapeValidPattern: tok.opts.escapeValid?.source,
329335
embed: tok.opts.embed,
330336
identifier: tok.opts.identifier,
331337
template: tok.opts.template,

src/gen-lexer.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export function createLexer(grammar: CstGrammar) {
4343
const tplInterpClose = templateToken?.template?.interpClose ?? '';
4444
const tplBraceOpen = tplInterpOpen.slice(-1); // brace that deepens interp nesting ('{' of '${')
4545
const tplOpenCode = tplOpen.length === 1 ? tplOpen.charCodeAt(0) : -1; // fast path when the open delimiter is one char
46+
// A valid single escape sequence inside a template; when declared, an escape that
47+
// does not match it is a scan error — but only outside tag position (a tagged
48+
// template legally carries invalid escapes). Sticky `y` so it matches at `pos`.
49+
const templateEscapeValidRe = templateToken?.escapeValidPattern
50+
? new RegExp(templateToken.escapeValidPattern, 'y')
51+
: null;
4652

4753
// Regex-vs-division context: declared by the grammar's `regex` token. ($templateTail
4854
// is the lexer's own synthetic template-end token — always a completed value, so `/`
@@ -61,10 +67,20 @@ export function createLexer(grammar: CstGrammar) {
6167
// Scan from inside a template span to its next boundary: an interpolation hole
6268
// (`interpOpen`) or the closing delimiter (`open`). Delimiters come from the
6369
// grammar's template token; only called when such a token is declared.
64-
function scanTemplateSpan(source: string, pos: number): { endsWithInterp: boolean; end: number } {
70+
function scanTemplateSpan(source: string, pos: number, validateEscapes: boolean): { endsWithInterp: boolean; end: number } {
6571
while (pos < source.length) {
6672
if (source[pos] === '\\') {
67-
pos += 2;
73+
// In tag position invalid escapes are legal (validateEscapes=false): just skip
74+
// `\` + next char. Otherwise the escape must match the token's declared
75+
// escapeValid pattern, else it's a scan error (e.g. `\u{110000}`, `\u{r}`).
76+
if (validateEscapes && templateEscapeValidRe) {
77+
templateEscapeValidRe.lastIndex = pos;
78+
const m = templateEscapeValidRe.exec(source);
79+
if (!m) throw new Error(`Invalid escape sequence in template at offset ${pos}`);
80+
pos += m[0].length;
81+
} else {
82+
pos += 2;
83+
}
6884
} else if (source.startsWith(tplInterpOpen, pos)) {
6985
return { endsWithInterp: true, end: pos + tplInterpOpen.length };
7086
} else if (source.startsWith(tplOpen, pos)) {
@@ -94,6 +110,15 @@ export function createLexer(grammar: CstGrammar) {
94110
if (pendingNl) { t.newlineBefore = true; pendingNl = false; }
95111
tokens.push(t);
96112
}
113+
// Is the previous token a completed VALUE? Then `/` after it is division (not a
114+
// regex) and a template after it is TAGGED (not a fresh literal). Same question,
115+
// shared by the regex-vs-division check and template escape validation.
116+
function prevIsValue(prev: Token | undefined): boolean {
117+
if (!prev) return false;
118+
const isExprKeyword = prev.type === identTokenName && expressionStartKeywords.has(prev.text);
119+
const isParenHead = prev.text === ')' && lastCloseWasParenHead;
120+
return !isExprKeyword && !isParenHead && (divisionPrevTypes.has(prev.type) || divisionPrevTexts.has(prev.text));
121+
}
97122

98123
while (pos < source.length) {
99124
// Skip whitespace
@@ -107,7 +132,10 @@ export function createLexer(grammar: CstGrammar) {
107132
templateStack.pop();
108133
const startPos = pos;
109134
pos += tplInterpClose.length;
110-
const { endsWithInterp, end } = scanTemplateSpan(source, pos);
135+
// Continuation spans (middle/tail): skip escape validation — the whole
136+
// template's tagged-ness was decided at its head and isn't re-derivable from
137+
// the prev token here (it's the interpolation's last token).
138+
const { endsWithInterp, end } = scanTemplateSpan(source, pos, false);
111139
if (endsWithInterp) {
112140
push({ type: '$templateMiddle', text: source.slice(startPos, end), offset: startPos });
113141
templateStack.push(0);
@@ -129,8 +157,11 @@ export function createLexer(grammar: CstGrammar) {
129157
// Template literal (simple or interpolated) — only if the grammar declares a template token.
130158
if (templateToken && (tplOpenCode >= 0 ? source.charCodeAt(pos) === tplOpenCode : source.startsWith(tplOpen, pos))) {
131159
const startPos = pos;
160+
// A template right after a value is TAGGED — invalid escapes are then legal
161+
// (cooked = undefined), so validate escapes only for an untagged literal.
162+
const tagged = prevIsValue(tokens[tokens.length - 1]);
132163
pos += tplOpen.length;
133-
const { endsWithInterp, end } = scanTemplateSpan(source, pos);
164+
const { endsWithInterp, end } = scanTemplateSpan(source, pos, !tagged);
134165
if (endsWithInterp) {
135166
push({ type: '$templateHead', text: source.slice(startPos, end), offset: startPos });
136167
templateStack.push(0);
@@ -148,16 +179,8 @@ export function createLexer(grammar: CstGrammar) {
148179
for (const tm of tokenMatchers) {
149180
if (tm.name === templateTokenName) continue;
150181
if (tm.isRegex) {
151-
const prev = tokens[tokens.length - 1];
152-
if (prev) {
153-
// Expression-start keywords (in, throw, return, etc.) flip back to regex context
154-
const isExprKeyword = prev.type === identTokenName && expressionStartKeywords.has(prev.text);
155-
// A `)` that closed a control head (`if (…) /re/`) is not a value → regex.
156-
const isParenHead = prev.text === ')' && lastCloseWasParenHead;
157-
if (!isExprKeyword && !isParenHead && (divisionPrevTypes.has(prev.type) || divisionPrevTexts.has(prev.text))) {
158-
continue;
159-
}
160-
}
182+
// prev is a completed value → `/` is division, not a regex literal → skip.
183+
if (prevIsValue(tokens[tokens.length - 1])) continue;
161184
}
162185
const m = remaining.match(tm.regex);
163186
if (m) {

src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ export interface TokenDecl {
33
pattern: string;
44
flags: string[];
55
scope?: string; // @scope(...) override
6-
escapePattern?: string; // @escape /pattern/ — escape sequence regex
6+
escapePattern?: string; // @escape /pattern/ — escape sequence regex (highlight only)
7+
escapeValidPattern?: string; // one well-formed escape; engine-scanned tokens reject non-matching `\`-escapes (skipped in tag position)
78
embed?: string; // @embed(lang) — embedded language scope name
89
// ── Lexer hints (keep the engine language-agnostic; all optional) ──
910
identifier?: boolean; // THE identifier token: engine uses its name for the

0 commit comments

Comments
 (0)