diff --git a/source/units/Goccia.Parser.pas b/source/units/Goccia.Parser.pas index cf376c6f..7fd71c7c 100644 --- a/source/units/Goccia.Parser.pas +++ b/source/units/Goccia.Parser.pas @@ -209,6 +209,8 @@ TGocciaParser = class procedure SkipExpression; procedure SkipExpressionWithLexicalGoal( const ALexicalGoal: TGocciaLexicalGoal); + function TokenRequiresFollowingOperand( + const ATokenType: TGocciaTokenType): Boolean; // TC39 Pattern Matching function ParseMatchExpression: TGocciaMatchExpression; function ParseMatchPattern: TGocciaMatchPattern; @@ -2866,6 +2868,20 @@ function TGocciaParser.IsMatchExpressionAhead: Boolean; EnsureToken(ScanIndex, ALexicalGoal); while ScanIndex < FTokens.Count do begin + // Skip a template literal whole so its '${ ... }' substitution + // closer (a gttRightBrace under this goal) and any parentheses in + // the substitution are not miscounted as discriminant structure. + // SkipTemplateLiteral works off the live cursor, so drive it from + // ScanIndex and resume the index scan past the template; the outer + // finally restores FCurrent (see SkipTemplateLiteral). + if FTokens[ScanIndex].TokenType in [gttTemplateHead, gttTemplate] then + begin + FCurrent := ScanIndex; + SkipTemplateLiteral; + ScanIndex := FCurrent; + EnsureToken(ScanIndex, ALexicalGoal); + Continue; + end; case FTokens[ScanIndex].TokenType of gttLeftParen: Inc(Depth); @@ -4732,15 +4748,34 @@ function TGocciaParser.IfStatement: TGocciaStatement; // 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. +// SkipUntilSemicolon) and the speculative parenthesized-group probes +// (IsArrowFunction's SkipExpressionWithLexicalGoal / SkipDestructuringPattern, +// IsMatchExpressionAhead, LooksLikeTraditionalForHeader) must route templates +// through here: the '}' that closes a '${ ... }' substitution is lexed as a +// plain gttRightBrace under any non-template 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. +// +// The substitution body is scanned operand-aware (RegExp goal where an operand +// is expected, Div otherwise) so a '/' inside the substitution is classified as +// a regex literal vs a division operator just as the real parse would — a +// mis-lexed '/' would otherwise run a regex literal past the substitution's '}'. procedure TGocciaParser.SkipTemplateLiteral; var Depth: Integer; + NeedsOperand: Boolean; + CurrentType: TGocciaTokenType; + + function SubstitutionGoal: TGocciaLexicalGoal; + begin + if NeedsOperand then + Result := glgInputElementRegExp + else + Result := glgInputElementDiv; + end; + begin // A template with no substitutions is a single token. if Check(gttTemplate) then @@ -4754,27 +4789,40 @@ procedure TGocciaParser.SkipTemplateLiteral; // 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 + NeedsOperand := True; + while True 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 + // Peek under the operand-aware goal *before* testing for EOF: a plain + // IsAtEnd/Peek would lex the next token under the default goal and a '/' + // would become a runaway regex literal that swallows the closing '}'. + CurrentType := PeekWithLexicalGoal(SubstitutionGoal).TokenType; + if CurrentType = gttEOF then + Exit; // unterminated substitution; leave the cursor at EOF + case CurrentType of + gttTemplateHead, gttTemplate: + begin + SkipTemplateLiteral; + NeedsOperand := False; + end; + gttLeftBrace: + begin + Inc(Depth); + Advance; + NeedsOperand := True; + end; + gttRightBrace: + begin + if Depth = 0 then + Break; + Dec(Depth); + Advance; + NeedsOperand := False; + end; else Advance; + NeedsOperand := TokenRequiresFollowingOperand(CurrentType); + end; 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. @@ -5457,6 +5505,21 @@ function TGocciaParser.LooksLikeTraditionalForHeader: Boolean; EnsureToken(Idx, ALexicalGoal); while (Idx < FTokens.Count) and (Depth > 0) do begin + // Skip a template literal whole so its '${ ... }' substitution + // closer (a gttRightBrace under this goal) is not miscounted as a + // structural brace, which would drop Depth to 0 and misclassify a + // traditional `for(init; ...)` whose init holds a template as a + // for-of/in head. SkipTemplateLiteral works off the live cursor, so + // drive it from Idx and resume past the template; the outer finally + // restores FCurrent (see SkipTemplateLiteral). + if FTokens[Idx].TokenType in [gttTemplateHead, gttTemplate] then + begin + FCurrent := Idx; + SkipTemplateLiteral; + Idx := FCurrent; + EnsureToken(Idx, ALexicalGoal); + Continue; + end; Tok := FTokens[Idx]; case Tok.TokenType of gttLeftParen, gttLeftBracket, gttLeftBrace: @@ -8448,6 +8511,34 @@ function TGocciaParser.ContinueStatement: TGocciaStatement; Result := TGocciaContinueStatement.Create(Line, Column, TargetLabel); end; +// Whether a '/' immediately following a token of the given type begins a regex +// literal (an operand is expected) rather than a division operator. Shared by +// the speculative skips (SkipExpressionWithLexicalGoal, SkipDestructuringPattern) +// and the template-substitution scan in SkipTemplateLiteral so they classify a +// following '/' under the correct lexical goal. +function TGocciaParser.TokenRequiresFollowingOperand( + const ATokenType: TGocciaTokenType): Boolean; +begin + case ATokenType of + gttAssign, gttPlusAssign, gttMinusAssign, gttStarAssign, + gttSlashAssign, gttPercentAssign, gttPowerAssign, + gttNullishCoalescingAssign, gttLogicalAndAssign, + gttLogicalOrAssign, + gttQuestion, gttColon, gttComma, + gttOr, gttAnd, gttNullishCoalescing, + gttBitwiseOr, gttBitwiseXor, gttBitwiseAnd, + gttEqual, gttNotEqual, gttLooseEqual, gttLooseNotEqual, + gttGreater, gttGreaterEqual, gttLess, gttLessEqual, + gttInstanceof, gttIn, + gttLeftShift, gttRightShift, gttUnsignedRightShift, + gttPlus, gttMinus, gttStar, gttSlash, gttPercent, gttPower, + gttNot, gttBitwiseNot, gttTypeof, gttVoid, gttDelete, + gttNew, gttArrow: + Exit(True); + end; + Result := False; +end; + procedure TGocciaParser.SkipDestructuringPattern; var BracketCount, BraceCount: Integer; @@ -8461,28 +8552,6 @@ procedure TGocciaParser.SkipDestructuringPattern; Result := glgInputElementDiv; end; - function TokenRequiresFollowingOperand( - const ATokenType: TGocciaTokenType): Boolean; - begin - case ATokenType of - gttAssign, gttPlusAssign, gttMinusAssign, gttStarAssign, - gttSlashAssign, gttPercentAssign, gttPowerAssign, - gttNullishCoalescingAssign, gttLogicalAndAssign, - gttLogicalOrAssign, - gttQuestion, gttColon, gttComma, - gttOr, gttAnd, gttNullishCoalescing, - gttBitwiseOr, gttBitwiseXor, gttBitwiseAnd, - gttEqual, gttNotEqual, gttLooseEqual, gttLooseNotEqual, - gttGreater, gttGreaterEqual, gttLess, gttLessEqual, - gttInstanceof, gttIn, - gttLeftShift, gttRightShift, gttUnsignedRightShift, - gttPlus, gttMinus, gttStar, gttSlash, gttPercent, gttPower, - gttNot, gttBitwiseNot, gttTypeof, gttVoid, gttDelete, - gttNew, gttArrow: - Exit(True); - end; - Result := False; - end; begin BracketCount := 0; BraceCount := 0; @@ -8503,6 +8572,16 @@ procedure TGocciaParser.SkipDestructuringPattern; if CurrentType = gttEOF then Break; + // Skip a template literal whole: its '${ ... }' substitution closer is a + // gttRightBrace under this goal and would otherwise be miscounted as a + // destructuring brace (see SkipTemplateLiteral). + if CurrentType in [gttTemplateHead, gttTemplate] then + begin + SkipTemplateLiteral; + NeedsOperand := False; + Continue; + end; + case CurrentType of gttLeftBracket: begin @@ -8554,28 +8633,6 @@ procedure TGocciaParser.SkipExpressionWithLexicalGoal( Result := ALexicalGoal; end; - function TokenRequiresFollowingOperand( - const ATokenType: TGocciaTokenType): Boolean; - begin - case ATokenType of - gttAssign, gttPlusAssign, gttMinusAssign, gttStarAssign, - gttSlashAssign, gttPercentAssign, gttPowerAssign, - gttNullishCoalescingAssign, gttLogicalAndAssign, - gttLogicalOrAssign, - gttQuestion, gttColon, gttComma, - gttOr, gttAnd, gttNullishCoalescing, - gttBitwiseOr, gttBitwiseXor, gttBitwiseAnd, - gttEqual, gttNotEqual, gttLooseEqual, gttLooseNotEqual, - gttGreater, gttGreaterEqual, gttLess, gttLessEqual, - gttInstanceof, gttIn, - gttLeftShift, gttRightShift, gttUnsignedRightShift, - gttPlus, gttMinus, gttStar, gttSlash, gttPercent, gttPower, - gttNot, gttBitwiseNot, gttTypeof, gttVoid, gttDelete, - gttNew, gttArrow: - Exit(True); - end; - Result := False; - end; begin ParenCount := 0; BracketCount := 0; @@ -8594,6 +8651,17 @@ procedure TGocciaParser.SkipExpressionWithLexicalGoal( (CurrentType in [gttComma, gttRightParen]) then Break; + // Skip a template literal whole: its '${ ... }' substitution closer is a + // gttRightBrace under this goal and would otherwise be miscounted as a + // structural brace, stopping the skip mid-default-value (see + // SkipTemplateLiteral). + if CurrentType in [gttTemplateHead, gttTemplate] then + begin + SkipTemplateLiteral; + NeedsOperand := False; + Continue; + end; + case CurrentType of gttLeftParen: begin diff --git a/tests/language/expressions/string/template-interpolation-speculative-probes.js b/tests/language/expressions/string/template-interpolation-speculative-probes.js new file mode 100644 index 00000000..d7d70f13 --- /dev/null +++ b/tests/language/expressions/string/template-interpolation-speculative-probes.js @@ -0,0 +1,55 @@ +/*--- +description: Template literals with substitutions survive speculative parenthesized-group probes +features: [template-literals, template-interpolation, pattern-matching] +---*/ + +describe("template interpolation inside parenthesized expressions", () => { + test("parenthesized template with division in substitution", () => { + const x = (`d=${6 / 2}`); + expect(x).toBe("d=3"); + }); + + test("parenthesized template with regex literal in substitution", () => { + const x = (`m=${/a.c/.test("abc")}`); + expect(x).toBe("m=true"); + }); + + test("parenthesized template is not mistaken for an arrow head", () => { + const x = (`v${1 + 1}`) + "!"; + expect(x).toBe("v2!"); + }); +}); + +describe("template interpolation as a match discriminant", () => { + test("match discriminant with division in substitution", () => { + const r = match (`n=${4 / 2}`) { + "n=2": "two"; + default: "other"; + }; + expect(r).toBe("two"); + }); + + test("match discriminant with a call and brackets in substitution", () => { + const r = match (`${[10, 20][1] / 2}`) { + "10": "ten"; + default: "no"; + }; + expect(r).toBe("ten"); + }); + + test("match discriminant with regex literal in substitution", () => { + const r = match (`${/x/.test("x")}`) { + "true": "hit"; + default: "miss"; + }; + expect(r).toBe("hit"); + }); + + test("match discriminant with a no-substitution template", () => { + const r = match (`plain`) { + "plain": "ok"; + default: "no"; + }; + expect(r).toBe("ok"); + }); +}); diff --git a/tests/language/for-loop/traditional-for-template-init.js b/tests/language/for-loop/traditional-for-template-init.js new file mode 100644 index 00000000..bc1cb9e0 --- /dev/null +++ b/tests/language/for-loop/traditional-for-template-init.js @@ -0,0 +1,30 @@ +/*--- +description: Traditional for-loop headers whose parts contain template literals with substitutions +features: [compat-traditional-for-loop, template-literals, template-interpolation] +---*/ + +describe("traditional for-loop header with template literals", () => { + test("template literal in the init is recognized as a traditional for-loop", () => { + let out = ""; + for (let s = `v${1}w`; s.length > 0; s = s.slice(1)) { + out = out + s[0]; + } + expect(out).toBe("v1w"); + }); + + test("template literal with division in the init", () => { + const seen = []; + for (let s = `c=${6 / 2}`; seen.length < 1; seen.push(s)) { + // body runs once, then update pushes the substituted value + } + expect(seen).toEqual(["c=3"]); + }); + + test("for-of over template literals is not misread as a traditional header", () => { + const out = []; + for (const ch of [`a${1}`, `b${2}`]) { + out.push(ch); + } + expect(out).toEqual(["a1", "b2"]); + }); +}); diff --git a/tests/language/functions/arrow-default-template-interpolation.js b/tests/language/functions/arrow-default-template-interpolation.js new file mode 100644 index 00000000..8248eb7f --- /dev/null +++ b/tests/language/functions/arrow-default-template-interpolation.js @@ -0,0 +1,75 @@ +/*--- +description: Template literals with substitutions in arrow parameter default values +features: [arrow-function, template-literals, template-interpolation, default-parameters] +---*/ + +describe("template literal defaults in arrow parameters", () => { + test("arrow default with arithmetic substitution", () => { + const h = (t = `a${1 + 2}b`) => t; + expect(h()).toBe("a3b"); + expect(h("x")).toBe("x"); + }); + + test("arrow default with division inside substitution", () => { + const f = (label = `d=${6 / 2}`) => label; + expect(f()).toBe("d=3"); + }); + + test("arrow default with regex literal inside substitution", () => { + const g = (x = `m=${/a.c/.test("abc")}`) => x; + expect(g()).toBe("m=true"); + }); + + test("arrow default with division after a parenthesized operand", () => { + const u = (v = `q=${(10) / 2}`) => v; + expect(u()).toBe("q=5"); + }); + + test("nested template inside an arrow default", () => { + const a = (x = `a${`b${1 + 2}c`}d`) => x; + expect(a()).toBe("ab3cd"); + }); + + test("multiple substitutions then a following parameter", () => { + const b = (p = `${1}-${2}`, q = 9) => p + ":" + q; + expect(b()).toBe("1-2:9"); + expect(b("z", 3)).toBe("z:3"); + }); + + test("balanced braces inside the substitution", () => { + const c = (y = `len=${[1, 2, 3].length}`) => y; + expect(c()).toBe("len=3"); + }); + + test("tagged template as a default value", () => { + const tag = (strings, ...vals) => strings.join("|") + "/" + vals.join(","); + const f = (w = tag`a${1 + 1}b`) => w; + expect(f()).toBe("a|b/2"); + }); + + test("plain division and regex defaults still parse without templates", () => { + const d = (m = 4 / 2) => m; + const e = (n = /xy/.source) => n; + expect(d()).toBe(2); + expect(e()).toBe("xy"); + }); +}); + +describe("template literal defaults in destructured arrow parameters", () => { + test("object destructuring default with substitution", () => { + const g = ({ a = `x${1}y` }) => a; + expect(g({})).toBe("x1y"); + expect(g({ a: "z" })).toBe("z"); + }); + + test("object destructuring default with division inside substitution", () => { + const e = ({ z = `r=${8 / 4}` }) => z; + expect(e({})).toBe("r=2"); + }); + + test("array destructuring default with substitution", () => { + const h = ([first = `n${2 * 3}`] = []) => first; + expect(h()).toBe("n6"); + expect(h(["set"])).toBe("set"); + }); +});