diff --git a/src/lexer.ts b/src/lexer.ts index 462badb..f7100d1 100644 --- a/src/lexer.ts +++ b/src/lexer.ts @@ -1000,12 +1000,15 @@ export class Lexer { if (ctx === LexContext.CommandStart) { if (!hasExpansions && !quoted) { const fc = text.charCodeAt(0); - if ( - ((fc >= CH_a && fc <= CH_z && text.length <= 8) || fc === CH_BANG || fc === CH_LBRACE || fc === CH_RBRACE) && - text in RESERVED_WORDS - ) { - setToken(out, RESERVED_WORDS[text], text, tokenStart, wordEnd); - return; + if ((fc >= CH_a && fc <= CH_z && text.length <= 8) || fc === CH_BANG || fc === CH_LBRACE || fc === CH_RBRACE) { + // Single lookup, then a typeof check — `text in RESERVED_WORDS` would also match + // inherited Object.prototype members, making `toString` and `valueOf` parse as + // keywords. Own reserved words are always numeric Token values. + const reserved = RESERVED_WORDS[text]; + if (typeof reserved === "number") { + setToken(out, reserved, text, tokenStart, wordEnd); + return; + } } if (fc === CH_LBRACKET && text === "[[") { setToken(out, Token.DblLBracket, text, tokenStart, wordEnd); diff --git a/test/parser.test.ts b/test/parser.test.ts index 36a9b1f..733be2f 100644 --- a/test/parser.test.ts +++ b/test/parser.test.ts @@ -51,6 +51,23 @@ test("set and trap commands", () => { assert.equal(ast.commands.length, 4); }); +// Command names that collide with Object.prototype members must not be mistaken +// for reserved words by the keyword lookup. +test("Object.prototype member names are ordinary command names", () => { + for (const name of ["toString", "valueOf", "constructor", "hasOwnProperty", "__proto__", "isPrototypeOf"]) { + const ast = parse(`${name} arg`); + assert.equal(ast.commands.length, 1, `no command for: ${name}`); + assert.equal(getCmd(ast).name?.text, name); + assert.deepEqual(args(getCmd(ast)), ["arg"]); + } +}); + +test("Object.prototype member name does not truncate the rest of the script", () => { + const ast = parse("echo hi; toString foo; echo bye"); + assert.equal(ast.commands.length, 3); + assert.equal(getCmd(ast, 2).name?.text, "echo"); +}); + // ── Real-world patterns ───────────────────────────────────────────── test("real-world scripts parse without errors", () => {