Skip to content

Format at-rule preludes with structured parsing instead of regexes#230

Open
bartveneman wants to merge 16 commits into
mainfrom
claude/atrule-prelude-structured-parsing
Open

Format at-rule preludes with structured parsing instead of regexes#230
bartveneman wants to merge 16 commits into
mainfrom
claude/atrule-prelude-structured-parsing

Conversation

@bartveneman

Copy link
Copy Markdown
Member

Summary

  • format_atrule_prelude reformatted @media/@supports/@container/@layer/@scope/@import preludes with 13 hand-written regexes applied to raw text. This missed cases the regex whitelist didn't cover — e.g. @container STYLE(--foo: bar) never got lowercased, since style wasn't in the hardcoded function-name list — and any new at-rule syntax needs a new regex to work correctly.
  • @projectwallace/css-parser (already used for everything else in this library) ships a real structured parser for this that was previously disabled (parse_atrule_preludes: false). Flipped it on and added a new internal printer, used only by print_atrule, that dispatches on each prelude child's node type instead of pattern-matching text.
  • No public API changes. format_atrule_prelude — the exported partial formatter documented in the README — is completely untouched: same signature, same regex implementation, same tests. It's now also reused internally as the safety-net fallback whenever the structured parser can't represent something losslessly (an at-rule it doesn't recognize, a comment inside a prelude, or the standalone @layer name; statement form).
  • While building this I found and worked around several real bugs in the dependency's structured prelude parser (dotted @layer names getting split at the dot and corrupting cascade semantics if naively reprinted, calc()/env()/other function calls silently dropped from media-feature and @supports condition values, @supports selector(...) returning nothing at all, and a few smaller ones). Full writeup with repro snippets in PARSER_ISSUES.md, since these are worth fixing upstream.
  • Verified via differential testing against the previous implementation across ~170 hand-picked and combinatorial cases (in addition to the existing test suite) to catch regressions the unit tests alone wouldn't — only one divergence found on genuinely invalid CSS input, and two on cases where the old regex output was actually wrong (e.g. @namespace svg url(http://...) — the old regex incorrectly inserted a space inside http://, which the new structured path fixes automatically).

Test plan

  • vitest run — all 269 tests pass (259 existing, unchanged, + 10 new covering the previously-uncovered cases this rework specifically fixes: @container style() lowercasing, function calls surviving in feature/condition values, dotted @layer names, unrecognized at-rules not losing their prelude, nested @supports boolean groups)
  • tsc --noEmit — no type errors
  • oxlint / oxfmt --check — clean
  • Differential fuzz testing against the pre-change implementation (~170 cases) — see PR description above
  • Rough perf sanity check on an at-rule-heavy stylesheet: ~6% slower (structured parsing now happens for every prelude instead of just capturing raw text), not alarming for a correctness fix

Generated by Claude Code

format_atrule_prelude reformatted @media/@supports/@container/@layer/
@scope/@import preludes with 13 hand-written regexes applied to raw
text. This missed real cases the regex whitelist didn't cover - e.g.
@container STYLE(--foo: bar) never got lowercased, since "style"
wasn't in the hardcoded function-name list - and any new syntax
needs a new regex to work correctly.

@projectwallace/css-parser (already used for everything else here)
ships a real structured parser for this that format-css previously
disabled (parse_atrule_preludes: false). Flip it on and add a new
internal printer, used only by print_atrule, that dispatches on each
prelude child's node type instead of pattern-matching text.

format_atrule_prelude itself - the exported partial formatter - is
untouched: same signature, same regex implementation, same tests,
same README example. No public API changes. It's now also reused
internally as the fallback whenever the structured parser can't
represent something losslessly (an at-rule it doesn't recognize, a
comment inside a prelude, or the standalone `@layer name;` statement
form, which has an upstream bug that splits dotted names like
`base.normalize` at the dot - see PARSER_ISSUES.md for this and
several other upstream issues found and worked around while building
this).

Verified via differential testing against the previous implementation
across ~170 hand-picked and combinatorial cases (plus the existing
259-test suite) to catch regressions the unit tests alone wouldn't.
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

⚠️ Package Size Increase

📦 Package 📏 Base Size 📏 Source Size 📈 Size Change
@projectwallace/format-css 8.7 kB 9.9 kB +1.2 kB

bartveneman and others added 3 commits July 19, 2026 21:39
…rser 0.17.0

The rebase picked up @projectwallace/css-parser 0.16.0 -> 0.17.0,
which fixes the dotted-layer-name splitting bug (PARSER_ISSUES.md
issue #1) that previously made the standalone `@layer name;`
statement form fall back to the regex-based formatter. Removed that
bypass now that LayerName.text/.name correctly include the full
dotted name, and let it go through the same structured printing path
as everything else. PARSER_ISSUES.md updated to reflect the fix;
re-verified the remaining issues (#2, #3, #5, #6, #7) are still
present in 0.17.0, so their workarounds are unchanged.
…em fixed

Renumbered the remaining issues (was #2-10, now #1-9) and updated
their cross-references. Fixed issues stay discoverable via git
history instead of accumulating as resolved clutter in the file.
bartveneman added a commit to projectwallace/css-parser that referenced this pull request Jul 19, 2026
…-parses values

Bumped @projectwallace/css-parser 0.17.0 -> 0.18.0, which deep-parses
at-rule prelude values instead of only capturing raw text for them.
This fixes three related PARSER_ISSUES.md entries at once: function
calls (calc(), env(), ...) being silently dropped from media-feature
and @supports/style() condition values, and the off-by-one end
offsets that came with it.

Simplified the printer accordingly, now that the structured nodes
can be trusted directly instead of needing text-based workarounds:
- print_media_feature now prints MediaFeature.value straight through
  print_list (like a declaration's value), instead of slicing it out
  of node.text and re-normalizing with the regex formatter.
- print_supports_query now reuses the already-parsed Declaration
  child via format_declaration when one exists, instead of
  re-parsing raw text through parse_declaration.
- print_prelude_function (@container style()) now reads
  Function.name/.value directly instead of slicing them out of
  node.text.
- extract_balanced_value is now dead code (removed); balance_parens
  and print_condition are still needed for style()'s condition text,
  which still isn't deep-parsed into children.

This also makes numeric formatting inside at-rule preludes consistent
with regular declaration values for the first time (e.g. `.001px` ->
`0.001px`, matching how `width: .001px` already prints elsewhere) -
updated the handful of tests that pinned the old, inconsistent
behavior.

PARSER_ISSUES.md: removed the three now-fixed entries and renumbered
the remaining ones.
claude added 8 commits July 19, 2026 22:13
… in css-parser 0.18.1

Bumped @projectwallace/css-parser 0.18.0 -> 0.18.1, which fixes
three more PARSER_ISSUES.md entries:

- @supports selector(...) (and other function-token conditions) used
  to return an empty array; it's now deep-parsed into a real selector
  list. print_prelude_function now special-cases `selector()` to
  print via format_selector_list instead of the declaration-parsing
  path used for style()/other conditions, since a selector list isn't
  a declaration.
- The leading only/not media-query prefix used to be silently
  dropped from a query's children (though still present in the
  query's own .text). format-css's own regex-based workaround
  (ONLY_NOT_PREFIX_RE, extracting it from node.text and prepending
  it) is now removed, since only/not are real PreludeOperator
  children the existing generic dispatch already prints correctly -
  leaving the old workaround in place would have double-printed them
  ("only only screen") now that the upstream fix landed. Confirmed
  via differential testing against the previous commit that this
  would have been a real, silent regression.
- The .d.ts child-union mismatches on AtrulePrelude/ContainerQuery/
  FeatureRange are also fixed - no code change needed there, just
  removed from the docs.

Only issue #1 (=> tokenized as two operators) and #2 (no
comment-preservation hook) remain open in PARSER_ISSUES.md.

Added test coverage for both fixed behaviors (only/not prefix
round-tripping without duplication, @supports selector() printing as
a selector list rather than going through declaration formatting).
The two remaining entries (=> tokenized as two operators, no
comment-preservation hook for prelude parsing) are being marked
won't-fix upstream. Cleaned up the dangling references to the file
in code comments, keeping the useful context inline.
…nger needed

Good catch - they were leftovers. A container style() condition is
now deep-parsed into a SupportsDeclaration the same way @supports's
own condition already is (turns out this landed in
@projectwallace/css-parser 0.18.1 too, one version after style()'s
own truncation bug was fixed - hadn't re-checked since). This makes
print_prelude_function's style() handling able to mirror
print_supports_query exactly: use the already-parsed Declaration via
format_declaration when node.has_children, fall back to
format_atrule_prelude on the (now complete, untruncated) raw value
otherwise.

That removes the only remaining callers of print_condition,
balance_parens, and has_top_level_colon (the raw-text-based
re-parsing/paren-balancing/declaration-detection they existed for).
Confirmed via differential testing against the previous commit that
this is a pure simplification with zero behavior change, including
the nested-boolean-group and non-declaration (selector()-in-style())
edge cases print_condition used to specifically guard.

Also dropped an oxlint-disable comment left in front of the wrong
test (a leftover from an earlier, unrelated fix - it belongs on the
actual test.skip a bit further down, which still has its own copy).

Added test coverage for the style() edge cases this change touches.
- Drop the => operator-merging workaround in print_feature_range; it
  only mattered for invalid CSS (=> isn't a real comparator), so skip
  the test that relied on it instead.
- Lowercase media/container feature names, preserving casing for
  custom-property-style (--foo) names.
- Add print_identifier(), a shared "lowercase unless --prefixed"
  helper, and use it everywhere an identifier gets lowercased
  (declaration/media-feature properties, function names, selector
  parts, attribute names, at-rule names) so custom-property-style
  names and custom function calls (--myFunc()) keep their casing.
- Remove the redundant is_prelude_operator branch in
  print_prelude_component; the trailing return node.text already
  covers it.
- Stop detecting/preserving comments dropped from structured at-rule
  preludes; skip the two tests that covered it, matching the existing
  preserves-comments skip.
- Apply print_prelude_url's "never touch quotes" behavior to
  value-position print_url as well, and delete print_prelude_url since
  it's now identical to print_url. Update the url() quote-handling
  tests to reflect that url() quoting is no longer touched.
- Trim verbose comments across the new atrule-prelude printers.

@import's functional supports(...)/layer(...) notation vs. standalone
@supports (...)/`@layer name;` are both valid, differently-shaped CSS
syntax, not a parser bug, so no PARSER_ISSUES.md changes here.
These two changes are broader than the at-rule prelude rework this PR
is otherwise scoped to (they touch existing declaration/selector
printing that predates this branch), so they're moving to their own
PRs (#232 and #233) for focused review and testing.

Reverted print_list's function-name lowercasing, format_declaration's
property lowercasing, print_attribute_selector, print_pseudo_selector,
the type/universal selector printer, and print_atrule's name
lowercasing back to their original (unshared) form, and print_url back
to its original quote-normalizing behavior with print_prelude_url
restored as its own function.

The two new call sites this branch actually introduces —
print_media_feature and print_prelude_function's custom-property-style
name handling — keep their `--`-aware lowercasing, just inlined
instead of calling the (now removed from this branch) shared helper.
#232 and #233 merged to main and landed in this branch via the merge
of main above. Point print_media_feature and print_prelude_function at
the real print_identifier() instead of their local duplicated
ternaries.

print_prelude_url stays a separate function from print_url, despite
looking identical at a glance: an @import specifier's Url node can
also be a bare string (@import "foo";) with no url( prefix, unlike
value-position url() where a string is always its own String node
type, never Url. Verified empirically -- unifying the two functions
broke `@import "foo";` (prints as `@import url(";`) since print_url's
now-unconditional slice(4) assumes a url( prefix that isn't always
there in this context.
Split out from the at-rule prelude structured-parsing rework (#230) so
the dependency bump can be reviewed independently. 0.18.0 added deep
parsing of at-rule prelude values (calc()/env()/etc. no longer
silently dropped from MediaFeature/SupportsDeclaration values) and
0.18.1 fixed @supports selector() and the only/not media-query prefix
being dropped -- none of which format-css's current (regex-based)
at-rule prelude formatter depends on, so this is a no-op for existing
behavior on its own.
@bartveneman
bartveneman changed the base branch from main to claude/bump-css-parser-0.18.1 July 20, 2026 18:25
…nto claude/atrule-prelude-structured-parsing
bartveneman added a commit that referenced this pull request Jul 20, 2026
## Summary

- Bumps `@projectwallace/css-parser` from `~0.17.0` to `~0.18.1`.
- Split out from the at-rule prelude structured-parsing rework (#230) so
the dependency bump can be reviewed independently.
- 0.18.0 added deep parsing of at-rule prelude values
(`calc()`/`env()`/etc. no longer silently dropped from
`MediaFeature`/`SupportsDeclaration` values); 0.18.1 fixed `@supports
selector()` and the `only`/`not` media-query prefix being dropped.
- None of this is used by format-css's current (regex-based) at-rule
prelude formatter, so the bump alone is a no-op for existing behavior —
#230 is what actually takes advantage of it.

## Test plan

- [x] `npx tsc --noEmit`, `npx vitest run`, `npx oxlint`, `npx oxfmt
--check` all clean at the new version, with zero code changes


---
_Generated by [Claude
Code](https://claude.ai/code/session_01AG67iFrhdQAGoTNnC7gmw4)_

Co-authored-by: Claude <noreply@anthropic.com>
Base automatically changed from claude/bump-css-parser-0.18.1 to main July 20, 2026 18:42
bartveneman and others added 2 commits July 20, 2026 20:46
print_feature_range and print_prelude_children were casting through
`as unknown as Iterable<CSSNode>` to iterate their node. Turns out
that's only needed for the base CSSNode type, which isn't always
iterable (leaf nodes have no children) -- the concrete node types here
(FeatureRange, and AtrulePrelude/MediaQuery/ContainerQuery once given
their own union type instead of the generic CSSNode) already declare
Symbol.iterator via WithChildren<T> and don't need a cast at all.

print_media_feature took `minify: boolean` but only ever used it to
derive optional_space locally -- it never forwards `{ minify }` into
any format_* call the way print_supports_query/print_prelude_function
do. Takes optional_space directly now, matching print_feature_range.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants