Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions scripts/test-cli-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,4 +178,107 @@ console.log("Unsupported var recovery (ASI and compat-var flags)...");
if (compatVarNoAsiRes.json.error?.type !== "SyntaxError") throw new Error(`Expected SyntaxError without ASI, got: ${compatVarNoAsiRes.json.error?.type}`);
}

// -- Disabled-feature recovery with interpolated template literals --------------
// Regression: error recovery for a disabled construct must skip a `${ ... }`
// substitution as part of its template literal. Previously the substitution's
// closing brace was miscounted as the structural brace that ends the skipped
// region, and the trailing backtick was then re-scanned as a fresh, unterminated
// template literal -- surfacing "Unterminated template literal" at EOF instead of
// the real compat-flag diagnostic. The for, while, and do...while recovery paths
// all share the SkipBalancedParens/SkipBlock/SkipUntilSemicolon + SkipTemplate-
// Literal flow, so each is covered here.

console.log("Disabled-feature recovery with interpolated template literals...");
{
const recoveryCases = [
{
desc: "for-loop, interpolated template in block body",
compatFlag: "--compat-traditional-for-loop",
source: [
"const obj = {};",
"for (let level = 0; level < 2; level++) {",
" obj[`u${level}`] = level;",
"}",
"console.log(JSON.stringify(obj));",
"",
].join("\n"),
expected: "{}\n",
},
{
desc: "for-loop, interpolated template in non-block body",
compatFlag: "--compat-traditional-for-loop",
source: [
"const obj = {};",
"for (let i = 0; i < 2; i++) obj[`u${i}`] = i;",
'console.log("after");',
"",
].join("\n"),
expected: "after\n",
},
{
desc: "for-loop, interpolated template in loop header",
compatFlag: "--compat-traditional-for-loop",
source: [
"let x = 1;",
"for (let i = `s${0}`.length; i < 2; i++) { x = 99; }",
"console.log(x);",
"",
].join("\n"),
expected: "1\n",
},
{
desc: "while-loop, interpolated template in condition",
compatFlag: "--compat-while-loops",
source: [
"let n = 0;",
"while (`v${n}`.length > 99) { n = 99; }",
'console.log("while-after");',
"",
].join("\n"),
expected: "while-after\n",
},
{
desc: "do...while loop, interpolated template in body",
compatFlag: "--compat-while-loops",
source: [
"let n = 0;",
"do { const s = `d${n}`; } while (n > 99);",
'console.log("do-after");',
"",
].join("\n"),
expected: "do-after\n",
},
];

for (const { desc, compatFlag, source, expected } of recoveryCases) {
for (const args of [[] as string[], ["--mode=bytecode"]]) {
const label = args.length ? `${desc} (bytecode)` : desc;
const { exitCode, json } = runLoaderJson(source, args);
if (json.ok !== true) {
if (json.error?.message === "Unterminated template literal")
throw new Error(
`${label}: regressed -- a disabled construct with a template literal surfaced "Unterminated template literal" instead of recovering`,
);
throw new Error(`${label}: should recover, got ok=${json.ok} error=${JSON.stringify(json.error)}`);
}
if (exitCode !== 0) throw new Error(`${label}: should exit 0, got ${exitCode}`);
if (normalizeLineEndings(json.output) !== expected)
throw new Error(`${label}: expected output ${JSON.stringify(expected)}, got ${JSON.stringify(json.output)}`);
}

// The human-readable diagnostic must name the compat flag, not the lexer's
// unterminated-template error. Parsing is shared across modes, so check once.
const diag = Bun.spawnSync([LOADER], {
stdin: new TextEncoder().encode(source),
stdout: "pipe",
stderr: "pipe",
});
const diagOut = `${diag.stdout.toString()}${diag.stderr.toString()}`;
if (!diagOut.includes(compatFlag))
throw new Error(`${desc}: expected the diagnostic to reference ${compatFlag}, got: ${diagOut}`);
if (diagOut.includes("Unterminated template literal"))
throw new Error(`${desc}: diagnostic should not mention an unterminated template literal, got: ${diagOut}`);
}
}

console.log("\nAll test-cli-parser.ts tests passed.");
83 changes: 77 additions & 6 deletions source/units/Goccia.Parser.pas
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ TGocciaParser = class
procedure SkipBlock;
procedure SkipBalancedParens;
procedure SkipStatementOrBlock;
procedure SkipTemplateLiteral;
procedure SkipUnsupportedFunctionSignature;
// Centralized 'async function' check: enforces same-line constraint,
// handles disabled-mode fallback. Call after consuming the 'async' token.
Expand Down Expand Up @@ -4723,6 +4724,58 @@ function TGocciaParser.IfStatement: TGocciaStatement;
Line, Column);
end;

// Skips a complete template literal whose leading token (gttTemplate or
// gttTemplateHead) is at the cursor, leaving the cursor just past the closing
// backtick. The error-recovery skips below (SkipBlock, SkipBalancedParens,
// SkipUntilSemicolon) must route templates through here: the '}' that closes a
// '${ ... }' substitution is lexed as a plain gttRightBrace under the default
// goal, so naive brace/paren counting would miscount it as a structural brace,
// stop early, and then re-scan the trailing span as a fresh, unterminated
// template literal. After consuming that '}', the continuation span must be
// re-lexed with the template-tail goal, exactly as ParseTemplateLiteral does.
procedure TGocciaParser.SkipTemplateLiteral;
var
Depth: Integer;
begin
// A template with no substitutions is a single token.
if Check(gttTemplate) then
begin
Advance;
Exit;
end;

Advance; // gttTemplateHead: `...${
repeat
// Skip the substitution expression up to the '}' that closes it, treating
// nested templates as units and balancing any inner object/block braces.
Depth := 0;
while not IsAtEnd do
begin
if Check(gttTemplateHead) or Check(gttTemplate) then
SkipTemplateLiteral
else if Check(gttLeftBrace) then
begin
Inc(Depth);
Advance;
end
else if Check(gttRightBrace) then
begin
if Depth = 0 then
Break;
Dec(Depth);
Advance;
end
else
Advance;
end;
if IsAtEnd then
Exit;
Advance; // gttRightBrace closing the substitution
// Re-lex the following span with the template-tail goal so it becomes a
// TemplateMiddle/TemplateTail token instead of a stray template start.
until ConsumeTemplateContinuation.TokenType = gttTemplateTail;
end;

procedure TGocciaParser.SkipBlock;
var
Depth: Integer;
Expand All @@ -4732,9 +4785,14 @@ procedure TGocciaParser.SkipBlock;
Depth := 1;
while not IsAtEnd and (Depth > 0) do
begin
if Check(gttLeftBrace) then Inc(Depth)
else if Check(gttRightBrace) then Dec(Depth);
if Depth > 0 then Advance;
if Check(gttTemplateHead) or Check(gttTemplate) then
SkipTemplateLiteral
else
begin
if Check(gttLeftBrace) then Inc(Depth)
else if Check(gttRightBrace) then Dec(Depth);
if Depth > 0 then Advance;
end;
end;
Consume(gttRightBrace, 'Expected "}"',
SSuggestCloseBlock);
Expand All @@ -4749,9 +4807,14 @@ procedure TGocciaParser.SkipBalancedParens;
Depth := 1;
while not IsAtEnd and (Depth > 0) do
begin
if Check(gttLeftParen) then Inc(Depth)
else if Check(gttRightParen) then Dec(Depth);
if Depth > 0 then Advance;
if Check(gttTemplateHead) or Check(gttTemplate) then
SkipTemplateLiteral
else
begin
if Check(gttLeftParen) then Inc(Depth)
else if Check(gttRightParen) then Dec(Depth);
if Depth > 0 then Advance;
end;
end;
Consume(gttRightParen, 'Expected ")"',
SSuggestCloseParenExpression);
Expand Down Expand Up @@ -7610,6 +7673,14 @@ procedure TGocciaParser.SkipUntilSemicolon;
(Previous.Line < Peek.Line) then
Exit;

// Skip template literals whole so a substitution's closing '}' is not
// miscounted as a structural brace (see SkipTemplateLiteral).
if Check(gttTemplateHead) or Check(gttTemplate) then
begin
SkipTemplateLiteral;
Continue;
end;

case Peek.TokenType of
gttLeftParen, gttLeftBracket, gttLeftBrace, gttLess: Inc(Depth);
gttRightParen, gttRightBracket, gttRightBrace, gttGreater: Dec(Depth);
Expand Down
26 changes: 26 additions & 0 deletions tests/language/statements/unsupported-features.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ describe.runIf(hasGoccia)("unsupported features are skipped", () => {
expect(x).toBe(1);
});

test("for loop with interpolated template literal in block body is skipped", () => {
const obj = {};
for (let level = 0; level < 2; level++) {
obj[`u${level}`] = level;
}
expect(Object.keys(obj).length).toBe(0);
});

test("for loop with interpolated template literal in non-block body is skipped", () => {
const obj = {};
for (let i = 0; i < 2; i++) obj[`u${i}`] = i;
expect(Object.keys(obj).length).toBe(0);
});

test("for loop with interpolated template literal in its header is skipped", () => {
let x = 1;
for (let i = `s${0}`.length; i < 2; i++) { x = 99; }
expect(x).toBe(1);
});

test("for loop with nested interpolated template literals is skipped", () => {
const parts = [];
for (let i = 0; i < 2; i++) { parts.push(`a${`b${i}c`}d`); }
expect(parts.length).toBe(0);
});

test("code after skipped while loop executes", () => {
let x = 1;
while (x < 10) { x = 99; }
Expand Down
Loading