From 8e3d8eda0635efaeab574ae5188e7de01a2c45e7 Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Sun, 2 Aug 2026 13:06:31 +0100 Subject: [PATCH] Fix TypeError on unclosed `[`, `(` and trailing `|` Selectors that ran out of tokens before a bracket closed threw `TypeError: Cannot read properties of undefined` instead of the parser's own `Expected a closing ...` error. The error path already existed in each case; it just could not be reached, because building the message dereferenced the token that was missing. parser().astSync('a]') // Expected an opening square bracket. (ok) parser().astSync('a[href') // TypeError: ...reading '0' Closing delimiters with no opener were already handled properly, so this brings the two directions into line. - attribute(): the while loop exits on either a closing bracket or end of input, and the check after it assumed the former. Errors now point at the opening bracket. - namespace(): a trailing `|` with nothing after it now reaches the existing unexpectedPipe(), which reports against currToken. - parentheses(): the unbalanced branch falls back to the opening token. No behaviour change for input that already parsed, and no existing error message changes. Tests assert the message rather than the type. exceptions.mjs already covered this input shape via `throws("unclosed attribute selector", ...)`, which passed throughout: `throws` falls back to `{instanceOf: Error}` when no message is given, and TypeError satisfies that. --- src/__tests__/exceptions.mjs | 22 ++++++++++++++++++++++ src/parser.js | 18 +++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/__tests__/exceptions.mjs b/src/__tests__/exceptions.mjs index c203a3f..62d0169 100644 --- a/src/__tests__/exceptions.mjs +++ b/src/__tests__/exceptions.mjs @@ -7,6 +7,28 @@ throws("unclosed pseudo element", "button::"); throws("unclosed pseudo class", "a:"); throws("unclosed attribute selector", '[name="james"][href'); +// Constructs left open at end of input. These threw a raw TypeError before +// `attribute`, `namespace` and `parentheses` guarded against running out of +// tokens. Asserted by message rather than by type: the default +// `{instanceOf: Error}` check is satisfied by a TypeError, which is why the +// existing "unclosed attribute selector" case above passed throughout. +throws( + "unclosed attribute at end of input", + "a[href", + "Expected a closing square bracket.", +); +throws( + "unclosed attribute with value at end of input", + "a[href=x", + "Expected a closing square bracket.", +); +throws("trailing namespace pipe", ".foo|", "Unexpected '|'."); +throws( + "unclosed parenthesis at end of input", + "a(", + "Expected a closing parenthesis.", +); + throws("no opening parenthesis", ")"); throws("no opening parenthesis (2)", ":global.foo)"); throws("no opening parenthesis (3)", "h1:not(h2:not(h3)))"); diff --git a/src/parser.js b/src/parser.js index 3b00836..6560907 100644 --- a/src/parser.js +++ b/src/parser.js @@ -161,6 +161,11 @@ export default class Parser { attr.push(this.currToken); this.position++; } + if (!this.currToken) { + // Ran off the end of the token stream: the attribute was never closed. + // Point at the opening bracket, which is where the author needs to look. + return this.expected("closing square bracket", startingToken[TOKEN.START_POS]); + } if (this.currToken[TOKEN.TYPE] !== tokens.closeSquare) { return this.expected("closing square bracket", this.currToken[TOKEN.START_POS]); } @@ -697,6 +702,11 @@ export default class Parser { namespace() { const before = (this.prevToken && this.content(this.prevToken)) || true; + if (!this.nextToken) { + // A trailing `|` with nothing after it. `unexpectedPipe` reports against + // `currToken`, which is the pipe itself and always present here. + return this.unexpectedPipe(); + } if (this.nextToken[TOKEN.TYPE] === tokens.word) { this.position++; return this.word(before); @@ -730,6 +740,7 @@ export default class Parser { parentheses() { let last = this.current.last; let unbalanced = 1; + const openingToken = this.currToken; this.position++; if (last && last.type === types.PSEUDO) { const selector = new Selector({ @@ -805,7 +816,12 @@ export default class Parser { } } if (unbalanced) { - return this.expected("closing parenthesis", this.currToken[TOKEN.START_POS]); + // `currToken` is undefined when the token stream ran out before the + // parenthesis was closed; fall back to the opening one. + return this.expected( + "closing parenthesis", + (this.currToken || openingToken)[TOKEN.START_POS], + ); } }