/
githubmirror
/
webpack
Обзор
Документация
Войти
/
githubmirror
/
webpack
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
test/WebpackParser.unittest.js
2 106 строк
72 KB
Alexander Akait
perf: speed up CSS, HTML and JavaScript parsing and cut parser memory (#21562)
31 июл 2026, 12:27
Не верифицирован
31 июл 2026, 12:27
c7142e3
Код
Авторство
О чём код?
"use strict"; // cspell:ignore ypeof averyvery ahri aafom Unsyntactic const JavascriptParser = require("../lib/javascript/JavascriptParser"); /** * @param {string} code source code the parser mapped * @returns {JavascriptParser} parser whose getLocation maps offsets in `code` */ const locationMapperFor = (code) => { const parser = new JavascriptParser("auto"); parser._source = code; return parser; }; /** * @param {string} code source * @param {object=} options extra parse options * @returns {import("../lib/javascript/JavascriptParser").ParseResult} result */ const parse = (code, options) => JavascriptParser._parse( code, /** @type {import("../lib/javascript/JavascriptParser").InternalParseOptions} */ ({ sourceType: "script", ecmaVersion: "latest", comments: true, ranges: true, allowHashBang: true, ...options }) ); describe("WebpackParser", () => { describe("lazy comments", () => { it("should collect hashbang, line, block and html comments lazily", () => { const { comments } = parse( "#!/usr/bin/env node\n// line\nconst x = /* block */ 1; <!-- html\n" ); expect(comments.map((c) => [c.type, c.value])).toEqual([ ["Line", "/usr/bin/env node"], ["Line", " line"], ["Block", " block "], ["Line", " html"] ]); expect(comments[1].range).toEqual([20, 27]); // comments carry no `loc` — locations derive from offsets expect(comments[1].loc).toBeUndefined(); }); it("should memoize and accept explicit value writes", () => { const { comments } = parse("// abc\n"); expect(comments[0].value).toBe(" abc"); // memoized second read expect(comments[0].value).toBe(" abc"); comments[0].value = "override"; expect(comments[0].value).toBe("override"); }); it("should collect comments eagerly when ranges are off", () => { const { comments } = parse("// line\n/* block */ x;", { ranges: false }); expect(comments.map((c) => [c.type, c.value])).toEqual([ ["Line", " line"], ["Block", " block "] ]); }); it("should report unterminated block comments", () => { expect(() => parse("const x = 1; /* nope")).toThrow( /Unterminated comment/ ); }); it("should map positions across CRLF, CR, LS and PS line breaks", () => { const code = "// a\r\n// b\r// c\u2028// d\u2029// e\n"; const { comments } = parse(code); const mapper = locationMapperFor(code); expect( comments.map( (c) => /** @type {import("../lib/Dependency").RealDependencyLocation} */ (mapper.getLocation(c)).start ) ).toEqual([ { line: 1, column: 0 }, { line: 2, column: 0 }, { line: 3, column: 0 }, { line: 4, column: 0 }, { line: 5, column: 0 } ]); }); }); describe("template fast path", () => { it("should cook CR and CRLF chunks through the cold path", () => { const { ast } = parse("`a\r\nb\rc`;"); const statement = /** @type {import("estree").ExpressionStatement} */ (ast.body[0]); const template = /** @type {import("estree").TemplateLiteral} */ (statement.expression); const quasi = template.quasis[0]; expect(quasi.value.cooked).toBe("a\nb\nc"); }); it("should report unterminated templates", () => { expect(() => parse("const x = `nope")).toThrow(/Unterminated template/); expect(() => // eslint-disable-next-line no-template-curly-in-string parse("const x = `nope ${y} still open") ).toThrow(/Unterminated template/); }); }); describe("regexp fast path", () => { it("should report unterminated regular expressions", () => { expect(() => parse("x = /nope")).toThrow( /Unterminated regular expression/ ); expect(() => parse("x = /new\nline/")).toThrow( /Unterminated regular expression/ ); }); it("should report flag errors with acorn's messages", () => { expect(() => parse("x = /a/q")).toThrow( /Invalid regular expression flag/ ); expect(() => parse("x = /a/gg")).toThrow( /Duplicate regular expression flag/ ); expect(() => parse("x = /a/uv")).toThrow( /Invalid regular expression flag/ ); expect(() => parse("x = /a/\\u0067")).toThrow(/Unexpected token/); }); it("should respect the ecmaVersion for allowed flags", () => { expect(() => parse("x = /a/s", { ecmaVersion: 6 })).toThrow( /Invalid regular expression flag/ ); expect(parse("x = /a/s", { ecmaVersion: 2018 }).ast).toBeDefined(); }); it("should report invalid patterns with V8's messages", () => { expect(() => parse("x = /(/")).toThrow(/Invalid regular expression/); }); it("should build the literal value once", () => { const { ast } = parse("x = /a[/]b/gi;"); const statement = /** @type {import("estree").ExpressionStatement} */ (ast.body[0]); const assignment = /** @type {import("estree").AssignmentExpression} */ (statement.expression); const literal = /** @type {import("estree").RegExpLiteral} */ (assignment.right); expect(literal.regex).toEqual({ pattern: "a[/]b", flags: "gi" }); expect(literal.value).toBeInstanceOf(RegExp); expect(/** @type {RegExp} */ (literal.value).source).toBe("a[/]b"); }); }); describe("number fast path", () => { /** * @param {string} code source * @param {object=} options extra parse options * @returns {import("estree").Literal} the sole declarator's literal init */ const literal = (code, options) => { const declaration = /** @type {import("estree").VariableDeclaration} */ (parse(code, options).ast.body[0]); return /** @type {import("estree").Literal} */ ( declaration.declarations[0].init ); }; it("should read integers, decimals and bare zero on the fast path", () => { expect(literal("var x = 0").value).toBe(0); expect(literal("var x = 42").value).toBe(42); expect(literal("var x = 1.5").value).toBe(1.5); expect(literal("var x = 0.5").value).toBe(0.5); expect(literal("var x = 100.").value).toBe(100); expect(literal("var x = 1.").value).toBe(1); expect(literal("var x = .5").value).toBe(0.5); }); it("should read radix, exponent, separator and bigint literals on the cold path", () => { expect(literal("var x = 0x1f").value).toBe(31); expect(literal("var x = 0o17").value).toBe(15); expect(literal("var x = 0b101").value).toBe(5); expect(literal("var x = 1e3").value).toBe(1000); expect(literal("var x = 1_000").value).toBe(1000); expect(literal("var x = 1234567890123456").value).toBe(1234567890123456); expect(typeof literal("var x = 123n").value).toBe("bigint"); }); it("should reject an identifier directly after a number", () => { expect(() => parse("var x = 0abc")).toThrow( /Identifier directly after number/ ); expect(() => parse("var x = 1.5abc")).toThrow( /Identifier directly after number/ ); }); }); describe("string fast path", () => { /** * @param {string} code source * @param {object=} options extra parse options * @returns {import("estree").Literal} the sole declarator's literal init */ const literal = (code, options) => { const declaration = /** @type {import("estree").VariableDeclaration} */ (parse(code, options).ast.body[0]); return /** @type {import("estree").Literal} */ ( declaration.declarations[0].init ); }; it("should read plain strings on the fast path", () => { expect(literal('var x = "abc"').value).toBe("abc"); expect(literal("var x = 'abc'").value).toBe("abc"); expect(literal('var x = ""').value).toBe(""); expect(literal("var x = 'it\"s'").value).toBe('it"s'); const lit = literal('var x = "abc"'); expect(lit.raw).toBe('"abc"'); expect(lit.range).toEqual([8, 13]); }); it("should read a string ending at the end of input", () => { expect(literal("var x = 'tail'").value).toBe("tail"); }); it("should allow unescaped LS/PS in strings (ES2019+) but delegate them below ES2019", () => { expect(literal("var x = '\u2028'").value).toBe("\u2028"); expect(literal("var x = '\u2029'").value).toBe("\u2029"); expect(() => parse("var x = '\u2028'", { ecmaVersion: 2018 })).toThrow( /Unterminated string constant/ ); }); it("should cook the common single-char escapes on the fast path", () => { expect(literal('var x = "a\\nb"').value).toBe("a\nb"); expect(literal('var x = "a\\tb"').value).toBe("a\tb"); expect(literal('var x = "a\\rb"').value).toBe("a\rb"); expect(literal('var x = "a\\bb"').value).toBe("a\bb"); expect(literal('var x = "a\\fb"').value).toBe("a\fb"); expect(literal('var x = "a\\vb"').value).toBe("a\u000Bb"); expect(literal('var x = "a\\\\b"').value).toBe("a\\b"); expect(literal("var x = 'it\\'s'").value).toBe("it's"); expect(literal('var x = "q\\"q"').value).toBe('q"q'); // unknown escapes are the escaped char verbatim expect(literal('var x = "a\\qb"').value).toBe("aqb"); expect(literal('var x = "a\\éb"').value).toBe("aéb"); }); it("should cook line continuations on the fast path", () => { expect(literal('var x = "a\\\nb"').value).toBe("ab"); expect(literal('var x = "a\\\r\nb"').value).toBe("ab"); expect(literal('var x = "a\\\rb"').value).toBe("ab"); expect( literal(`var x = "a\\${String.fromCharCode(0x2028)}b"`).value ).toBe("ab"); expect( literal(`var x = "a\\${String.fromCharCode(0x2029)}b"`).value ).toBe("ab"); }); it("should route hex, unicode and octal escapes through the cold reader", () => { expect(literal('var x = "a\\x41b"').value).toBe("aAb"); expect(literal('var x = "a\\u0041b"').value).toBe("aAb"); expect(literal('var x = "a\\u{1F600}b"').value).toBe("a\u{1F600}b"); expect(literal('var x = "a\\101b"').value).toBe("aAb"); expect(literal('var x = "a\\0b"').value).toBe("a b"); // octal in strict mode still throws through the cold reader expect(() => parse('"use strict";\nvar x = "\\101";')).toThrow( /Octal literal in strict mode/ ); }); it("should cook a string mixing owned and delegated escapes", () => { expect(literal('var x = "a\\n\\x41\\t\\\\z"').value).toBe("a\nA\t\\z"); }); it("should report a lone trailing backslash as unterminated", () => { expect(() => parse('var x = "abc\\')).toThrow( /Unterminated string constant/ ); }); it("should report strings broken by a line terminator", () => { expect(() => parse('var x = "a\nb"')).toThrow( /Unterminated string constant/ ); expect(() => parse('var x = "a\rb"')).toThrow( /Unterminated string constant/ ); }); it("should report unterminated strings", () => { expect(() => parse('var x = "nope')).toThrow( /Unterminated string constant/ ); expect(() => parse("var x = '")).toThrow(/Unterminated string constant/); }); it("should read strings identically when acorn tracks locations", () => { expect( literal('var x = "abc"', { ranges: false, locations: true }).value ).toBe("abc"); }); }); describe("automatic semicolon insertion", () => { /** * @param {string} code source the parser maps * @param {[number, number]} current current statement range * @param {[number, number]=} prev previous statement range * @returns {JavascriptParser} parser positioned in `current` */ const asiParserFor = (code, current, prev) => { const parser = new JavascriptParser("auto"); parser._source = code; parser.statementPath = [/** @type {EXPECTED_ANY} */ ({ range: current })]; if (prev) { parser.prevStatement = /** @type {EXPECTED_ANY} */ ({ range: prev }); } return parser; }; it("should derive ASI positions from the source text", () => { // no real semicolon → end and (via prev) start are ASI positions const asi = asiParserFor("a\nb", [2, 3], [0, 1]); expect(asi.isAsiPosition(3)).toBe(true); expect(asi.isAsiPosition(2)).toBe(true); // a real semicolon terminates the statement → not an ASI position const term = asiParserFor("x;\ny;", [3, 5], [0, 2]); expect(term.isAsiPosition(5)).toBe(false); expect(term.isAsiPosition(3)).toBe(false); // a position that is neither statement boundary is never ASI expect(term.isAsiPosition(4)).toBe(false); }); it("should honor set/unsetAsiPosition overrides over the source", () => { const term = asiParserFor("x;\ny;", [3, 5]); // force ASI even though a real semicolon is present term.setAsiPosition(5); expect(term.isAsiPosition(5)).toBe(true); const asi = asiParserFor("a\nb", [2, 3]); // force a real semicolon even though the source relies on ASI asi.unsetAsiPosition(3); expect(asi.isAsiPosition(3)).toBe(false); }); it("should treat a comma-continued sequence element as non-ASI", () => { // the separator can trail whitespace, line and block comments for (const code of ["a,b", "a ,b", "a\t,b", "a//c\n,b", "a/* c */,b"]) { const parser = new JavascriptParser("auto"); parser._source = code; expect(parser._isAsiPosition(1)).toBe(false); } // a genuine line break with no separator is an ASI position; a lone // slash (division) is a token, not a comment, so it stays ASI too for (const code of [ "a\nb", "a//c\nb", "a/* c */\nb", "a", "a/* c */", "a/b" ]) { const parser = new JavascriptParser("auto"); parser._source = code; expect(parser._isAsiPosition(1)).toBe(true); } }); it("should look past a parenthesized element's trailing `)` for the separator", () => { // a parenthesized node's range excludes its wrapping parens, so the // `,`/`;` separator sits behind one or more `)` — still non-ASI. `pos` // is the end of the inner `a`, i.e. the first trailing `)`. for (const [code, pos] of [ ["(a),b", 2], ["((a)),b", 3], ["(a) ,b", 2], ["(a);b", 2] ]) { const parser = new JavascriptParser("auto"); parser._source = /** @type {string} */ (code); expect(parser._isAsiPosition(/** @type {number} */ (pos))).toBe(false); } }); it("should assume ASI when no source text is available", () => { const parser = new JavascriptParser("auto"); parser.statementPath = [/** @type {EXPECTED_ANY} */ ({ range: [0, 1] })]; expect(parser.isAsiPosition(1)).toBe(true); }); it("should throw when queried outside of a statement", () => { const parser = new JavascriptParser("auto"); parser.statementPath = []; expect(() => parser.isAsiPosition(0)).toThrow("Not in statement"); }); it("should keep splitting statements at ASI boundaries", () => { expect(parse("x").ast.body).toHaveLength(1); expect(parse("a\nb").ast.body).toHaveLength(2); const { ast } = parse("var x = 1.5\nvar y = 0", { ranges: false }); expect(ast.body).toHaveLength(2); }); it("should tokenize non-ASCII identifiers and unicode whitespace", () => { const { ast } = parse("var π = 1;\u00A0var café = 2;"); const first = /** @type {import("estree").VariableDeclaration} */ (ast.body[0]); expect( /** @type {import("estree").Identifier} */ (first.declarations[0].id) .name ).toBe("π"); expect(ast.body).toHaveLength(2); }); }); describe("reserved words and bindings", () => { it("should reject contextual and reserved identifiers like acorn", () => { expect(() => parse("function* g() { var yield; }")).toThrow( /Cannot use 'yield' as identifier inside a generator/ ); expect(() => parse("async function f() { var await; }")).toThrow( /Cannot use 'await' as identifier inside an async function/ ); expect(() => parse("class C { x = arguments; }", { sourceType: "module" }) ).toThrow(/Cannot use 'arguments' in class field initializer/); expect(() => parse("class C { static { arguments; } }", { sourceType: "module" }) ).toThrow(/class static initialization block/); expect(() => parse("var enum;", { sourceType: "module" })).toThrow( /The keyword 'enum' is reserved/ ); expect(() => parse("'use strict'; var eval;")).toThrow( /Binding eval in strict mode/ ); }); it("should allow keywords as property names", () => { expect( parse("obj.class; obj.enum; ({ if: 1, default: 2 });").ast ).toBeDefined(); }); it("should reject escape sequences in keywords like acorn", () => { // the owned `next` keyword-escape guard fires when a keyword token // carries a `\uXXXX` escape expect(() => parse("\\u0069f (x) {}")).toThrow( /Escape sequence in keyword if/ ); expect(() => parse("\\u0074ypeof x;")).toThrow( /Escape sequence in keyword typeof/ ); // `liberal` idents (keyword as property name) pass `ignore`, so an // escaped keyword is allowed there expect(parse("obj.\\u0069f;").ast).toBeDefined(); }); it("should allow await as a sloppy-mode identifier", () => { const { ast } = parse("var await = 1; await;"); const declaration = /** @type {import("estree").VariableDeclaration} */ (ast.body[0]); expect( /** @type {import("estree").Identifier} */ ( declaration.declarations[0].id ).name ).toBe("await"); }); it("should detect redeclarations and undefined exports", () => { expect(() => parse("let x; let x;")).toThrow( /Identifier 'x' has already been declared/ ); expect(() => parse("{ let a; let a; }")).toThrow(/already been declared/); expect(parse("var x; var x;").ast).toBeDefined(); expect(() => parse("export { x };", { sourceType: "module" })).toThrow( /Export 'x' is not defined/ ); expect( parse("export { x }; var x;", { sourceType: "module" }).ast ).toBeDefined(); }); }); describe("identifier word cache", () => { /** * @param {string} code source * @returns {string[]} every Identifier name in program order */ const names = (code) => { /** @type {string[]} */ const found = []; JSON.stringify(parse(code).ast, (_key, value) => { if (value && value.type === "Identifier") found.push(value.name); return value; }); return found; }; it("should return identical names for repeated, uncached and long identifiers", () => { // repeated words hit the cache; 1-char and >12-char words bypass it expect(names("foo + foo + f + averyveryLongIdentifier;")).toEqual([ "foo", "foo", "f", "averyveryLongIdentifier" ]); }); it("should survive hash collisions by content check", () => { // `aaaa`/`ahri` and `aaaa`/`aafom` share a cache slot (same djb2&mask), // exercising the same-length and length-mismatch collision branches expect(names("aaaa + ahri + aaaa + aafom + aaaa;")).toEqual([ "aaaa", "ahri", "aaaa", "aafom", "aaaa" ]); }); it("should serve multi-char operators as static strings", () => { // repeated occurrences reuse one string per operator; sizes 1-4 const { ast } = parse( "a < b; a << b; a <<= b; a === b; a === c; a >>>= b; x &&= y;" ); const ops = ast.body.map( (s) => /** @type {{ expression: { operator: string } }} */ ( /** @type {unknown} */ (s) ).expression.operator ); expect(ops).toEqual(["<", "<<", "<<=", "===", "===", ">>>=", "&&="]); }); }); describe("token context (finishToken exprAllowed)", () => { /** * @param {string} code source * @returns {string} the sole expression's node type */ const exprType = (code) => { const statement = /** @type {import("estree").ExpressionStatement} */ (parse(code).ast.body[0]); return statement.expression.type; }; it("should classify braces as blocks or objects like acorn", () => { // colon in block vs object context, nested blocks, function bodies const { ast } = parse( "foo: { bar(); } x = { a: { b: 1 } }; { { } } y = function () { return 1; };" ); expect(ast.body.map((s) => s.type)).toEqual([ "LabeledStatement", "ExpressionStatement", "BlockStatement", "ExpressionStatement" ]); // a line terminator after `return` turns the brace into a block const returned = /** @type {import("estree").FunctionDeclaration} */ (parse("function f() { return {}; }").ast.body[0]); const ret = /** @type {import("estree").ReturnStatement} */ ( returned.body.body[0] ); expect(/** @type {import("estree").Node} */ (ret.argument).type).toBe( "ObjectExpression" ); const broken = /** @type {import("estree").FunctionDeclaration} */ (parse("function f() { return\n{}; }").ast.body[0]); expect( /** @type {import("estree").ReturnStatement} */ (broken.body.body[0]) .argument ).toBeNull(); // a stray closer still pops safely before the parser rejects it expect(() => parse("}")).toThrow(/Unexpected token/); }); it("should read `/` after a value as division, not a regexp", () => { // the `else` branch: exprAllowed follows the token type's beforeExpr expect(exprType("a / b / c")).toBe("BinaryExpression"); expect(exprType("1 / 2")).toBe("BinaryExpression"); expect(exprType("(1 / n) ** (1 / 2)")).toBe("BinaryExpression"); expect(exprType("a.b / c")).toBe("BinaryExpression"); expect(exprType("a[b] / c")).toBe("BinaryExpression"); expect(exprType("f() / 2")).toBe("BinaryExpression"); }); it("should read `/` where an expression is allowed as a regexp", () => { // the updateContext branch (parenR pop) and value-position defaults expect(exprType("x = /re/g")).toBe("AssignmentExpression"); expect(parse("if (a) /re/.test(b);").ast).toBeDefined(); expect(parse("function f() { return /re/; }").ast).toBeDefined(); expect( // eslint-disable-next-line no-template-curly-in-string parse("var t = `${1 / 2}${/re/.source}`;").ast ).toBeDefined(); }); it("should keep `/` after a keyword-valued property name as division", () => { // keyword-after-dot forbids an expression, so the next `/` divides expect(parse("a.in / b; a.of / c;").ast).toBeDefined(); }); it("should re-allow an expression after `of` and generator `yield`", () => { // the inlined name.updateContext true branches: the `/` lexes a regexp expect(parse("for (x of /re/g);").ast).toBeDefined(); expect(parse("function* g() { yield /re/g; }").ast).toBeDefined(); }); it("should read multi-char `.` and `=` tokens in the owned scanner", () => { // `...`, `==`, `===` and `=>` miss the single-char dispatch fast paths expect(exprType("f(...a)")).toBe("CallExpression"); expect(exprType("a == b")).toBe("BinaryExpression"); expect(exprType("a === b")).toBe("BinaryExpression"); expect(exprType("(x) => x")).toBe("ArrowFunctionExpression"); }); }); describe("expression atoms", () => { it("should parse keyword literals and this on the fast path", () => { const { ast } = parse("this; true; false; null; 1.5;"); expect( ast.body.map( (s) => /** @type {import("estree").ExpressionStatement} */ (s).expression .type ) ).toEqual(["ThisExpression", "Literal", "Literal", "Literal", "Literal"]); const literal = /** @type {import("estree").Literal} */ ( /** @type {import("estree").ExpressionStatement} */ (ast.body[1]) .expression ); expect(literal.raw).toBe("true"); expect(literal.value).toBe(true); }); it("should parse async function expressions and async arrows", () => { expect( parse("const f = async function() { return 1; };").ast ).toBeDefined(); expect( parse("const g = async x => x; const h = x => x;").ast ).toBeDefined(); // ASI keeps `async` a plain identifier, so the arrow is unexpected expect(() => parse("async\n() => {};")).toThrow(/Unexpected token/); }); }); describe("statements", () => { it("should parse declarations, blocks and expression statements", () => { const { ast } = parse( "var a = 1, b; { let c = 2; } d = 3; for (var i = 0; i < 3; i++) {}" ); expect(ast.body.map((s) => s.type)).toEqual([ "VariableDeclaration", "BlockStatement", "ExpressionStatement", "ForStatement" ]); const declaration = /** @type {import("estree").VariableDeclaration} */ ( ast.body[0] ); expect(declaration.kind).toBe("var"); expect( declaration.declarations.map((d) => d.init && d.init.type) ).toEqual(["Literal", null]); }); it("should parse if/else chains and returns on the fast path", () => { const { ast } = parse( "if (a) b(); else if (c) { d(); } function f(){ if (x) return y; return; }" ); const ifStatement = /** @type {import("estree").IfStatement} */ ( ast.body[0] ); expect(ifStatement.type).toBe("IfStatement"); expect( /** @type {import("estree").IfStatement} */ (ifStatement.alternate).type ).toBe("IfStatement"); // the script-mode helper allows top-level return; module mode doesn't expect(() => parse("return 1;", { sourceType: "module" })).toThrow( /'return' outside of function/ ); }); it("should keep acorn's declaration checks", () => { expect(() => parse("const f;")).toThrow(/Unexpected token/); expect(() => parse("let {a};")).toThrow( /Complex binding patterns require an initialization value/ ); expect( parse("function f(){ 'use strict'; return 1; }").ast ).toBeDefined(); }); it("should dispatch let declarations and let-as-identifier like acorn", () => { const { ast } = parse("let a = 1; let = 2; let.x; let(1);"); expect(ast.body.map((s) => s.type)).toEqual([ "VariableDeclaration", "ExpressionStatement", "ExpressionStatement", "ExpressionStatement" ]); expect( /** @type {import("estree").VariableDeclaration} */ (ast.body[0]).kind ).toBe("let"); expect(() => parse("if (a) let b = 1;")).toThrow(/Unexpected token/); // `let [` is the one head where isLet stays true in statement position expect(() => parse("if (a) let [b] = 1;")).toThrow(/Unexpected token/); expect(() => parse("if (a) const b = 1;")).toThrow(/Unexpected token/); }); it("should parse labeled statements through acorn's tail", () => { const { ast } = parse("outer: for (;;) { break outer; } let: x();"); expect(ast.body.map((s) => s.type)).toEqual([ "LabeledStatement", "LabeledStatement" ]); const labeled = /** @type {import("estree").LabeledStatement} */ ( ast.body[0] ); expect(labeled.label.name).toBe("outer"); expect(labeled.range).toEqual([0, 32]); expect(() => parse("break outer;")).toThrow(/Unsyntactic break/); }); it("should delegate async, using and await statement heads to acorn", () => { const { ast } = parse( "async function f() { await g(); } async () => 1; using?.x;" ); expect(ast.body.map((s) => s.type)).toEqual([ "FunctionDeclaration", "ExpressionStatement", "ExpressionStatement" ]); const { ast: moduleAst } = parse("await x;", { sourceType: "module" }); expect(moduleAst.body[0].type).toBe("ExpressionStatement"); }); it("should parse if and return through acorn without ranges", () => { const { ast } = parse( "function f() { if (a) return 1; return 2; } if (b) c();", { ranges: false } ); expect(ast.body.map((s) => s.type)).toEqual([ "FunctionDeclaration", "IfStatement" ]); }); it("should delegate rare statement heads to acorn", () => { const { ast } = parse( "do ; while (a); switch (b) { default: } try { throw 1; } catch { } with (c) d(); debugger; ;", { ecmaVersion: 2019 } ); expect(ast.body.map((s) => s.type)).toEqual([ "DoWhileStatement", "SwitchStatement", "TryStatement", "WithStatement", "DebuggerStatement", "EmptyStatement" ]); }); it("should keep the statement fast path off for parser plugins overriding statement parsers", () => { const { WebpackParser } = require("../lib/javascript/syntax"); let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").Node} node started statement node * @returns {import("acorn").Node} if statement */ parseIfStatement(node) { calls++; return super.parseIfStatement(node); } /** * @param {import("acorn").Node} node started statement node * @returns {import("acorn").Node} return statement */ parseReturnStatement(node) { calls++; return super.parseReturnStatement(node); } } const code = "if (a) { b(); } var x = 1; function f() { return x; }"; const ast = Plugin.parse( code, /** @type {import("acorn").Options} */ ( /** @type {unknown} */ ({ ecmaVersion: "latest", sourceType: "script", lazyNodes: true }) ) ); expect(calls).toBe(2); expect(ast.body.map((s) => s.type)).toEqual([ "IfStatement", "VariableDeclaration", "FunctionDeclaration" ]); }); }); describe("conditional, new, array and template atoms", () => { it("should parse them on the single-shape fast paths", () => { const { ast } = parse("a ? b : c; new A; new B(1); [1, , 2];"); expect( ast.body.map( (s) => /** @type {import("estree").ExpressionStatement} */ (s).expression .type ) ).toEqual([ "ConditionalExpression", "NewExpression", "NewExpression", "ArrayExpression" ]); const template = /** @type {import("estree").TemplateLiteral} */ ( /** @type {import("estree").ExpressionStatement} */ ( // eslint-disable-next-line no-template-curly-in-string parse("`a${x}b`;").ast.body[0] ).expression ); expect(template.quasis.map((q) => q.value.raw)).toEqual(["a", "b"]); expect(template.quasis[1].tail).toBe(true); }); it("should keep acorn's new.target and template escape checks", () => { expect(parse("function f(){ return new.target; }").ast).toBeDefined(); expect(() => parse("new.target;")).toThrow( /'new\.target' can only be used in functions/ ); expect(() => parse("`bad \\unicode`;")).toThrow( /Bad escape sequence in untagged template literal/ ); const tagged = /** @type {import("estree").TaggedTemplateExpression} */ ( /** @type {import("estree").ExpressionStatement} */ ( parse("tag`ok \\unicode`;").ast.body[0] ).expression ); expect(tagged.quasi.quasis[0].value.cooked).toBeNull(); }); }); describe("unary and update expressions", () => { it("should parse prefix and postfix operators on the fast path", () => { const { ast } = parse("!x; y++; --z; typeof w; a ** -b;"); expect( ast.body .slice(0, 3) .map( (s) => /** @type {import("estree").ExpressionStatement} */ (s).expression .type ) ).toEqual(["UnaryExpression", "UpdateExpression", "UpdateExpression"]); }); it("should keep acorn's delete and exponentiation checks", () => { expect(() => parse('"use strict"; delete x;')).toThrow( /Deleting local variable in strict mode/ ); expect(() => parse("class C { #p; m(){ delete this.#p; } }")).toThrow( /Private fields can not be deleted/ ); expect(() => parse("-a ** 2;")).toThrow(/Unexpected token/); expect(() => parse("1++;")).toThrow(/Assigning to rvalue/); }); }); describe("assignment expressions", () => { it("should parse destructuring, chained and compound assignments", () => { expect( parse("({a, b = 1} = obj); [x = 2, ...r] = arr;").ast ).toBeDefined(); const { ast } = parse("a = b = c; d **= 2; e ??= f;"); const chained = /** @type {import("estree").AssignmentExpression} */ ( /** @type {import("estree").ExpressionStatement} */ (ast.body[0]) .expression ); expect(chained.right.type).toBe("AssignmentExpression"); expect(parse("function* g(){ const x = yield 1; }").ast).toBeDefined(); }); it("should reject invalid assignment targets", () => { expect(() => parse("1 = 2;")).toThrow(/Assigning to rvalue/); expect(() => parse("({a}) = b;")).toThrow(/Assigning to rvalue/); }); }); describe("binary expressions", () => { it("should keep private-in checks and delegate without ranges", () => { expect( parse("class C { #x; m(o){ return #x in o; } }").ast ).toBeDefined(); // a private name only reaches buildBinary's right side via a chain expect(() => parse("class C { #x; m(o){ return o in #x in o; } }") ).toThrow(/Private identifier can only be left side/); const { ast } = parse("a + b && c;", { ranges: false }); expect( /** @type {import("estree").ExpressionStatement} */ (ast.body[0]) .expression.type ).toBe("LogicalExpression"); }); it("should climb operator precedence like acorn", () => { /** * @param {string} code source * @returns {import("estree").Expression} the sole expression */ const expression = (code) => /** @type {import("estree").ExpressionStatement} */ ( parse(code).ast.body[0] ).expression; const sum = /** @type {import("estree").BinaryExpression} */ ( expression("1 + 2 * 3 - 4;") ); // ((1 + (2 * 3)) - 4): the same-precedence loop keeps left-association expect(sum.operator).toBe("-"); const add = /** @type {import("estree").BinaryExpression} */ (sum.left); expect(add.operator).toBe("+"); expect(add.right.type).toBe("BinaryExpression"); expect(sum.range).toEqual([0, 13]); const coalesce = /** @type {import("estree").LogicalExpression} */ ( expression("a ?? b ?? c;") ); expect(coalesce.operator).toBe("??"); expect(coalesce.left.type).toBe("LogicalExpression"); const exponent = /** @type {import("estree").BinaryExpression} */ ( expression("2 ** 3 ** 4;") ); // `**` is right-associative via the recursive right-side climb expect(exponent.right.type).toBe("BinaryExpression"); }); it("should reject mixing ?? with && and || like acorn", () => { expect(() => parse("a ?? b || c;")).toThrow(/cannot be mixed/); expect(() => parse("a && b ?? c;")).toThrow(/cannot be mixed/); }); it("should not read `in` as an operator in for-init position", () => { const { ast } = parse('for (var x = "a" in b) break;'); expect(ast.body[0].type).toBe("ForInStatement"); expect( /** @type {import("estree").ExpressionStatement} */ ( parse('x = "a" in b;').ast.body[0] ).expression.type ).toBe("AssignmentExpression"); }); }); describe("destructuring-errors record pool", () => { it("should keep raising expression errors from pooled records", () => { expect(() => parse("({x = 1});")).toThrow( /Shorthand property assignments are valid only in destructuring patterns/ ); expect(() => parse("f({x = 1});")).toThrow( /Shorthand property assignments are valid only in destructuring patterns/ ); }); it("should parse nested own-record expressions and async arrows", () => { expect(parse("f(g(h(x = 1)), [y = 2] = z);").ast).toBeDefined(); expect(parse("f(async (a = 1) => a, (b = 2) => b);").ast).toBeDefined(); const { ast } = parse("r = async (q = 1) => q;"); const assignment = /** @type {import("estree").AssignmentExpression} */ ( /** @type {import("estree").ExpressionStatement} */ (ast.body[0]) .expression ); expect(assignment.right.type).toBe("ArrowFunctionExpression"); }); }); describe("expressions, spreads and parameter checks", () => { it("should parse comma sequences and spreads with ranges", () => { const { ast } = parse("a, b, c; f(...args); [...xs];"); const sequence = /** @type {import("estree").SequenceExpression} */ ( /** @type {import("estree").ExpressionStatement} */ (ast.body[0]) .expression ); expect(sequence.type).toBe("SequenceExpression"); expect(sequence.expressions).toHaveLength(3); expect(sequence.range).toEqual([0, 7]); const call = /** @type {import("estree").CallExpression} */ ( /** @type {import("estree").ExpressionStatement} */ (ast.body[1]) .expression ); expect(call.arguments[0].type).toBe("SpreadElement"); expect(call.arguments[0].range).toEqual([11, 18]); // non-lazy mode delegates to acorn expect(parse("f(...args);", { ranges: false }).ast).toBeDefined(); }); it("should report stack overflow on pathological nesting like acorn", () => { expect(() => parse(`${"(".repeat(100000)}x`)).toThrow( /Not enough stack space to parse input/ ); }); it("should check identifier parameter lists like acorn", () => { // sloppy simple functions allow duplicates; arrows and strict don't expect(parse("function f(a, a) { return a; }").ast).toBeDefined(); expect(() => parse("(a, a) => a;")).toThrow(/Argument name clash/); expect(() => parse("'use strict'; function f(a, a) {}")).toThrow( /Argument name clash/ ); expect(() => parse("'use strict'; function f(eval) {}")).toThrow( /Binding eval in strict mode/ ); }); it("should delegate pattern parameters to acorn's full walk", () => { expect( parse("function f({ a }, [b], c = 1, ...rest) { return a + b + c; }") .ast ).toBeDefined(); expect(() => parse("(a, { a }) => a;")).toThrow(/Argument name clash/); }); }); describe("subscript parsing", () => { it("should parse optional chains, private members and tagged templates", () => { expect( // eslint-disable-next-line no-template-curly-in-string parse("a?.b; a?.[b]; a?.(); x = f(a, b,); tag`x${1}`;").ast ).toBeDefined(); expect( parse("class P { #x = 1; m() { return this.#x; } }").ast ).toBeDefined(); }); it("should reject optional chaining in new callees and template tags", () => { expect(() => parse("new a?.b();")).toThrow( /Optional chaining cannot appear in the callee/ ); expect(() => parse("a?.b`t`;")).toThrow( /Optional chaining cannot appear in the tag/ ); }); it("should parse async arrows through the call subscript path", () => { expect( parse("const f = async (a, b = 1) => a + b; async(1, 2);").ast ).toBeDefined(); expect(() => parse("async (await) => 1;")).toThrow( /Cannot use 'await' as identifier inside an async function/ ); }); it("should delegate to acorn without ranges and pre-2020 ecmaVersions", () => { expect(parse("a.b(c)[d];", { ranges: false }).ast).toBeDefined(); const { ast } = parse("a.b(c);", { ecmaVersion: 10 }); const call = /** @type {import("estree").CallExpression} */ ( /** @type {import("estree").ExpressionStatement} */ (ast.body[0]) .expression ); // acorn only adds `optional` from ecmaVersion 11 on expect("optional" in call.callee).toBe(false); }); }); describe("punctuator fast path", () => { it("should tokenize the single-char punctuators into the right AST", () => { const program = parse("f(a, [b], { c: 1 });").ast; const call = /** @type {import("estree").CallExpression} */ ( /** @type {import("estree").ExpressionStatement} */ (program.body[0]) .expression ); expect(call.type).toBe("CallExpression"); expect(call.arguments).toHaveLength(3); expect(call.arguments[1].type).toBe("ArrayExpression"); expect(call.arguments[2].type).toBe("ObjectExpression"); }); it("should keep ranges aligned across punctuator-dense code", () => { const { ast } = parse("[({})];"); const { range } = /** @type {{ range: [number, number] }} */ ( /** @type {unknown} */ (ast.body[0]) ); expect(range).toEqual([0, 7]); }); }); describe("owned operator scanner", () => { /** * @param {string} code source with one expression statement * @returns {string} the expression's operator */ const op = (code) => /** @type {{ expression: { operator: string } }} */ ( /** @type {unknown} */ (parse(code).ast.body[0]) ).expression.operator; it("should scan every multi-char operator to its exact string", () => { expect(op("a /= b;")).toBe("/="); expect(op("a %= b;")).toBe("%="); expect(op("a % b;")).toBe("%"); expect(op("a **= b;")).toBe("**="); expect(op("a ** b;")).toBe("**"); expect(op("a *= b;")).toBe("*="); expect(op("a ||= b;")).toBe("||="); expect(op("a || b;")).toBe("||"); expect(op("a |= b;")).toBe("|="); expect(op("a | b;")).toBe("|"); expect(op("a &&= b;")).toBe("&&="); expect(op("a && b;")).toBe("&&"); expect(op("a &= b;")).toBe("&="); expect(op("a & b;")).toBe("&"); expect(op("a ^= b;")).toBe("^="); expect(op("a ^ b;")).toBe("^"); expect(op("++a;")).toBe("++"); expect(op("a += b;")).toBe("+="); expect(op("a + b;")).toBe("+"); expect(op("--a;")).toBe("--"); expect(op("a -= b;")).toBe("-="); expect(op("a - b;")).toBe("-"); expect(op("a <<= b;")).toBe("<<="); expect(op("a << b;")).toBe("<<"); expect(op("a <= b;")).toBe("<="); expect(op("a < b;")).toBe("<"); expect(op("a >>>= b;")).toBe(">>>="); expect(op("a >>> b;")).toBe(">>>"); expect(op("a >>= b;")).toBe(">>="); expect(op("a >> b;")).toBe(">>"); expect(op("a >= b;")).toBe(">="); expect(op("a > b;")).toBe(">"); expect(op("a === b;")).toBe("==="); expect(op("a == b;")).toBe("=="); expect(op("a !== b;")).toBe("!=="); expect(op("a != b;")).toBe("!="); expect(op("!a;")).toBe("!"); expect(op("~a;")).toBe("~"); expect(op("a ??= b;")).toBe("??="); expect(op("a ?? b;")).toBe("??"); }); it("should scan ?., conditional ? and ?. before a digit", () => { const chain = /** @type {{ expression: import("estree").ChainExpression }} */ ( /** @type {unknown} */ (parse("a?.b;").ast.body[0]) ).expression; expect(chain.type).toBe("ChainExpression"); // `?.` directly before a digit is a conditional, not optional chaining const conditional = /** @type {{ expression: import("estree").Expression }} */ ( /** @type {unknown} */ (parse("a ?.5:b;").ast.body[0]) ).expression; expect(conditional.type).toBe("ConditionalExpression"); expect( /** @type {{ expression: import("estree").Expression }} */ ( /** @type {unknown} */ (parse("a ? b : c;").ast.body[0]) ).expression.type ).toBe("ConditionalExpression"); }); it("should dispatch radix numbers, strings, templates and privates", () => { const values = parse("const a = [0xff, 0o17, 0b11, 0, \"d\", 's'];"); const array = /** @type {{ init: import("estree").ArrayExpression }} */ ( /** @type {{ declarations: unknown[] }} */ ( /** @type {unknown} */ (values.ast.body[0]) ).declarations[0] ).init; expect( array.elements.map( (e) => /** @type {import("estree").Literal} */ (e).value ) ).toEqual([255, 15, 3, 0, "d", "s"]); expect(parse("`t`;").ast).toBeDefined(); expect( parse("class A { #p; m() { return this.#p; } }").ast ).toBeDefined(); expect(() => parse("a = §;")).toThrow(/Unexpected character/); }); it("should read a lone dot after a dot as a plain dot token", () => { // `..` misses the ellipsis check and lands on the plain-dot tail expect(() => parse("a..b;")).toThrow(/Unexpected token/); }); it("should keep single-char tails for direct getTokenFromCode calls", () => { // unreachable from the dispatch fast path (nextToken finishes plain // `=`/`.` itself) but part of the getTokenFromCode contract const { tokTypes } = require("acorn"); const { WebpackParser } = require("../lib/javascript/syntax"); const source = "= ."; const parser = new WebpackParser( /** @type {EXPECTED_ANY} */ ({ ecmaVersion: "latest", lazyNodes: true }), source ); /** @type {EXPECTED_ANY} */ (parser).getTokenFromCode(61); expect(/** @type {EXPECTED_ANY} */ (parser).type).toBe(tokTypes.eq); expect(/** @type {EXPECTED_ANY} */ (parser).value).toBe("="); /** @type {EXPECTED_ANY} */ (parser).pos = 2; /** @type {EXPECTED_ANY} */ (parser).getTokenFromCode(46); expect(/** @type {EXPECTED_ANY} */ (parser).type).toBe(tokTypes.dot); }); it("should read the inlined HTML comment forms", () => { // `<!--` opens a line comment in script mode only const script = parse("x <!--y\nz;"); expect(script.comments.map((c) => c.value)).toEqual(["y"]); const module_ = parse("x <!--y;", { sourceType: "module" }); expect(module_.comments).toHaveLength(0); // `-->` after a line break is a comment; `a-->b` stays `(a--) > b` const close = parse("a\n--> rest\nb;"); expect(close.comments.map((c) => c.value)).toEqual([" rest"]); expect(op("a-->b;")).toBe(">"); }); }); describe("newline-before-token tracking", () => { it("should apply ASI from the flag without a gap scan", () => { expect(parse("a\nb;").ast.body).toHaveLength(2); expect(() => parse("a b;")).toThrow(/Unexpected token/); // postfix `++` on the next line binds to the next statement expect(parse("a\n++b;").ast.body).toHaveLength(2); }); it("should fall back to the gap scan around comments", () => { // line terminator hidden inside a block comment still triggers ASI expect(parse("a /* \n */ b;").ast.body).toHaveLength(2); expect(() => parse("a /* x */ b;")).toThrow(/Unexpected token/); // unicode whitespace defers to acorn's skipSpace, then the scan expect(parse("a \u2028 b;").ast.body).toHaveLength(2); expect(() => parse("a \u00A0 b;")).toThrow(/Unexpected token/); }); it("should keep ASI working inside template expression gaps", () => { // tokens after a template chunk come from acorn's tokenizer (flag // unknown), so the scan fallback decides // eslint-disable-next-line no-template-curly-in-string expect(parse("f`${a\n}`;").ast.body).toHaveLength(1); }); it("should keep a seen line terminator across unicode whitespace", () => { // LF before a NBSP: the flag stays "yes" through acorn's skipSpace expect(parse("a \n b;").ast.body).toHaveLength(2); // unicode whitespace running to eof expect(parse("a; ").ast.body).toHaveLength(1); }); }); describe("keyword and reserved-word gates", () => { /** * @param {string} code source with one expression statement * @returns {string} the expression's operator */ const op = (code) => /** @type {{ expression: { operator: string } }} */ ( /** @type {unknown} */ (parse(code).ast.body[0]) ).expression.operator; it("should classify keywords and near-keywords correctly", () => { expect(op("a instanceof b;")).toBe("instanceof"); expect(op("'x' in a;")).toBe("in"); // same length/lowercase shape as keywords, but not keywords expect(parse("const instanceofX = 1; let doX = 2;").ast).toBeDefined(); // capitalized, single-char, and >10-char words skip the keyword probe expect( parse("Function; x; internationalization; whileTrue;").ast.body ).toHaveLength(4); }); it("should still reject reserved words through the gate", () => { expect(() => parse("var enum = 1;")).toThrow( /The keyword 'enum' is reserved/ ); expect(() => parse('"use strict"; var implements = 1;')).toThrow( /The keyword 'implements' is reserved/ ); expect(parse("var synchronized = 1;").ast).toBeDefined(); expect(() => parse("var if = 1;")).toThrow(/Unexpected keyword 'if'/); }); }); describe("object literals and patterns (single-shape nodes)", () => { it("should parse every property kind onto the fixed Property shape", () => { const { ast } = parse( "({ m() {}, get g() { return 1; }, set g(v) {}, async af() {}, " + '*gen() {}, [k]: 1, "s": 2, 3: 3, sh, ...sp });' ); const properties = /** @type {{ expression: import("estree").ObjectExpression }} */ ( /** @type {unknown} */ (ast.body[0]) ).expression.properties; expect( properties.map((p) => p.type === "Property" ? [p.kind, p.method, p.shorthand, p.computed] : p.type ) ).toEqual([ ["init", true, false, false], ["get", false, false, false], ["set", false, false, false], ["init", true, false, false], ["init", true, false, false], ["init", false, false, true], ["init", false, false, false], ["init", false, false, false], ["init", false, true, false], "SpreadElement" ]); }); it("should parse object patterns with defaults and rest", () => { const { ast } = parse("const { a, b = 1, [c]: d, ...rest } = o;"); const pattern = /** @type {{ id: import("estree").ObjectPattern }} */ ( /** @type {{ declarations: unknown[] }} */ ( /** @type {unknown} */ (ast.body[0]) ).declarations[0] ).id; expect(pattern.type).toBe("ObjectPattern"); expect(pattern.properties.map((p) => p.type)).toEqual([ "Property", "Property", "Property", "RestElement" ]); // assignment-destructuring converts the same nodes via toAssignable expect(parse("({ a, b = 1, ...r } = o);").ast).toBeDefined(); }); it("should parse trailing commas and record-less objects", () => { expect(parse("({ a: 1, });").ast).toBeDefined(); // no destructuring-errors record flows in through `typeof` expect(parse("typeof { a: 1 };").ast).toBeDefined(); }); it("should keep acorn's property error checks", () => { expect(() => parse("const { ...rest, a } = o;")).toThrow( /Comma is not permitted after the rest element/ ); expect(() => parse("({ ...a, b } = c);")).toThrow( /Comma is not permitted after the rest element/ ); expect(() => parse('x = { __proto__: 1, "__proto__": 2 };')).toThrow( /Redefinition of __proto__ property/ ); expect(() => parse("x = { a = 1 };")).toThrow( /Shorthand property assignments are valid only in destructuring/ ); expect(() => parse("x = { get g(a, b) {} };")).toThrow(/getter/); }); it("should delegate object parsing without ranges", () => { const { ast } = parse("({ a: 1, ...s, m() {} });", { ranges: false }); const properties = /** @type {{ expression: import("estree").ObjectExpression }} */ ( /** @type {unknown} */ (ast.body[0]) ).expression.properties; expect(properties.map((p) => p.type)).toEqual([ "Property", "SpreadElement", "Property" ]); }); }); describe("tokenizer cold paths (ported from acorn)", () => { /** * @param {string} code source * @param {object=} options extra parse options * @returns {import("estree").Literal} the sole declarator's literal init */ const literal = (code, options) => { const declaration = /** @type {import("estree").VariableDeclaration} */ (parse(code, options).ast.body[0]); return /** @type {import("estree").Literal} */ ( declaration.declarations[0].init ); }; it("should cook hex, unicode and octal string escapes", () => { expect(literal('var x = "\\x41\\u0042\\u{1F600}"').value).toBe( "AB\u{1F600}" ); // a hex escape routes the whole string cold, so every following escape // is cooked there too, including the CRLF line continuation expect(literal('var x = "\\x41\\r\\b\\f\\v\\n\\t"').value).toBe( "A\r\b\f\u000B\n\t" ); expect(literal('var x = "\\x41\\\r\nz"').value).toBe("Az"); // an unescaped LS/PS stays valid (ES2019+) even on the cold path const ls = String.fromCharCode(0x2028); expect(literal(`var x = "\\x41${ls}b"`).value).toBe(`A${ls}b`); // an octal escape > 255 drops its last digit, which stays literal expect(literal('var x = "\\400"').value).toBe(" 0"); expect(literal('var x = "\\101\\0"').value).toBe("A\u0000"); }); it("should report bad and out-of-range escapes like acorn", () => { expect(() => parse('var x = "\\xZZ"')).toThrow( /Bad character escape sequence/ ); expect(() => parse('var x = "\\u{110000}"')).toThrow( /Code point out of bounds/ ); expect(() => parse('"use strict"; var x = "\\101"')).toThrow( /Octal literal in strict mode/ ); expect(() => parse('"use strict"; var x = "\\8"')).toThrow( /Invalid escape sequence/ ); // a bare line terminator after a cold escape is still unterminated expect(() => parse('var x = "\\x41\nb"')).toThrow( /Unterminated string constant/ ); }); it("should cook escapes and yield null for invalid template escapes", () => { /** * @param {string} code source with a template literal * @returns {string | null | undefined} the first quasi's cooked value */ const cooked = (code) => { const statement = /** @type {import("estree").ExpressionStatement} */ (parse(code).ast.body[0]); return /** @type {import("estree").TemplateLiteral} */ ( statement.expression ).quasis[0].value.cooked; }; expect(cooked("`a\\x41\\u{42}b`;")).toBe("aABb"); // a tagged template keeps an invalid escape with a null cooked value const tagged = /** @type {import("estree").TaggedTemplateExpression} */ ( /** @type {import("estree").ExpressionStatement} */ ( parse("tag`bad \\8`;").ast.body[0] ).expression ); expect(tagged.quasi.quasis[0].value.cooked).toBeNull(); // an untagged invalid template escape is rejected by the parser expect(() => parse("`bad \\8`;")).toThrow( /Bad escape sequence in untagged template literal/ ); // a cold template chunk (escape seen) that never closes is unterminated expect(() => parse("`a\\t")).toThrow(/Unterminated template/); }); it("should read radix integers and bigints", () => { expect(literal("var x = 0xFF").value).toBe(255); expect(literal("var x = 0o17").value).toBe(15); expect(literal("var x = 0b1010").value).toBe(10); expect(literal("var x = 0x1Fn").value).toBe(BigInt(31)); expect(typeof literal("var x = 0b101n").value).toBe("bigint"); }); it("should report radix, separator and octal number errors like acorn", () => { expect(() => parse("var x = 0x")).toThrow(/Expected number in radix 16/); expect(() => parse("var x = 0x1g")).toThrow( /Identifier directly after number/ ); expect(() => parse("var x = 0x_1")).toThrow( /Numeric separator is not allowed at the first of digits/ ); expect(() => parse("var x = 1_")).toThrow( /Numeric separator is not allowed at the last of digits/ ); expect(() => parse("var x = 1__2")).toThrow( /Numeric separator must be exactly one underscore/ ); expect(() => parse("var x = 0_1")).toThrow( /Numeric separator is not allowed in legacy octal/ ); expect(() => parse('"use strict"; var x = 0777')).toThrow( /Invalid number/ ); expect(() => parse("var x = 123nx")).toThrow( /Identifier directly after number/ ); }); it("should read legacy octal, exponent and float forms", () => { expect(literal("var x = 0777").value).toBe(511); // an 8 or 9 digit makes a leading-zero literal decimal, not octal expect(literal("var x = 0778").value).toBe(778); expect(literal("var x = 1e3").value).toBe(1000); expect(literal("var x = 1E-3").value).toBe(0.001); expect(literal("var x = 1.5e+2").value).toBe(150); expect(() => parse("var x = 1e")).toThrow(/Invalid number/); }); it("should read escaped and astral identifiers", () => { /** * @param {string} code source * @returns {string} the sole declarator's binding name */ const idName = (code) => { const declaration = /** @type {import("estree").VariableDeclaration} */ (parse(code).ast.body[0]); return /** @type {import("estree").Identifier} */ ( declaration.declarations[0].id ).name; }; expect(idName("var \\u0041 = 1")).toBe("A"); expect(idName("var \\u{42} = 1")).toBe("B"); // a raw astral identifier advances two code units per character const astral = String.fromCodePoint(0x1d400); expect(idName(`var ${astral} = 1`)).toBe(astral); }); it("should report identifier escape errors like acorn", () => { expect(() => parse("var \\q = 1")).toThrow( /Expecting Unicode escape sequence/ ); expect(() => parse("var \\u0020 = 1")).toThrow(/Invalid Unicode escape/); }); it("should read private identifiers and reject stray characters", () => { expect( parse("class C { #x = 1; m() { return this.#x; } }").ast ).toBeDefined(); expect(() => parse("var x = #;")).toThrow(/Unexpected character/); expect(() => parse("var x = §;")).toThrow(/Unexpected character/); }); it("should skip unicode whitespace and comments before a token", () => { // NBSP, ideographic and em spaces and LS around real tokens are consumed expect(literal(`var x =${String.fromCharCode(0xa0)}1`).value).toBe(1); expect(literal(`var x =${String.fromCharCode(0x3000)}1`).value).toBe(1); expect(literal(`var x =${String.fromCharCode(0x2028)}1`).value).toBe(1); expect( literal(`var x =${String.fromCharCode(0x2003)}/* c */1`).value ).toBe(1); // a CRLF following unicode whitespace is consumed as one line break expect(literal(`var x =${String.fromCharCode(0xa0)}\r\n1`).value).toBe(1); // unicode whitespace before a `/` that is division, not a comment const em = String.fromCharCode(0x2003); const statement = /** @type {import("estree").ExpressionStatement} */ (parse(`x${em}/ y;`).ast.body[0]); expect(statement.expression.type).toBe("BinaryExpression"); }); }); describe("auto source type module->script fallback", () => { const { WebpackParser } = require("../lib/javascript/syntax"); /** * @param {string} code source * @param {object=} options extra parse options * @returns {{ ast: import("../lib/javascript/JavascriptParser").ParseResult["ast"], parses: number }} result and acorn parse count */ const parseAuto = (code, options) => { const proto = /** @type {{ parse: () => unknown }} */ (/** @type {unknown} */ (WebpackParser.prototype)); const spy = jest.spyOn(proto, "parse"); try { const { ast } = parse(code, { sourceType: "auto", ...options }); return { ast, parses: spy.mock.calls.length }; } finally { spy.mockRestore(); } }; it("should downgrade a top-level return in a single parse (fast path)", () => { const { ast, parses } = parseAuto( 'if (typeof window === "undefined") { return; }\nmodule.exports = 1;' ); expect(parses).toBe(1); expect(ast.sourceType).toBe("script"); expect(ast.body[0].type).toBe("IfStatement"); }); it("should downgrade a bare top-level return without ranges", () => { const { ast, parses } = parseAuto("return 1;", { ranges: false }); expect(parses).toBe(1); expect(ast.body[0].type).toBe("ReturnStatement"); }); it("should produce the same AST as an explicit script parse", () => { const code = "var x = 1;\nif (x) return;\nmodule.exports = x;"; const strip = (/** @type {unknown} */ node) => JSON.stringify(node, (k, v) => k === "loc" || k === "start" || k === "end" || k === "range" ? undefined : v ); expect(strip(parseAuto(code).ast)).toBe( strip(parse(code, { sourceType: "script" }).ast) ); }); it("should single-parse a top-level return past an async function's await", () => { // the await is function-scoped, not module syntax, so the downgrade still applies const { parses, ast } = parseAuto( "async function g() { await x; }\nif (!y) return;" ); expect(parses).toBe(1); expect(ast.sourceType).toBe("script"); }); it("should keep esm a single module parse", () => { const { ast, parses } = parseAuto("export const x = 1;"); expect(parses).toBe(1); expect(ast.sourceType).toBe("module"); expect(ast.body[0].type).toBe("ExportNamedDeclaration"); }); it.each([ ['import x from "./x";\nreturn;', "import"], ["export const a = 1;\nreturn;", "export"], ["await Promise.resolve();\nreturn;", "top-level await"], ["import.meta.url;\nreturn;", "import.meta"] ])( "should not downgrade when %s precedes the return (module syntax seen)", (code) => { const proto = /** @type {{ parse: () => unknown }} */ (/** @type {unknown} */ (WebpackParser.prototype)); const spy = jest.spyOn(proto, "parse"); expect(() => parse(code, { sourceType: "auto" })).toThrow( /'return' outside of function/ ); const parses = spy.mock.calls.length; spy.mockRestore(); // module syntax before the return blocks the in-place downgrade, so // the outer double-parse still runs and re-throws the module error expect(parses).toBe(2); } ); it("should not downgrade a return inside a class static block", () => { expect(() => parse("class C { static { return; } }", { sourceType: "auto" }) ).toThrow(/'return' outside of function/); }); it("should still fall back to script for strict-only syntax", () => { // `with` is not handled by the in-place switch; the outer retry covers it const { ast, parses } = parseAuto( "with (obj) { x = 1; }\nmodule.exports = x;" ); expect(parses).toBe(2); expect(ast.body[0].type).toBe("WithStatement"); }); }); describe("import phases (owned defer/source parsing)", () => { /** * @param {string} code source * @param {object=} options extra parse options * @returns {import("../lib/javascript/JavascriptParser").ParseResult["ast"]["body"][0]} first statement */ const first = (code, options) => parse(code, { sourceType: "module", importPhases: true, ...options }).ast .body[0]; it("should tag `import defer` and `import source` declarations with a phase", () => { expect(first('import defer * as ns from "x";')).toMatchObject({ type: "ImportDeclaration", phase: "defer" }); expect(first('import source src from "x";')).toMatchObject({ type: "ImportDeclaration", phase: "source" }); }); it("should treat defer/source as the default name when followed by `from` or a comma", () => { const deferName = /** @type {import("estree").ImportDeclaration & { phase?: string }} */ (first('import defer from "x";')); expect(deferName.phase).toBeUndefined(); expect(deferName.specifiers[0].type).toBe("ImportDefaultSpecifier"); const withNamed = /** @type {import("estree").ImportDeclaration} */ (first('import source, { a } from "x";')); expect(withNamed.specifiers.map((s) => s.type)).toEqual([ "ImportDefaultSpecifier", "ImportSpecifier" ]); }); it("should reject phase modifiers on the wrong specifier shape", () => { expect(() => first('import defer notNamespace from "x";')).toThrow( /'import defer' can only be used with namespace imports/ ); expect(() => first('import source * as ns from "x";')).toThrow( /'import source' can only be used with direct identifier specifier imports/ ); // a defer/source default name may only be followed by `* as`/`{…}` expect(() => first('import defer, x from "x";')).toThrow( /Unexpected token/ ); // a source phase rejects named specifiers after the default import expect(() => first('import source a, { b } from "x";')).toThrow( /'import source' can only be used with direct identifier specifier imports/ ); }); it("should parse `import.defer()` / `import.source()` dynamic imports with a phase", () => { const declarationFor = (/** @type {string} */ code) => /** @type {import("estree").ImportExpression & { phase?: string }} */ ( /** @type {import("estree").VariableDeclaration} */ (first(code)) .declarations[0].init ); expect(declarationFor('const y = import.defer("x");')).toMatchObject({ type: "ImportExpression", phase: "defer" }); expect(declarationFor('const y = import.source("x");')).toMatchObject({ type: "ImportExpression", phase: "source" }); }); it("should reject a phase meta-property that is not a dynamic import", () => { expect(() => first("const y = import.defer;")).toThrow( /'import\.defer' can only be used in a dynamic import/ ); expect(() => first('const y = new import.defer("x");')).toThrow( /import call cannot be the target of `new`/ ); }); it("should keep acorn's import.meta checks under owned parsing", () => { expect(first("const y = import.meta;")).toMatchObject({ type: "VariableDeclaration" }); expect(() => first("const y = import.foo;")).toThrow( /only valid meta property for import is 'import\.meta'/ ); expect(() => first("const y = import.m\\u0065ta;")).toThrow( /'import\.meta' must not contain escaped characters/ ); expect(() => first("const y = import.meta;", { sourceType: "script" }) ).toThrow(/Cannot use 'import\.meta' outside a module/); }); it("should delegate import.meta and specifiers to acorn when phases are off", () => { expect( parse("const y = import.meta;", { sourceType: "module", importPhases: false }).ast.body[0].type ).toBe("VariableDeclaration"); expect( parse('import a from "x";', { sourceType: "module", importPhases: false }).ast.body[0].type ).toBe("ImportDeclaration"); }); }); describe("import attributes (owned with/assert clause)", () => { /** * @param {string} code source * @returns {import("estree").ImportDeclaration} the import declaration */ const importDeclaration = (code) => /** @type {import("estree").ImportDeclaration} */ (parse(code, { sourceType: "module" }).ast.body[0]); it("should parse `with` and legacy `assert` attribute clauses", () => { expect( importDeclaration('import x from "x" with { type: "json" };').type ).toBe("ImportDeclaration"); expect( importDeclaration('import x from "x" assert { type: "json" };').type ).toBe("ImportDeclaration"); expect(importDeclaration('import x from "x" with {};').type).toBe( "ImportDeclaration" ); expect( importDeclaration('import x from "x" with { type: "json", };').type ).toBe("ImportDeclaration"); }); it("should reject a duplicate attribute key", () => { expect(() => parse('import x from "x" with { type: "json", type: "js" };', { sourceType: "module" }) ).toThrow(/Duplicate attribute key 'type'/); }); }); }); describe("WebpackParser acorn-override fast-path gates", () => { const { WebpackParser } = require("../lib/javascript/syntax"); /** @type {import("acorn").Options} */ const lazyOptions = /** @type {import("acorn").Options} */ ( /** @type {unknown} */ ({ ecmaVersion: "latest", sourceType: "script", lazyNodes: true }) ); it("keeps the function fast path off for plugins overriding its inlined methods", () => { let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").Node} node function node * @returns {void} */ initFunction(node) { calls++; // @ts-expect-error acorn internal return super.initFunction(node); } /** * @param {import("acorn").Node[]} params parameter nodes * @returns {boolean} whether all params are plain identifiers */ isSimpleParamList(params) { calls++; // @ts-expect-error acorn internal return super.isSimpleParamList(params); } } const code = "function f(a) { return a; } const g = (x) => x + 1;"; const ast = Plugin.parse(code, lazyOptions); expect(calls).toBeGreaterThan(0); expect(ast.body.map((s) => s.type)).toEqual([ "FunctionDeclaration", "VariableDeclaration" ]); expect(JSON.stringify(ast)).toBe( JSON.stringify(WebpackParser.parse(code, lazyOptions)) ); }); it("keeps dynamically dispatched overrides working on the function fast path", () => { let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").Node} node function node * @param {boolean} allowDuplicates whether duplicate params are allowed * @returns {void} */ checkParams(node, allowDuplicates) { calls++; // @ts-expect-error acorn internal return super.checkParams(node, allowDuplicates); } } Plugin.parse("function f(a, b) { return a + b; }", lazyOptions); expect(calls).toBeGreaterThan(0); }); it("keeps the statement-head fast path off for plugins overriding those parsers", () => { let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").Node} node started statement node * @returns {import("acorn").Node} for statement */ parseForStatement(node) { calls++; // @ts-expect-error acorn internal return super.parseForStatement(node); } } const code = "for (let i = 0; i < 2; i++) f(i); while (a) b();"; const ast = Plugin.parse(code, lazyOptions); expect(calls).toBe(1); expect(ast.body.map((s) => s.type)).toEqual([ "ForStatement", "WhileStatement" ]); expect(JSON.stringify(ast)).toBe( JSON.stringify(WebpackParser.parse(code, lazyOptions)) ); }); it("parses the statement-head edge arcs identically to acorn", () => { // multiple-default switch is a SyntaxError expect(() => WebpackParser.parse( "switch (x) { default: a(); default: b(); }", lazyOptions ) ).toThrow(/Multiple default clauses/); // param-less catch (ES2019) parses const catchAst = WebpackParser.parse( "try { f() } catch { g() }", lazyOptions ); expect( /** @type {EXPECTED_ANY} */ (catchAst.body[0]).handler.param ).toBeNull(); // for-await outside async context is rejected expect(() => WebpackParser.parse("for await (const x of y) f(x);", lazyOptions) ).toThrow(/Unexpected token/); // labeled loops resolve break/continue through the shared label records const labeled = WebpackParser.parse( "outer: for (;;) { for (;;) { continue outer; } break outer; }", lazyOptions ); expect(labeled.body[0].type).toBe("LabeledStatement"); expect(() => WebpackParser.parse("break missing;", lazyOptions)).toThrow( /Unsyntactic break/ ); }); it("serves the strict-bind probe from the Set stand-in and its .test fallback", () => { const strictOptions = /** @type {import("acorn").Options} */ ( /** @type {unknown} */ ({ ecmaVersion: "latest", sourceType: "module", lazyNodes: true }) ); expect(() => WebpackParser.parse("eval = 1;", strictOptions)).toThrow( /Assigning to eval in strict mode/ ); // a runtime-replaced reservedWordsStrictBind takes the .test() fallback class Plugin extends WebpackParser { /** * @param {import("acorn").Options} options parser options * @param {string} input source code * @param {number=} startPos start position */ constructor(options, input, startPos) { super(options, input, startPos); /** @type {{ reservedWordsStrictBind: { test: (name: string) => boolean } }} */ ( /** @type {unknown} */ (this) ).reservedWordsStrictBind = { test: (/** @type {string} */ name) => name === "customBad" }; } } expect(() => Plugin.parse('"use strict"; customBad = 1;', lazyOptions) ).toThrow(/Assigning to customBad in strict mode/); expect(() => Plugin.parse('"use strict"; eval = 1;', lazyOptions) ).not.toThrow(); }); it("keeps list-producer overrides working with the scratch pool", () => { let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").Node} item paren item * @returns {import("acorn").Node} paren item */ parseParenItem(item) { calls++; return item; } } const code = "const s = (a, b); f(1, [2, 3]);"; const ast = Plugin.parse(code, lazyOptions); expect(calls).toBeGreaterThan(0); expect(JSON.stringify(ast)).toBe( JSON.stringify(WebpackParser.parse(code, lazyOptions)) ); }); it("keeps the token fast path off for plugins overriding tokenizer internals", () => { let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").TokenType} prevType previous token type * @returns {void} */ updateContext(prevType) { calls++; // @ts-expect-error acorn internal return super.updateContext(prevType); } } // non-lazy: the extended fast token loop must gate off for the override const tokens = [ ...Plugin.tokenizer("a + { b: 1 };", { ecmaVersion: "latest", sourceType: "script" }) ]; expect(calls).toBeGreaterThan(0); expect(tokens.length).toBeGreaterThan(5); }); it("keeps the delegated tokenizer cold paths reachable with the fast loop off", () => { class Plugin extends WebpackParser { /** * @param {import("acorn").TokenType} prevType previous token type * @returns {void} */ updateContext(prevType) { // @ts-expect-error acorn internal return super.updateContext(prevType); } } // with the fast loop off acorn's own readToken_* family runs, so the // multi-char operators land in the owned finishOp and the comments in // the owned skipSpace's cold delegate const code = "a === b && (c >>>= d) ** 2; /* c */ x ??= y; // trailing\n z >>> 1;"; const values = [ ...Plugin.tokenizer(code, { ecmaVersion: "latest", sourceType: "script" }) ].map((token) => /** @type {EXPECTED_ANY} */ (token).value); expect(values).toContain("==="); expect(values).toContain(">>>="); expect(values).toContain("??="); expect(values).toContain(">>>"); expect(values).toContain("**"); }); it("runs the inlined token-context hooks when finishToken is overridden", () => { let calls = 0; class Plugin extends WebpackParser { /** * @param {import("acorn").TokenType} type token type * @param {string=} value token value * @returns {void} */ finishToken(type, value) { calls++; return super.finishToken(type, value); } } // the override only disables the inline token finisher, so every token // now takes the owned finishToken and its inlined updateContext arcs const code = "if (a) (b); while (c) { d } (function () {})();"; const ast = Plugin.parse(code, lazyOptions); expect(calls).toBeGreaterThan(10); expect(JSON.stringify(ast)).toBe( JSON.stringify(WebpackParser.parse(code, lazyOptions)) ); }); it("reads private names through readWord1's cache and cold reader", () => { // private names are the one word production that still goes through // acorn's readWord1 (the owned readWord serves every other identifier) const ast = WebpackParser.parse( "class A { #ahri = 1; get v() { return this.#ahri; } #\\u0062 = 2; #café = 3; }", lazyOptions ); expect(ast.body[0].type).toBe("ClassDeclaration"); expect(() => WebpackParser.parse("class A { #a = this.#missing; }", lazyOptions) ).toThrow( /Private field '#missing' must be declared in an enclosing class/ ); }); });