From 7760fd1b119515bcb8ac0803b39cf93b77950b63 Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Mon, 10 Aug 2026 10:05:26 +0100 Subject: [PATCH] fix: lossless mode drops whitespace when a selector ends empty `space()` parks leading whitespace on `this.spaces` for the next node to claim, and `newNode` is its only consumer. A selector that ends before any node is created therefore loses it: :not(a, ) -> :not(a,) :not( ) -> :not() a, -> a, Two conditions route whitespace there: the previous token is a comma or an opening parenthesis, or the selector so far holds nothing but comments. The second is why `:not( /*c*/ )` also loses its trailing space. Pending whitespace is now flushed wherever a selector can close: at a comma, at a pseudo's closing parenthesis, and at end of input. It attaches to the last node's `spaces.after` when there is one, otherwise to an empty string node, matching what `parseWhitespaceEquivalentTokens` already does. Lossy output is unchanged. Across 480 generated selectors: 240 newly round-trip, no regressions, and `lossless: false` is byte-identical to before. Fixes #298 --- src/__tests__/pseudos.mjs | 17 +++++++++++++++++ src/parser.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/__tests__/pseudos.mjs b/src/__tests__/pseudos.mjs index 0eee446..2677b90 100644 --- a/src/__tests__/pseudos.mjs +++ b/src/__tests__/pseudos.mjs @@ -184,3 +184,20 @@ test("nested pseudo classes", "section:not( :has(h1, h2 ) )", (t, tree) => { t.deepEqual(tree.nodes[0].nodes[1].nodes[0].nodes[0].nodes[1].nodes[0].type, "tag"); t.deepEqual(tree.nodes[0].nodes[1].nodes[0].nodes[0].nodes[1].nodes[0].value, "h2"); }); + +// Whitespace is stored on nodes, so a selector that ends before any node is +// created had nowhere to keep it and lost it. `test` asserts the round-trip. +test("trailing space after a comma inside a pseudo", ":not(a, )", (t, tree) => { + const args = tree.nodes[0].nodes[0].nodes; + t.deepEqual(args.length, 2); + t.deepEqual(args[1].toString(), " "); +}); + +test("trailing space in a nested pseudo", "div:has(a, )"); +test("trailing space after several commas", ":not(a, b, )"); +test("space before a trailing comma", ":not(a , )"); +test("whitespace-only pseudo argument", ":not( )"); +test("multiple spaces in a whitespace-only argument", ":not( )"); +test("trailing space after a top-level comma", "a, "); +test("space around a comment in a pseudo argument", ":not( /*c*/ )"); + diff --git a/src/parser.js b/src/parser.js index bcec5ac..f745d20 100644 --- a/src/parser.js +++ b/src/parser.js @@ -641,7 +641,37 @@ export default class Parser { return this.newNode(node); } + // Whitespace is only ever stored on a node, as `spaces.before` or + // `spaces.after`. `space()` parks leading whitespace on `this.spaces` for the + // next node to claim, so a selector that ends before any node is created -- + // `:not(a, )`, `:not( )`, a trailing `a, ` -- drops it. Park it on an empty + // string node instead, which is what `parseWhitespaceEquivalentTokens` + // already does for the comment-adjacent case. + flushPendingSpaces() { + if (!this.spaces) { + return; + } + const last = this.current.last; + if (last) { + // `space()` routes trailing whitespace to `this.spaces` whenever the + // selector so far holds nothing but comments, so `:not( /*c*/ )` lands + // here rather than on the `spaces.after` path. + last.spaces.after += this.spaces; + this.spaces = ""; + return; + } + const token = this.currToken || this.prevToken; + this.newNode( + new Str({ + value: "", + source: token ? getTokenSource(token) : undefined, + sourceIndex: token ? token[TOKEN.START_POS] : 0, + }), + ); + } + comma() { + this.flushPendingSpaces(); if (this.position === this.tokens.length - 1) { this.root.trailingComma = true; this.position++; @@ -782,6 +812,7 @@ export default class Parser { if (unbalanced) { this.parse(); } else { + this.flushPendingSpaces(); this.current.source.end = tokenEnd(this.currToken); this.current.parent.source.end = tokenEnd(this.currToken); this.position++; @@ -1005,6 +1036,7 @@ export default class Parser { while (this.position < this.tokens.length) { this.parse(true); } + this.flushPendingSpaces(); this.current._inferEndPosition(); return this.root; }