Skip to content

css: default the at-rule and qualified-rule parser traits, drop the url_to_css shim - #31999

Open
alii wants to merge 13 commits into
mainfrom
claude/split/css
Open

alii wants to merge 13 commits into
mainfrom
claude/split/css

Conversation

@alii

@alii alii commented Jun 8, 2026 •

Copy link
Copy Markdown
Member

What this does

Split from #31912. Behavior-preserving dedup in the CSS parser, net +89/-305 across 9 files.

This PR originally also converted compat.rs from a generated match to a data table. #39770 landed an equivalent conversion on main (a [[u32; 9]; 215] table emitted by build-prefixes.js), so that part, the generator changes, and the FontSizeXxxLarge rename that only existed to go with it were dropped here in favor of main's version. Also dropped, and not shipped by this PR: the one-line webview_ios: null entries this branch used to add to the generator's browser maps so it runs against newer BCD data; that belongs with the generator now owned by #39770 and can land on its own. What remains:

  • src/css/css_parser.rs: AtRuleParser and QualifiedRuleParser get default method bodies that reject the rule (at_rule_invalid / at_rule_body_invalid / qualified_rule_invalid, and Err(()) for rule_without_block).
  • declaration.rs, rules/font_face.rs, rules/font_palette_values.rs, rules/keyframes.rs, rules/page.rs, rules/property.rs: delete the copy-pasted reject-all impls, which were byte-for-byte the new defaults. PageRuleParser keeps its real parse_prelude/parse_block and only drops rule_without_block. TopLevelRuleParser/NestedRuleParser keep their explicit impls and are unaffected.
  • src/css/properties/custom.rs: drop the inlined url_to_css shim and call the shared Url::to_css (already used by font_face.rs, image.rs, syntax.rs). The shim differed only in testing tag.is_internal() where Url::to_css tests flags.IS_INTERNAL. For CSS url records the two cannot diverge: IS_INTERNAL is only set by the JS parser, and the one bundler site that can put a >= Runtime tag (the threshold Tag::is_internal uses) on a CSS record is bun:wrap, which is rejected at resolve time before anything prints; builtin aliases set Builtin, below the threshold. Verified by bundling url() of bun:wrap/node:fs/bun:sqlite/bun:ffi/fs under both targets on this branch and on the release build: identical output in all ten cases.

Tests

test/js/bun/css/css.test.ts gains two blocks:

  • at-rule and qualified-rule rejection defaults: unknown at-rules and qualified rules inside @font-face, @keyframes, @font-palette-values, @property, and style attributes now hit the trait defaults; the tests pin the recovery output and the Unknown at-rule @bogus error text.
  • browser compat feature table: three features probed one version below and at their chrome minimum. These were written against this PR's table and now run against the Trim ~2 MB from the release binary without touching hot paths #39770 table on main, where they pass, so they double as a cross-check of that table and are kept.

Both blocks pass before and after by construction (the change is a dedup); they were run on the merge base and on this branch with identical results.

Verification

cargo check / cargo clippy -p bun_css clean on the merged head. bun bd test test/js/bun/css/css.test.ts 1193 pass, bun bd test test/bundler/css/ 169 pass.

@robobun

robobun commented Jun 8, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 5:56 PM PT - Aug 20th, 2026

✅ @robobun, your commit f52c5be629c213c8a338eee2466a674937478f12 passed in Build #101938! 🎉


🧪   To try this PR locally:

bunx bun-pr 31999

That installs a local version of the PR into your bun-31999 executable, so you can run:

bun-31999 --bun

@alii
alii marked this pull request as ready for review June 9, 2026 20:18
@alii

alii commented Jun 9, 2026

Copy link
Copy Markdown
Member Author

@robobun adopt

@coderabbitai

coderabbitai Bot commented Jun 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds default rejection behavior to CSS parser traits, removes explicit rejection methods from several rule parsers, updates browser compatibility data and Rust compat generation, changes one font-size feature lookup, refactors custom property URL serialization, and adds CSS tests.

Changes

Parser trait defaults and implementation cleanup

Layer / File(s) Summary
Trait default implementations
src/css/css_parser.rs
QualifiedRuleParser and AtRuleParser now provide default implementations that reject unsupported qualified rules and at-rules with parse errors.
Rule parser implementations simplified
src/css/declaration.rs, src/css/rules/font_face.rs, src/css/rules/font_palette_values.rs, src/css/rules/keyframes.rs, src/css/rules/page.rs, src/css/rules/property.rs, test/js/bun/css/css.test.ts
PropertyDeclarationParser, FontFaceDeclarationParser, FontPaletteValuesDeclarationParser, KeyframesListParser, and PageRuleParser drop explicit rejection method bodies and unused imports; the CSS test file adds coverage for default nested at-rule rejection.
Browser compat and rule-default tests
test/js/bun/css/css.test.ts
Adds browser compatibility min-version assertions and nested at-rule rejection assertions using minify_test_with_options and attrTest.

CSS browser compatibility and Rust code generation

Layer / File(s) Summary
Browser mapping configuration
src/css/build-prefixes.js
webview_ios is added to BROWSER_MAPPING and MDN_BROWSER_MAPPING as unsupported, and the MDN feature key for xxx-large font size is renamed to fontSizeXxxLarge.
Rust compat code generation
src/css/build-prefixes.js, src/css/properties/font.rs
The compat generator now emits UNSUPPORTED, MinVersions, FEATURE_COUNT, and a MIN_VERSIONS table, and rewrites Feature::is_compatible and Feature::is_partially_compatible to use table lookup and macro-based browser checks; AbsoluteFontSize::XxxLarge now references Feature::FontSizeXxxLarge.

Custom properties URL serialization refactoring

Layer / File(s) Summary
Direct URL::to_css invocation
src/css/properties/custom.rs
ext::url_to_css is removed, and TokenList::to_css now serializes TokenOrValue::Url through url.to_css(dest) directly.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately describes the parser default implementations and URL shim removal, which are major changes in the pull request.
Description check ✅ Passed The description explains the changes and provides detailed tests and verification results, despite using headings that differ from the template.

Comment @coderabbitai help to get the list of available commands.

@alii
alii force-pushed the claude/split/css branch from e0533a1 to a31d011 Compare June 9, 2026 20:19
@robobun

robobun commented Jun 9, 2026 •

Copy link
Copy Markdown
Collaborator

Adopted. The compat.rs table conversion that was the headline here has since landed on main via #39770, so this PR is now only the parser dedup (trait defaults plus six deleted impls, and the url_to_css shim removal), +89/-305.

Merged with main at f52c5be and mergeable; CSS suites green on the merged head. Waiting on alii to either merge the remaining dedup or close it as not worth a separate landing.

@alii

alii commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

@robobun does anything get faster because of this table?

@robobun

robobun commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Runtime: no. is_compatible only runs during CSS transforms with browser targets set, and per call both forms are a handful of predictable compares (the old dense match compiled to a jump table), all of it a rounding error next to parse/print. I would not expect any benchmark to move.

Binary size: yes. The CI size check on this build compares against main exactly at the merge point (build 61606 = 09703da), so the delta is this diff alone: -16 KB darwin-x64, -32 KB linux-x64 and x64-baseline, -48 KB darwin-aarch64, -64 KB linux-aarch64. The bigger aarch64 savings fit deleting ~5.2k lines of compare-and-branch codegen (fixed-width instructions make branchy code larger there), replaced by a 7.6 KB static table.

Compile time of bun_css: I timed old vs new on this container (3 runs each, dev and release) and the difference is within run-to-run noise, so no claim there.

So the honest answer is smaller, not faster; the motivation remains the -5.2k lines and build-prefixes.js being able to regenerate the file.

# Conflicts:
#	src/css/properties/custom.rs
@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

Merge conflicts resolved (741eebd). The only real conflict was in src/css/properties/custom.rs: #32519 rewrote the port-era comments around mod ext and on dashed_ident_ref_parse, right where this branch deletes the url_to_css shim. Kept the shim deletion and took #32519's cleaned comments for the rest. cargo check/clippy -p bun_css clean; css.test.ts (1105 pass) and test/bundler/css/ (167 pass) both green on the merged head.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/css/css_parser.rs`:
- Around line 2200-2217: The code uses i32::try_from(...).expect("int cast") on
parser offsets which can panic on large input values, but since this parser path
processes external CSS, panics should be avoided. Replace the expect calls on
the i32::try_from casts for both the location and len values in the
PropertyUsage range creation with proper error handling that returns a Result
instead of panicking, allowing errors to be propagated gracefully to the caller
rather than aborting the process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c38e13da-fdb2-41f7-9282-10bb84d178b7

📥 Commits

Reviewing files that changed from the base of the PR and between 5e62878 and 741eebd.

📒 Files selected for processing (7)
  • src/css/css_parser.rs
  • src/css/declaration.rs
  • src/css/properties/custom.rs
  • src/css/properties/font.rs
  • src/css/rules/font_face.rs
  • src/css/rules/font_palette_values.rs
  • src/css/rules/keyframes.rs
💤 Files with no reviewable changes (4)
  • src/css/rules/keyframes.rs
  • src/css/properties/font.rs
  • src/css/rules/font_palette_values.rs
  • src/css/rules/font_face.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/css/css_parser.rs`:
- Around line 2200-2217: The code uses i32::try_from(...).expect("int cast") on
parser offsets which can panic on large input values, but since this parser path
processes external CSS, panics should be avoided. Replace the expect calls on
the i32::try_from casts for both the location and len values in the
PropertyUsage range creation with proper error handling that returns a Result
instead of panicking, allowing errors to be propagated gracefully to the caller
rather than aborting the process.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c38e13da-fdb2-41f7-9282-10bb84d178b7

📥 Commits

Reviewing files that changed from the base of the PR and between 5e62878 and 741eebd.

📒 Files selected for processing (7)
  • src/css/css_parser.rs
  • src/css/declaration.rs
  • src/css/properties/custom.rs
  • src/css/properties/font.rs
  • src/css/rules/font_face.rs
  • src/css/rules/font_palette_values.rs
  • src/css/rules/keyframes.rs
💤 Files with no reviewable changes (4)
  • src/css/rules/keyframes.rs
  • src/css/properties/font.rs
  • src/css/rules/font_palette_values.rs
  • src/css/rules/font_face.rs
🛑 Comments failed to post (1)
src/css/css_parser.rs (1)

2200-2217: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid panicking integer casts on parser offsets.

i32::try_from(...).expect("int cast") can panic on large input offsets/lengths. This parser path is fed by external CSS, so it should not abort the process.

Suggested fix
-                let len = input.position() - location;
+                let len = input.position() - location;
+                let start_i32 = i32::try_from(location).unwrap_or(i32::MAX);
+                let max_len = i32::MAX.saturating_sub(start_i32.max(0));
+                let len_i32 = i32::try_from(len).map_or(max_len, |v| v.min(max_len));
                 let mut usage = PropertyBitset::init_empty();
                 let mut custom_properties: Vec<&'static [u8]> = Vec::new();
                 fill_property_bit_set(&mut usage, &declarations, &mut custom_properties);
@@
                                 range: bun_ast::Range {
                                     loc: bun_ast::Loc {
-                                        start: i32::try_from(location).expect("int cast"),
+                                        start: start_i32,
                                     },
-                                    len: i32::try_from(len).expect("int cast"),
+                                    len: len_i32,
                                 },
                                 ..Default::default()
                             });

As per coding guidelines, “Do not use .unwrap() on fallible paths that user input or the OS can hit at runtime — return the error instead.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/css/css_parser.rs` around lines 2200 - 2217, The code uses
i32::try_from(...).expect("int cast") on parser offsets which can panic on large
input values, but since this parser path processes external CSS, panics should
be avoided. Replace the expect calls on the i32::try_from casts for both the
location and len values in the PropertyUsage range creation with proper error
handling that returns a Result instead of panicking, allowing errors to be
propagated gracefully to the caller rather than aborting the process.

Source: Coding guidelines

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

re the coderabbit finding on i32::try_from(...).expect("int cast") at css_parser.rs:2200-2217: that code is on main since the initial Rust port (#30412) and this PR does not touch it. The net css_parser.rs diff here is entirely the AtRuleParser/QualifiedRuleParser default implementations at lines ~694-810; coderabbit picked up the 2200-block because the merge-commit range surfaces auto-merged main changes.

On the substance: bun_ast::Range stores i32 offsets by design (shared with the JS parser), and that .expect("int cast") pattern appears 71 times across src/css/ and src/js_parser/ for the same reason. Firing requires a >2 GiB source file. Changing that representation or its casts is a separate decision that belongs in its own PR, not a CSS dedup refactor, so leaving it alone here.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this restructures the 215-entry browser-compat dispatch into an order-dependent static table and includes a noted behavior change in custom-property URL serialization (tag.is_internal() → flags.IS_INTERNAL), so it's worth a human sign-off.

Extended reasoning...

Overview

This PR touches 12 files in src/css/: it replaces the ~5.4k-line generated match in compat.rs with a 215-row static MIN_VERSIONS table indexed by Feature as usize, adds default reject-all method bodies to the AtRuleParser/QualifiedRuleParser traits in css_parser.rs and deletes six copy-pasted impls that now fall through to those defaults, drops the inlined url_to_css shim in properties/custom.rs in favor of the shared Url::to_css, and extends build-prefixes.js to regenerate compat.rs (plus restores allBrowsers and maps webview_ios). New tests in css.test.ts pin three compat-table boundaries and the at-rule rejection paths.

Security risks

None identified. The changes are confined to CSS parsing/printing and browser-feature-compat lookup; no auth, network, filesystem, or untrusted-input handling beyond what already existed.

Level of scrutiny

Medium-high. The compat table drives which CSS syntax gets lowered for browser targets — user-visible bundler output. Correctness hinges on the 215 table rows being in exact enum-declaration order (FEATURE_COUNT enforces length but not ordering); the author reports a mechanical 215×9 equivalence audit and the generator reproduces the checked-in file byte-for-byte, but that verification is self-reported. Separately, the url_to_css shim deletion is not a pure no-op: the shim checked import_record.tag.is_internal() while the shared Url::to_css (src/css/values/url.rs:100) checks flags.contains(IS_INTERNAL). The description frames this as the shim having drifted from the Zig reference, which is plausible, but it's an intentional behavior change worth a human eye.

Other factors

The trait-default refactor and the six impl deletions are mechanical and clearly equivalent (the deleted bodies match the new defaults verbatim). The build-prefixes.js changes are dev-tooling not exercised in CI. Existing CSS suites (~2300 tests) plus the new boundary tests provide reasonable coverage, and a maintainer (alii) is already engaged on the thread. Given the scale of the compat.rs rewrite and the small intentional semantic change, I'm deferring rather than auto-approving.

@robobun

robobun commented Jun 20, 2026

Copy link
Copy Markdown
Collaborator

On the two points the review bot raised:

Table ordering. FEATURE_COUNT only pins the row count; ordering is what the per-row trailing // FeatureName comment is for. The equivalence script I ran checks rowOrder[i] == enum[i] for every i (besides value-for-value equality against the 5.4k-line match on main). The boundary tests added to css.test.ts probe three rows at spread enum positions (FontFamilySystemUi, HexAlphaColors, MediaRangeSyntax) one version below and at the chrome minimum; a shuffled table makes those fail. And since build-prefixes.js now emits the file, both the enum and the rows come from the same sorted source, so a future regen cannot drift them apart.

tag.is_internal() vs flags.IS_INTERNAL. Observationally a no-op on this path. All three CSS ImportRecord construction sites (css_parser.rs:903/2952/3468) set tag: Default::default() (= Tag::None) and flags: Default::default(); nothing in src/css/ or src/bundler/ writes either one. tag.is_internal() is tag >= Tag::Runtime, which is false for Tag::None; flags.contains(IS_INTERNAL) is false for empty flags. Both checks yield false for every CSS url record, so the shim deletion does not change output. The shared Url::to_css has used flags.IS_INTERNAL since it was written (matching the Zig reference in values/url.zig), and font_face.rs, image.rs, syntax.rs already call it; this PR just aligns custom.rs to the same helper.

Agree it warrants a human sign-off given the table size; leaving it as-is for alii.

# Conflicts:
#	src/css/build-prefixes.js
#	src/css/compat.rs
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Merge conflicts resolved (091df37). #32621 rewrote build-prefixes.js to emit Rust for targets.rs, prefixes.rs, and compat.rs (the .zig reference files are gone), with compat.rs still in the match form this PR replaces. Resolution:

  • build-prefixes.js: took main's structure wholesale, then re-applied the table-form compat.rs emission on top of it (replacing the match-form block), plus the two small additions this PR had previously: webview_ios: null in both browser-mapping tables, and the fontSizeXxxLarge key casing so the emitted variant name matches compat.rs/font.rs.
  • compat.rs: kept the branch's table form; updated the header to drop the compat.zig reference and match main's one-line Autogenerated by build-prefixes.js. DO NOT EDIT! form, which the new emission block now produces.

Verified the emission block is byte-identical to the checked-in compat.rs when driven with the 215-feature dataset parsed from main's match form, and a full end-to-end generator run with the Sept 2024 pinned data deps completes (targets.rs + prefixes.rs + compat.rs all written, rustfmt-idempotent, only data-drift differences). cargo check/clippy -p bun_css clean; css.test.ts 1105 pass, test/bundler/css/ 167 pass.

@alii

alii commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

@robobun get this mergeable — or close it if the changes are already covered on main / too stale to salvage.

# Conflicts:
#	src/css/compat.rs
#	test/js/bun/css/css.test.ts
@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Mergeable again at 1f544bf (merged main in; GitHub reports mergeable, CI running on the new head).

Not covered on main: compat.rs there is still the 5.5k-line match, the AtRuleParser/QualifiedRuleParser defaults do not exist, and the url_to_css shim is still in custom.rs, so the PR is still doing its job rather than being stale. Despite ~1060 commits of drift only two files conflicted:

  • compat.rs: Narrow crate-internal Rust visibility across all targets and delete the code it proves dead #36184 narrowed is_compatible/is_partially_compatible to pub(crate) (the workspace now denies unreachable_pub). Applied the same narrowing to the table form and to the generator's emission block, so regeneration still reproduces the checked-in file byte for byte (re-verified by driving the emission block with the 215 features parsed out of main's match form).
  • css.test.ts: both sides appended tests at the end of the file; kept both.

Everything else (trait defaults, the six impl deletions, the shim removal, build-prefixes.js) auto-merged intact. Net diff is the same 12 files as before, +430/-5601. On the merged tree: cargo check/clippy -p bun_css clean under the stricter lints, css.test.ts 1154 pass, test/bundler/css/ 169 pass.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Given the ~5k-line data-table transformation in compat.rs and the fact that alii is already shepherding it, a human sign-off on the equivalence claim still makes sense.

What was reviewed:

  • Spot-checked table rows for HexAlphaColors, MediaRangeSyntax, FontFamilySystemUi, and the three None entries against the deleted match arms — values and UNSUPPORTED sentinels line up, and the encoded chrome minimums match the new test's 56/62/104 << 16 boundaries.
  • Confirmed is_compatible/is_partially_compatible table semantics are equivalent to the old per-browser if version < N / .is_some() chains, including the None-row early return.
  • Verified each deleted AtRuleParser/QualifiedRuleParser impl body matches the new trait defaults exactly (including PageRuleParser keeping its real parse_prelude/parse_block and only dropping rule_without_block); BasicParseErrorKind::at_rule_invalid accepts &[u8] via *const [u8] coercion so the dropped std::ptr::from_ref wrapping is a no-op.
  • Compared the deleted ext::url_to_css against Url::to_css in src/css/values/url.rs — the only semantic difference is tag.is_internal() vs flags.IS_INTERNAL, already argued as unreachable for CSS url records in the thread.
Extended reasoning...

Overview

This PR refactors src/css/compat.rs from a ~5.2k-line generated match into a 215-row static MIN_VERSIONS table indexed by Feature as usize, rewrites the build-prefixes.js generator to emit that table form, adds default reject-all method bodies to the AtRuleParser/QualifiedRuleParser traits and deletes the six copy-pasted implementations, drops the ext::url_to_css shim in properties/custom.rs in favor of the shared Url::to_css, renames Feature::FontSizeXXXLarge → FontSizeXxxLarge, and adds two test blocks pinning the compat-table boundaries and the trait-default rejection paths.

Security risks

None. This is a pure refactor of CSS bundler internals — no auth, network, filesystem, or user-input validation surface changes. The generated data table drives feature-lowering decisions during CSS transforms; the worst-case failure mode of a wrong entry is emitting a fallback (or not) for a given browser target, not a security issue.

Level of scrutiny

Medium-high. The individual code changes (trait defaults, shim deletion, enum rename) are straightforward and I verified each against what it replaces. The compat table itself is the load-bearing part: 215 rows × 9 browsers of numeric data that cannot be eyeballed. The PR description documents a mechanical row-by-row equivalence audit against the old match form plus a generator round-trip producing byte-identical output, and my spot checks of four features (plus the three None rows) against the deleted match arms all agree. The FEATURE_COUNT const ties table length to the enum, and both enum and rows are emitted from the same sorted list in the generator, so ordering drift on regen is prevented structurally.

Other factors

alii has been actively engaged (asked about perf, asked robobun to get it mergeable) and robobun's own 2026-06-20 comment already flagged that the table size warrants a human sign-off. The 1105-test CSS suite plus the bundler CSS suite pass, and new tests pin three spread table positions at ±1 of their chrome minimum. I found nothing wrong, but the correctness of the full 215×9 data transformation ultimately rests on trusting the author's mechanical audit — which is reasonable, but is exactly the kind of thing a maintainer should nod at rather than a bot.

@robobun

robobun commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Re the human sign-off on the 215x9 data: agreed, and to make it cheap rather than a trust-me, here is the checker that backs the equivalence claim so anyone can re-run it on the current head. It parses every match arm out of origin/main:src/css/compat.rs, parses every row out of the table on this branch, and compares all 1935 cells plus row-vs-enum order. It refuses to pass if an arm contains any shape it did not parse, so a missed construct fails loudly instead of being skipped.

Current head (1f544bf):

features: 215, browsers: 9, cells compared: 1935
EQUIVALENT: every feature x browser matches and rows are in enum order

Sanity that it can fail: bumping one cell by 1 reports FontStretchPercentage.chrome: 4063232 (main) vs 4063233 (branch); swapping the first two rows reports both row[i]/enum[i] mismatches.

verify_compat_table.js (run from the repo root on this branch)
// Proves src/css/compat.rs (table form, this branch) encodes exactly the same
// data as the match form on origin/main, for every Feature and every browser,
// and that the table rows are in enum-declaration order.
//
//   node verify_compat_table.js        (run from the repo root on this branch)
const fs = require("fs");
const { execSync } = require("child_process");

const BROWSERS = ["android", "chrome", "edge", "firefox", "ie", "ios_saf", "opera", "safari", "samsung"];
const UNSUPPORTED = 0xffffffff;
const RENAMES = { FontSizeXXXLarge: "FontSizeXxxLarge" };

// ---- old: match form from main ------------------------------------------
const old = execSync("git show origin/main:src/css/compat.rs", { maxBuffer: 64 << 20 }).toString();
const oldEnum = old
  .match(/pub enum Feature \{([\s\S]*?)\n\}/)[1]
  .split(",")
  .map(s => s.trim())
  .filter(Boolean)
  .map(n => RENAMES[n] ?? n);

const body = old.slice(old.indexOf("fn is_compatible"), old.indexOf("fn is_partially_compatible"));
const armRe = /((?:Feature::\w+\s*(?:\|\s*)?)+)=>\s*\{([\s\S]*?)\n            \}/g;
const oldData = new Map();
let m;
while ((m = armRe.exec(body)) !== null) {
  const names = [...m[1].matchAll(/Feature::(\w+)/g)].map(x => RENAMES[x[1]] ?? x[1]);
  const arm = m[2];
  let row;
  if (/^\s*return false;\s*$/.test(arm)) {
    row = null;
  } else {
    row = Object.fromEntries(BROWSERS.map(b => [b, UNSUPPORTED]));
    const minRe = /if let Some\(version\) = browsers\.(\w+) \{\s*if version < (\d+) \{/g;
    let mm;
    while ((mm = minRe.exec(arm)) !== null) row[mm[1]] = Number(mm[2]);
    // Every browser must be mentioned either as a min check or in the
    // `.is_some()` unsupported clause; anything else means the parser missed
    // a shape and the comparison would be meaningless.
    for (const b of BROWSERS) {
      if (!arm.includes(`browsers.${b}`)) throw new Error(`${names[0]}: browser ${b} not mentioned in arm`);
    }
    const leftover = arm
      .replace(/if let Some\(version\) = browsers\.\w+ \{\s*if version < \d+ \{\s*return false;\s*\}\s*\}/g, "")
      .replace(/if (?:browsers\.\w+\.is_some\(\)(?:\s*\|\|\s*)?)+\s*\{\s*return false;\s*\}/g, "")
      .trim();
    if (leftover) throw new Error(`${names[0]}: unparsed arm content: ${leftover.slice(0, 120)}`);
  }
  for (const n of names) oldData.set(n, row);
}

// ---- new: table form on this branch ---------------------------------------
const nu = fs.readFileSync("src/css/compat.rs", "utf8");
const nuEnum = nu
  .match(/pub enum Feature \{([\s\S]*?)\n\}/)[1]
  .split(",")
  .map(s => s.trim())
  .filter(Boolean);
const rows = nu
  .match(/static MIN_VERSIONS[\s\S]*?= \[\n([\s\S]*?)\n\];/)[1]
  .split("\n")
  .map(s => s.trim())
  .filter(Boolean);

const rowOrder = [];
const nuData = new Map();
for (const line of rows) {
  const name = line.match(/\/\/ (\w+)\s*$/)[1];
  rowOrder.push(name);
  if (line.startsWith("None,")) {
    nuData.set(name, null);
    continue;
  }
  const row = {};
  for (const [, k, v] of line.matchAll(/(\w+): (UNSUPPORTED|\d+)/g)) row[k] = v === "UNSUPPORTED" ? UNSUPPORTED : Number(v);
  nuData.set(name, row);
}

// ---- compare ---------------------------------------------------------------
let failures = 0;
const fail = msg => {
  failures++;
  console.error("FAIL:", msg);
};

if (oldEnum.length !== nuEnum.length) fail(`enum length ${oldEnum.length} (main) vs ${nuEnum.length} (branch)`);
oldEnum.forEach((n, i) => n !== nuEnum[i] && fail(`enum[${i}]: ${n} (main) vs ${nuEnum[i]} (branch)`));
nuEnum.forEach((n, i) => n !== rowOrder[i] && fail(`row[${i}] is ${rowOrder[i]} but enum[${i}] is ${n}`));

for (const n of oldEnum) {
  if (!oldData.has(n)) fail(`${n}: no match arm on main`);
  if (!nuData.has(n)) fail(`${n}: no table row on branch`);
  const a = oldData.get(n), b = nuData.get(n);
  if (a === null || b === null) {
    const aNone = a === null || BROWSERS.every(k => a[k] === UNSUPPORTED);
    const bNone = b === null || BROWSERS.every(k => b[k] === UNSUPPORTED);
    if (aNone !== bNone) fail(`${n}: None-row mismatch`);
    continue;
  }
  for (const k of BROWSERS) if (a[k] !== b[k]) fail(`${n}.${k}: ${a[k]} (main) vs ${b[k]} (branch)`);
}

console.log(`features: ${oldEnum.length}, browsers: ${BROWSERS.length}, cells compared: ${oldEnum.length * BROWSERS.length}`);
console.log(failures ? `${failures} failure(s)` : "EQUIVALENT: every feature x browser matches and rows are in enum order");
process.exit(failures ? 1 : 0);

@alii

alii commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

@robobun retry ci — darwin lanes expired (runner timeout)

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Retriggered: build 91722 is running on 11f51b9 (empty commit on top of 1f544bf; my Buildkite token is read-only so a push was the only way to get a fresh build). On the old build 91653 the 6 expired darwin jobs had already been auto-retried once and were sitting in scheduled with no runner picking them up; the other 190 jobs had all passed. It was canceled when the new build started.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and found no bugs. Given the size of the 215×9 compat data table and that alii is already shepherding this, a human sign-off still makes sense.

What was reviewed:

  • is_compatible/is_partially_compatible table logic is equivalent to the old match (UNSUPPORTED sentinel ↔ .is_some() clause; None row ↔ return false arm); spot-checked several rows against the deleted arms.
  • New trait defaults return the same errors as every deleted impl body; PageRuleParser correctly keeps its custom parse_prelude/parse_block and only drops rule_without_block.
  • Url::to_css in values/url.rs matches the deleted shim except for flags.IS_INTERNAL vs tag.is_internal(), which the PR shows are both always false for CSS url records.
Extended reasoning...

Overview

Twelve files in src/css/ plus one test file. Four independent refactors bundled: (1) compat.rs — a ~5.2k-line generated match replaced by a 215-row static MIN_VERSIONS table with generic is_compatible/is_partially_compatible; (2) css_parser.rs — default reject-all method bodies added to AtRuleParser/QualifiedRuleParser, with six copy-pasted impls deleted; (3) custom.rs — an inlined url_to_css shim removed in favor of the shared Url::to_css; (4) build-prefixes.js — the generator updated to emit the table form and handle webview_ios. One enum variant renamed (FontSizeXXXLarge → FontSizeXxxLarge) with its one caller updated. Net +430/-5601.

Security risks

None. This is CSS parser/printer internals with no auth, crypto, path handling, or untrusted-length arithmetic in the diff. The only semantic hinge is browser-compat lowering decisions (whether to emit a fallback), and getting those wrong produces suboptimal CSS output rather than a vulnerability.

Level of scrutiny

Medium-high. Each piece is mechanical, but the compat table encodes 1935 data points that cannot be hand-verified in review — correctness rests on the equivalence script the author posted plus spot checks. I verified the new is_compatible logic is algebraically identical to the old per-arm form (including the None → return false case and the UNSUPPORTED sentinel matching the old .is_some() unsupported clause), spot-checked Selectors2, Fullscreen, and the three None rows against the deleted match arms, and confirmed is_partially_compatible's direct table lookup is equivalent to the old probe-via-is_compatible approach. The trait-default dedup is trivially equivalent (deleted bodies match the new defaults byte-for-byte modulo a ptr::from_ref no-op). The url_to_css removal is the one place with a real semantic difference (tag.is_internal() vs flags.IS_INTERNAL); I read src/css/values/url.rs:67-139 and it matches the deleted shim otherwise, and the PR's argument that both flags are always false for CSS import records is well-sourced.

Other factors

The bug-hunting system found nothing. The PR adds boundary tests for three spread table rows and rejection-default tests for all five affected rule contexts. alii has been actively engaged (perf question, mergeability, CI retry) and the author's own agent noted on 2026-06-20 that the table size warrants a human sign-off. Given that context — a large, provably-but-not-visually-verifiable data refactor with an engaged human reviewer already in the loop — deferring rather than auto-approving is the right call.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

The retry (build 91722) hit the same thing: 190/196 jobs passed, and all 6 darwin test jobs (14 x64, 14 aarch64, 26 aarch64) were canceled without ever starting; same as 91653 before it. It does not look specific to this branch: the last main build that ran darwin test jobs was 91391 about 17 hours ago, and the current main build 91743 has its darwin jobs sitting in waiting too, so the darwin runners appear to be down or badly backed up. Another retrigger from my side would just queue the same 6 jobs again, so I am holding off; once darwin agents are picking up jobs again, a rebuild of 91722 (or a fresh push) should go green. Everything non-darwin, including all the CSS suites, is green on this head.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Given the size of the data-table conversion (215×9 cells) and the url_to_css shim removal that swaps tag.is_internal() for flags.IS_INTERNAL, a human sign-off still makes sense — which lines up with what was already agreed in-thread.

What was checked:

  • is_compatible/is_partially_compatible table-lookup semantics are equivalent to the deleted match form (including the UNSUPPORTED sentinel and None rows); spot-checked HexAlphaColors, MediaRangeSyntax, FontFamilySystemUi, and the three None entries against the deleted arms.
  • All AtRuleParser/QualifiedRuleParser implementers enumerated: only the six edited in this PR pick up the new defaults; TopLevelRuleParser/NestedRuleParser keep their explicit impls.
  • Url::to_css in values/url.rs matches the removed shim except for the is_internal source, which the description shows is unreachable for CSS import records.
Extended reasoning...

Overview

Twelve files in src/css/ plus one test file. The bulk of the diff (-5.2k lines) is compat.rs, where a generated per-feature match of browser-version checks is replaced by a static MIN_VERSIONS: [Option<MinVersions>; FEATURE_COUNT] table indexed by Feature as usize, with generic is_compatible/is_partially_compatible functions that walk the row via a check! macro. build-prefixes.js is updated to emit the table form (plus webview_ios: null and the fontSizeXxxLarge casing fix). css_parser.rs gains default reject-all bodies on the AtRuleParser/QualifiedRuleParser traits, and six impl sites drop their identical hand-written reject-all bodies. custom.rs deletes an inlined url_to_css shim in favor of the shared Url::to_css. font.rs follows the FontSizeXXXLarge → FontSizeXxxLarge rename.

Security risks

None identified. This is a build-time CSS lowering data table and parser-trait boilerplate; no untrusted-input parsing, auth, crypto, or filesystem paths change. The generator script is developer-run, not shipped.

Level of scrutiny

Higher than a typical mechanical refactor because a single wrong cell in the 215×9 table would silently mis-lower CSS for one browser target with no compile error. The equivalence claim is well-supported (a re-runnable verifier script comparing every cell against main's match form is posted in-thread, and boundary tests for three spread features are added), but the sheer volume of data warrants a maintainer glance rather than a bot approval. The url_to_css shim removal also swaps which ImportRecord field gates the internal-URL branch — argued to be a no-op for CSS records, but that argument depends on no bundler path ever setting either field on a CSS import record.

Other factors

I verified the new is_partially_compatible is algebraically identical to the old probe-one-browser-at-a-time form. I enumerated every AtRuleParser/QualifiedRuleParser implementer via grep and confirmed only the six edited files rely on the new defaults; the top-level and nested rule parsers retain explicit method bodies and are unaffected. PageRuleParser correctly keeps its real parse_prelude/parse_block and only drops rule_without_block. New tests pin the three probed table rows and the at-rule rejection error message. CI on the previous head was green on 190/196 lanes with the remaining 6 being darwin runner-availability failures, not test failures. The maintainer driving this has already asked for it to be made mergeable, so deferring rather than staying silent gives them the no-bugs-found signal.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Good prompt on the is_internal point; my earlier "nothing in the bundler writes these" was too strong, so I went and checked it properly.

Repo-wide, IS_INTERNAL is only ever set by the JS parser (js_parser/p.rs, parse_entry.rs), never on CSS records. import_record.tag does get written in the bundler at two sites that CSS records can reach (bundle_v2.rs in resolve_import_records, which runs for every loader): bun:wrap sets Runtime, and under --target bun a hardcoded builtin alias sets the alias's tag (Builtin). So the old shim's tag.is_internal() could in principle see a non-default tag on a CSS url().

Why it still cannot diverge from flags.IS_INTERNAL: Tag::is_internal() is tag >= Runtime, and Bun/Builtin sort below Runtime, so the builtin-alias case returned false under the old shim as well; the only CSS-reachable writer of a >= Runtime tag is bun:wrap, and that record is rejected at resolve time (Cannot import a ".js" file into a CSS file) before anything is printed.

Confirmed empirically rather than just by reading: bundled .a{--x:url(X)} with minify for X in bun:wrap, node:fs, bun:sqlite, bun:ffi, fs, under both targets, on this branch and on the current release build (old shim). Output and diagnostics are identical for all ten combinations, including node:fs under --target bun, which is the one that actually exercises the tag write (it prints as url(fs) on both). So the shim removal is a no-op on every reachable path, not just on the default-tag one.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Build 91770 on your merge (c201cfd) finished 187/190. Two observations:

  1. No darwin test jobs were created on this build at all (as opposed to the earlier builds where they were created and then canceled unrun), so the hosted-queue change did not produce darwin test coverage here; you may want to check whether that is the intended interim behavior or whether the step is being skipped.

  2. The 3 failed jobs are all shards of the one debian 13 x64-asan lane: setInterval leak test (30s timeout), fs.test.ts readdirSync x100 (10s timeout), html-rewriter-leak (15s timeout, plus a JSON EOF from the killed child), and an Elysia vendor stream.test.ts cancel race. Three timeouts at their ceilings in unrelated subsystems on a single ASAN agent in one run reads as that agent being slow; none of the four files shows up in the last several main builds, and none of them is anywhere near the CSS diff. I have not re-pushed since that would only re-roll the same lane.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug hunt found no issues. Given the size of the 215×9 compat data table and the tag.is_internal() → flags.IS_INTERNAL change in the url_to_css path, a human sign-off still makes sense (as already noted in the thread).

What was reviewed:

  • is_compatible/is_partially_compatible table-lookup semantics vs. the old match arms — equivalent for supported, unsupported (.is_some()), and return false (→ None) cases; spot-checked HexAlphaColors and several list-style rows against the deleted arms.
  • The new AtRuleParser/QualifiedRuleParser defaults produce the same BasicParseErrorKind variants as every deleted impl; PageRuleParser correctly keeps its custom parse_prelude/parse_block and only drops rule_without_block.
  • Url::to_css in values/url.rs matches the deleted shim except for the is_internal predicate, which the thread's analysis covers.
Extended reasoning...

Overview

Twelve files in src/css/: (1) compat.rs replaces a ~5.2k-line generated match with a 215-row MIN_VERSIONS static table indexed by Feature as usize, plus generic is_compatible/is_partially_compatible; (2) css_parser.rs gives AtRuleParser/QualifiedRuleParser default reject-all method bodies and six rule parsers drop their copy-pasted impls; (3) properties/custom.rs deletes the inlined url_to_css shim in favor of the shared Url::to_css; (4) build-prefixes.js emits the table form and adds webview_ios: null / the fontSizeXxxLarge key; (5) Feature::FontSizeXXXLarge → FontSizeXxxLarge with the one call site in font.rs updated; (6) two new test blocks in css.test.ts.

Security risks

None. No untrusted-input parsing, auth, crypto, or filesystem paths are touched. The compat table only influences which CSS syntax variants are emitted for given browser targets.

Level of scrutiny

Medium-high. The line count is dominated by generated-data reshaping, and the trait-default dedup is mechanical, but two aspects need human judgement: (a) the 1935-cell data table drives user-visible CSS lowering — a single wrong cell silently changes output for some browser target, and while the posted verify_compat_table.js checker is convincing, that's exactly what a maintainer should confirm rather than an auto-approval; (b) the url_to_css shim removal changes the is_internal predicate from tag.is_internal() to flags.IS_INTERNAL, argued equivalent for CSS records via a chain of reasoning about where tags/flags are written in the bundler — well-argued and empirically tested in the thread, but subtle enough to warrant maintainer sign-off.

Other factors

The new is_compatible logic is a straightforward encoding of the old per-arm shape (version < min → false; .is_some() on unsupported → false via UNSUPPORTED sentinel; empty arm → None row → false), and is_partially_compatible is now a direct table check equivalent to the old probe-one-browser-at-a-time loop. FEATURE_COUNT = last_variant as usize + 1 ties table length to the enum at compile time, and the generator emits both from the same sorted list so ordering can't drift. The trait defaults use the same BasicParseErrorKind::at_rule_invalid(name) call form that declaration.rs already used on main. CI is green on all lanes that ran (darwin fleet offline is infrastructure, not this diff), css.test.ts and test/bundler/css/ pass, and clippy is clean. alii is actively reviewing; the thread already flagged that the table size warrants human sign-off, which I agree with.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Correction to my earlier note about darwin jobs being absent: that is by design, not a skipped step. #37354 (which your merge pulled in) only schedules darwin tests when darwinTestsEnabled() holds, i.e. on main, on manual builds, or when the commit subject contains [macos tests], and even then only the aarch64 latest lane can run on the hosted queue. PR pushes get no darwin test jobs while the fleet is offline, which is what 91770 and 91823 show (190 jobs each, all non-darwin), so darwin is not a blocker for this PR under the current config.

If you want darwin coverage on this diff before merging anyway, say so and I will push an empty commit with [macos tests] in the subject to opt this branch in; otherwise 91823 is at 169/190 passed with 0 failures and the rest still running.

# Conflicts:
#	src/css/build-prefixes.js
#	src/css/compat.rs
@robobun robobun changed the title Convert generated css compat table to a data table css: default the at-rule and qualified-rule parser traits, drop the url_to_css shim Aug 21, 2026
@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Status change worth a decision from you: #39770 landed the compat.rs table conversion on main (a [[u32; 9]; 215] emitted by build-prefixes.js), which was the headline of this PR. Merging main in, I took main's compat.rs and generator and dropped the parts that only existed to go with them (the FontSizeXxxLarge rename; the webview_ios generator mapping is also gone, it is a one-line robustness fix for newer BCD data that can go separately if anyone regenerates). Retitled and rewrote the description to match.

What is left is the parser dedup: the AtRuleParser/QualifiedRuleParser defaults plus the six deleted copy-paste impls, and the url_to_css shim removal. Net +89/-305 over 9 files, mergeable at f52c5be, cargo check/clippy clean, css.test.ts 1193 pass and test/bundler/css/ 169 pass on the merged head. The two bot reviews on the thread had already verified these specific pieces (deleted bodies match the defaults; shim matches Url::to_css modulo the is_internal predicate analysed above). One small bonus: the three compat boundary tests from this PR now run against the #39770 table and pass, so they cross-check that table and I kept them.

So per your earlier instruction: it is mergeable as a small dedup, or if you consider the remaining ~300 lines not worth a separate landing now that the table is done, close it and nothing is lost. Your call; I will not push again unless you want something changed.

Comment thread test/js/bun/css/css.test.ts

This branch has not been deployed

No deployments
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