Skip to content

Commit 8cca2bc

Browse files
committed
Fix JS line-terminator conformance across all four lexers (CR / LS / PS)
The lexers counted only LF as a line terminator, but ECMAScript also defines CR (U+000D), LS (U+2028), and PS (U+2029) — the set that drives ASI and the "no LineTerminator here" restrictions. So `return\r1` was parsed `return 1` where a conforming JS parser applies ASI (bare `return`, then `1`). Fixed consistently in all four lexer implementations so they stay in lockstep: - gen-lexer.ts (interpreter, the oracle): LF/CR in the ASCII path, LS/PS via the \s-run regex, and the comment-span check. - emit-lexer.ts (emitted SoA/JS lexer): the same, in its codegen. - target-ts.ts (portable, UTF-16): LF/CR/LS/PS. - target-go.ts / target-rust.ts (portable, byte-based): LF/CR (LS/PS are multi-byte and fall under the documented non-ASCII offset boundary). CRLF is unchanged (the LF already set newline-before), so the existing corpus is unaffected — the change only reaches lone-CR and LS/PS inputs. This supersedes the earlier direction (which had made the portable lexers match the LF-only interpreter); now the interpreter is conforming and all four agree on the full set. sljs gate extended: `return\r1;` / `return\r\n1;` / `return /*\r*/ 1;` reject, `return\t1;` accepts (tab is whitespace, not a terminator) — checked across ts/go/rust. emit-parser-verify byte-identical, portable-targets 16 grammars ×3, full suite 42/42.
1 parent 84895d3 commit 8cca2bc

6 files changed

Lines changed: 24 additions & 23 deletions

File tree

src/emit-lexer.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const NON_ASCII_WS_FN =
4343
// The non-ASCII whitespace fallback, emitted at the two sites that need it (after an ASCII run,
4444
// and as the lead char). `cont` appends the `continue` the lead-char site needs.
4545
const nonAsciiWsConsume = (v: string, cont: boolean, indent: string): string =>
46-
`${indent}if (${v} > 127 && lxNonAsciiWs(${v})) { LX_WS.lastIndex = pos; const m = LX_WS.exec(source); if (m !== null) { if (m[0].includes('\\n')) pendingNl = true; pos += m[0].length;${cont ? ' continue;' : ''} } }`;
46+
`${indent}if (${v} > 127 && lxNonAsciiWs(${v})) { LX_WS.lastIndex = pos; const m = LX_WS.exec(source); if (m !== null) { if (/[\\n\\r\\u2028\\u2029]/.test(m[0])) pendingNl = true; pos += m[0].length;${cont ? ' continue;' : ''} } }`;
4747

4848
export function emitSoaLexer(grammar: CstGrammar, st: LexerSymtab): string | null {
4949
// Out of scope: the markup / indentation / newline state machines.
@@ -390,7 +390,7 @@ export function emitSoaLexer(grammar: CstGrammar, st: LexerSymtab): string | nul
390390
emit(` if (cc === 32 || (cc >= 9 && cc <= 13)) {`);
391391
emit(` let wc = cc;`);
392392
emit(` do {`);
393-
emit(` if (wc === 10) pendingNl = true;`);
393+
emit(` if (wc === 10 || wc === 13) pendingNl = true;`); // JS line terminators LF/CR (LS/PS via the \\s regex below)
394394
emit(` pos++;`);
395395
emit(` wc = source.charCodeAt(pos);`);
396396
emit(` } while (wc === 32 || (wc >= 9 && wc <= 13));`);
@@ -476,7 +476,7 @@ export function emitSoaLexer(grammar: CstGrammar, st: LexerSymtab): string | nul
476476
emit(`${ind} }`);
477477
}
478478
if (m.skip) {
479-
emit(`${ind} if (m[0].includes('\\n')) pendingNl = true;`);
479+
emit(`${ind} if (/[\\n\\r\\u2028\\u2029]/.test(m[0])) pendingNl = true;`);
480480
emit(`${ind} pos += m[0].length;`);
481481
} else {
482482
emit(`${ind} const _e = pos + m[0].length;`);

src/gen-lexer.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -842,21 +842,23 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
842842
let wc = source.charCodeAt(pos);
843843
if (wc === 32 || (wc >= 9 && wc <= 13)) {
844844
do {
845-
if (wc === 10) pendingNl = true;
845+
// JS line terminators: LF, CR, LS, PS (the ECMAScript set, driving ASI / "no
846+
// LineTerminator here"). LF/CR are ASCII (here); LS/PS arrive via the \s regex below.
847+
if (wc === 10 || wc === 13) pendingNl = true;
846848
pos++;
847849
wc = source.charCodeAt(pos);
848850
} while (wc === 32 || (wc >= 9 && wc <= 13));
849851
if (wc > 127) { // a Unicode space may continue the run — absorb it like the old regex did
850852
wsReY.lastIndex = pos;
851853
const wsMatch = wsReY.exec(source);
852-
if (wsMatch) { if (wsMatch[0].includes('\n')) pendingNl = true; pos += wsMatch[0].length; }
854+
if (wsMatch) { if (/[\n\r\u2028\u2029]/.test(wsMatch[0])) pendingNl = true; pos += wsMatch[0].length; }
853855
}
854856
continue;
855857
}
856858
if (wc > 127) {
857859
wsReY.lastIndex = pos;
858860
const wsMatch = wsReY.exec(source);
859-
if (wsMatch) { if (wsMatch[0].includes('\n')) pendingNl = true; pos += wsMatch[0].length; continue; }
861+
if (wsMatch) { if (/[\n\r\u2028\u2029]/.test(wsMatch[0])) pendingNl = true; pos += wsMatch[0].length; continue; }
860862
}
861863
}
862864

@@ -1178,7 +1180,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
11781180
if (!tm.skip) {
11791181
push(mkNamed(tm.name, m[0], pos, tm.k));
11801182
} else {
1181-
if (m[0].includes('\n')) pendingNl = true; // a skipped comment spanning a newline still terminates the previous line
1183+
if (/[\n\r\u2028\u2029]/.test(m[0])) pendingNl = true; // a skipped comment spanning a line terminator still terminates the previous line
11821184
// An inline comment (indentation grammars) ENDS a plain scalar — flag the next token so a
11831185
// multi-line fold won't reabsorb a post-comment line (yaml-test-suite 8XDJ / BF9H).
11841186
if (indent?.comment && m[0].startsWith(indent.comment)) pendingComment = true;

src/target-go.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function scanTok(t: LexTok, defs: string[], rxTok?: string, tplTok?: string): st
5050
const name = (t as { name: string }).name;
5151
const stateful = rxTok !== undefined || tplTok !== undefined;
5252
if (tplTok !== undefined && name === tplTok) return ''; // template token scanned by the state machine
53-
const push = (endE: string) => (t.skip ? `if strings.Contains(src[pos:${endE}], "\\n") { pendingNl = true }; ` : stateful ? `emit(${J(name)}, src[pos:${endE}], pos, ${endE}); ` : `pushTok(${J(name)}, src[pos:${endE}], pos, ${endE}); `);
53+
const push = (endE: string) => (t.skip ? `if strings.ContainsAny(src[pos:${endE}], "\\n\\r") { pendingNl = true }; ` : stateful ? `emit(${J(name)}, src[pos:${endE}], pos, ${endE}); ` : `pushTok(${J(name)}, src[pos:${endE}], pos, ${endE}); `);
5454
const gate = rxTok !== undefined && name === rxTok ? '!prevIsValue() && ' : '';
5555
if (t.kind === 'run') return `\t\tif ${gate}${rangeCond('c', t.first)} {
5656
\t\t\te := pos + 1
@@ -156,8 +156,8 @@ ${emitHooks}
156156
\t_ = pendingNl
157157
${rxState}${tplState}${emitFn}${pushTokFn}${defs.length ? '\t_s = src\n' : ''}\tfor pos < n {
158158
\t\tc := int(src[pos])
159-
\t\tif c == 10 { pendingNl = true; pos++; continue } // only LF (10) is newline-before (matches the interpreter); CR is plain whitespace
160-
\t\tif c == 13 || c == 32 || c == 9 || c == 11 || c == 12 || c == 160 || c == 5760 || (c >= 8192 && c <= 8202) || c == 8239 || c == 8287 || c == 12288 || c == 65279 { pos++; continue }
159+
\t\tif c == 10 || c == 13 { pendingNl = true; pos++; continue } // JS line terminators LF/CR (matches the interpreter; LS/PS are multi-byte: non-ASCII boundary)
160+
\t\tif c == 32 || c == 9 || c == 11 || c == 12 || c == 160 || c == 5760 || (c >= 8192 && c <= 8202) || c == 8239 || c == 8287 || c == 12288 || c == 65279 { pos++; continue }
161161
${tplDispatch}${toks}
162162
${puncts}
163163
\t\tpanic(fmt.Sprintf("lex error at %d", pos))

src/target-rust.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function scanTok(t: LexTok, defs: string[], rxTok?: string, tplTok?: string): st
5454
const stateful = rxTok !== undefined || tplTok !== undefined;
5555
if (tplTok !== undefined && name === tplTok) return ''; // template token scanned by the state machine
5656
const nlVar = stateful ? 'st.pending_nl' : 'pending_nl';
57-
const push = (endE: string) => (t.skip ? `if src[pos..${endE}].contains('\\n') { ${nlVar} = true; } ` : stateful ? `st.emit(${J(name)}, &src[pos..${endE}], pos, ${endE}); ` : `toks.push(Tok { kind: ${J(name)}, text: &src[pos..${endE}], off: pos, end: ${endE}, nl: pending_nl }); pending_nl = false; `);
57+
const push = (endE: string) => (t.skip ? `if src[pos..${endE}].bytes().any(|c| c == 10 || c == 13) { ${nlVar} = true; } ` : stateful ? `st.emit(${J(name)}, &src[pos..${endE}], pos, ${endE}); ` : `toks.push(Tok { kind: ${J(name)}, text: &src[pos..${endE}], off: pos, end: ${endE}, nl: pending_nl }); pending_nl = false; `);
5858
const gate = rxTok !== undefined && name === rxTok ? '!st.prev_is_value() && ' : '';
5959
if (t.kind === 'run') return ` if ${gate}${rangeCond('c', t.first)} {
6060
let mut e = pos + 1;
@@ -163,8 +163,8 @@ ${open}
163163
let mut pos = 0usize;
164164
while pos < n {
165165
let c = b[pos] as u32;
166-
if c == 32 || c == 9 || c == 13 { pos += 1; continue; } // CR is plain whitespace, NOT newline-before
167-
if c == 10 { ${nlVar} = true; pos += 1; continue; } // only LF (10) is newline-before (matches the interpreter)
166+
if c == 32 || c == 9 { pos += 1; continue; }
167+
if c == 10 || c == 13 { ${nlVar} = true; pos += 1; continue; } // JS line terminators LF/CR (matches the interpreter; LS/PS multi-byte: non-ASCII boundary)
168168
${tplDispatch}${toks}
169169
${puncts}
170170
panic!("lex error at {}", pos);

src/target-ts.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ function scanTok(t: LexTok, defs: string[], rxTok?: string, tplTok?: string): st
5050
if (tplTok !== undefined && name === tplTok) return ''; // template token is scanned by the state machine
5151
// `emit(...)` threads the lexer state in stateful mode; a plain push otherwise. A skipped
5252
// token (comment) still records a newline it spans, so `sameLine` sees it.
53-
const push = (endExpr: string) => (t.skip ? `if (src.slice(pos, ${endExpr}).indexOf('\\n') >= 0) pendingNl = true; ` : `${stateful ? 'emit' : 'push'}(${J(name)}, src.slice(pos, ${endExpr}), pos, ${endExpr}); `);
53+
const push = (endExpr: string) => (t.skip ? `if (/[\\n\\r\\u2028\\u2029]/.test(src.slice(pos, ${endExpr}))) pendingNl = true; ` : `${stateful ? 'emit' : 'push'}(${J(name)}, src.slice(pos, ${endExpr}), pos, ${endExpr}); `);
5454
const gate = rxTok !== undefined && name === rxTok ? '!prevIsValue() && ' : '';
5555
if (t.kind === 'run') return ` if (${gate}${rangeCond('c', t.first)}) {
5656
let e = pos + 1;
@@ -147,10 +147,9 @@ ${emitHooks}
147147
let pendingNl = false;
148148
${defs.length ? ' _s = src;\n' : ''}${rxState}${tplState}${stateful ? emitFn : ' const push = (kind: string, text: string, off: number, end: number) => { toks.push({ kind, text, off, end, nl: pendingNl }); pendingNl = false; };\n'} while (pos < n) {
149149
const c = src.charCodeAt(pos);
150-
// Only LF (char 10) sets newline-before, matching the interpreter (gen-lexer.ts: only wc === 10).
151-
// CR/LS/PS are whitespace but NOT newline-before there, so a lone CR must not flip sameLine.
152-
if (c === 10) { pendingNl = true; pos++; continue; }
153-
if (c === 13 || c === 8232 || c === 8233 || c === 32 || c === 9 || c === 11 || c === 12 || c === 160 || c === 5760 || (c >= 8192 && c <= 8202) || c === 8239 || c === 8287 || c === 12288 || c === 65279) { pos++; continue; }
150+
// JS line terminators LF/CR/LS/PS set newline-before, matching the interpreter (gen-lexer.ts).
151+
if (c === 10 || c === 13 || c === 8232 || c === 8233) { pendingNl = true; pos++; continue; }
152+
if (c === 32 || c === 9 || c === 11 || c === 12 || c === 160 || c === 5760 || (c >= 8192 && c <= 8202) || c === 8239 || c === 8287 || c === 12288 || c === 65279) { pos++; continue; }
154153
${tplDispatch}${toks}
155154
${puncts}
156155
throw new Error('lex error at ' + pos + ': ' + JSON.stringify(src[pos]));

test/portable-targets.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,12 @@ const CASES: Case[] = [
135135
grammar: 'sljs', path: './fixtures/sljs.ts',
136136
accept: [
137137
'return 1;', 'return;', 'return 1 + 2;', '1 + 2;', 'return /* c */ 1;',
138-
'(a);', 'return (1);',
139-
// Only `\n` is newline-before — a lone `\r` is plain whitespace, so `return` still takes its
140-
// same-line operand (matches the interpreter; was a portable-lexer bug). CRLF still has the `\n`.
141-
'return\r1;',
138+
'(a);', 'return (1);', 'return\t1;',
142139
],
143-
reject: ['return\n1;', 'return\nx;', 'return /*\n*/ 1;', 'return // c\n 1;', 'return\r\n1;'],
140+
// `\r`, LS, PS are JS line terminators just like `\n` (ASI / "no LineTerminator here"), so a
141+
// `return` followed by any of them takes no operand — across all four lexers (interpreter,
142+
// emitted JS, portable ts/go/rust). A `\t` (tab) is whitespace but NOT a terminator → accepted above.
143+
reject: ['return\n1;', 'return\nx;', 'return /*\n*/ 1;', 'return // c\n 1;', 'return\r1;', 'return\r\n1;', 'return /*\r*/ 1;'],
144144
},
145145
{
146146
// capBelow (assignment-level) arrow functions: a NUD parsed only when minBp < the

0 commit comments

Comments
 (0)