`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 postcss#298
Fixes #298. @alexander-akait said "pr welcome" on the issue.
The cause
space() parks leading whitespace on this.spaces for the next node to claim, and newNode is its only consumer:
Whitespace in this parser only exists as a property of a node. A selector that ends before any node is created has nowhere to keep it, so it is silently dropped. That is why :not( a ) round-trips — the space attaches to the tag — while :not(a, ) does not.
Two conditions in space() route whitespace onto this.spaces: 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*/ ) loses its trailing space too.
Scope
The issue reports :not(a, ). The same defect covers more than that, including a case with no pseudo involved:
The change
Pending whitespace is flushed at the three points where 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, and otherwise to an empty Str node — which is what parseWhitespaceEquivalentTokens already does for the comment-adjacent case, so the AST shape is not a new idea.
Verification
Across 480 generated selectors — six pseudo forms × sixteen argument shapes × five enclosing contexts:
Lossy mode is deliberately untouched: :not(a, ) still minifies to :not(a,).
npm test passes, including oxlint, the type check and the coverage thresholds: 95.02% lines, 95.30% branches, 97.72% functions against gates of 94/94/96. Test count 789 to 798.
Tests
Nine cases in pseudos.mjs, which uses the test helper and so asserts the round-trip automatically. With the fix reverted but the tests kept, all nine fail — they do not pass incidentally.
A note on the AST
:not( ) now yields a selector containing one empty Str node rather than an empty selector. Anything iterating .nodes will see it. The alternative was to store the whitespace on the Selector itself and teach Container._stringify to emit it, which keeps the AST cleaner but changes serialisation for every container type. I took the lower-risk option, but happy to switch if you would rather have the other.