Skip to content

Expand modern CSS grammar coverage - #57

Closed
Kristofer Baxter (kristofer-baxter) wants to merge 1 commit into
microsoft:mainfrom
kristofer-baxter:feature/css-syntax-highlighting-coverage
Closed

Expand modern CSS grammar coverage#57
Kristofer Baxter (kristofer-baxter) wants to merge 1 commit into
microsoft:mainfrom
kristofer-baxter:feature/css-syntax-highlighting-coverage

Conversation

@kristofer-baxter

Copy link
Copy Markdown

Expand modern CSS grammar coverage

Coverage

This broad update adds modern at-rule, function, property, selector, and media-query coverage. The main review risk is recovery around the parenthesized regions, which gets most of the detail below.

The grammar diff adds dedicated coverage for:

  • @container size, named, style(), and scroll-state() queries, including logical operators and size or scroll-state features
  • @scope start and limit preludes, @starting-style, and @property
  • color functions, env(), anchor(), anchor-size(), and additional math and value functions
  • selector-list functional pseudo-classes, :nth-child(... of ...), :state(), and argument-specific functional pseudo-elements
  • current interaction, preference, viewport-segment, dynamic-range, and related media features and values
  • anchor positioning, scroll and view timelines, view transitions, containment, text, and layout properties

@property accepts digit-prefixed custom-property names. Ordinary identifiers added by this change use the stricter first-character pattern.

Everything lives in grammars/css.cson; the specs exercise complete scope arrays with deepStrictEqual.

Recovery guard

Dedicated parenthesized regions improve coverage, but an unclosed region can retain its scopes beyond the prelude. To preserve recovery, all 36 reachable regions end at { only when the rest of the line does not close the parenthesis.

A plain [^)]* is not enough. A ) inside a comment, string or escape closes nothing, and counting it suppressed recovery that origin/main performed: @container (width > 1px{ /* ) */ stayed open. Trailing comments containing parentheses are ordinary in real stylesheets.

Instead, the lexical bail-out skips complete and unterminated comments, quoted strings, and escapes while looking for a real closing parenthesis. Escapes include incomplete ones. If a line ends in an odd number of backslashes, bare or inside an unterminated string, no complete-escape alternative matches; the inherited newline-escape rule would otherwise hold the prelude open across the line break.

Every reachable nested region needs the guard because an outer end cannot be evaluated while an inner rule is active.

A recovery oracle checks every prefix of 24 representative preludes, with { and { also appended: 2,412 cases total, 1,715 of them inside the guard contract. Against origin/main, this branch has 186 improvements, 42 regressions, and 147 residual contract violations also present on main. All 42 regressions are @scope ( inputs containing an unclosed [.

A differential over legal snippets covering every guarded construct, plus Bootstrap and 176k lines of Tailwind output, found no genuine losses.

Known limitation

@scope ([data-x{ leaks, and an unterminated url(a{ inside a prelude stays open until a later ) or guard-eligible {.

That first one is main's existing attribute-selector leak: a[data-x{ also leaks there. New @scope coverage exposes it in one additional context. Guarding the attribute selector was tried and reverted, because it broke a legal multi-line attribute selector covered by a pre-existing passing test.

The url() case has the same shape. url()'s argument pattern, [^'")\s]+, is byte-identical to main's and consumes the { before the region's end can be tested. That means a { background: url(x{ leaks on main too, and the region stays open across however many lines precede the next ) or guard-eligible {, not one.

Tightening the class to exclude { was also tried and measured. It splits the legal value url(https://x/a{b}) into three tokens and drops the brace out of variable.parameter.url.css. An unquoted url-token may legally contain {, so the pattern is left alone.

Both leaks are inherited rather than introduced. The new @scope and @container preludes reuse existing selector and function rules, exposing their pre-existing leaks in contexts where main never applied them.

On balance the branch removes four pre-existing leak cases while inheriting these. It does not solve every malformed-input leak, and makes no claim to.

Two narrower cases remain. A closing parenthesis inside a nested parenthesised construct, as in @container (width > 1px{ url("x)"), still suppresses recovery because balanced-parenthesis tracking is beyond a line-local lookahead. Separately, @document's url-prefix(), domain() and regexp() do not recover with or without the guard: their argument patterns consume the { before the region's end is tested, and main behaves identically.

There is also an @container-only case. <general-enclosed> accepts <any-value>, so @container (future: "a{b") is legal and the brace inside the string is not a body brace. The bail-out inspects only the text from the candidate brace onwards; with no string rule active, it reads the closing quote as an opening one, treats the real ) as shielded, and opens the body early.

@media and @supports are fixed by giving their condition regions a #condition-string rule, which keeps the enclosing end from being evaluated inside a string. A new test covers both. #condition-string is a condition-local copy of #string rather than #string itself, differing only in that its newline escape ends at ^ rather than the shared rule's ^(?<!\G).

The shared form cannot match while the condition is still open, so a legal @media (future: "a{\ line continuation swallowed the rest of the stylesheet. The local copy fixes that while leaving string tokenization in every other context byte-for-byte as it is on main, checked across 15 line-continuation constructs. @supports routes the same string through the shared property-value context, where main swallows it too; that case is left at parity rather than fixed.

The same fix was tried for @container and reverted. There, an unterminated string would shield a malformed { from the bail-out and reintroduce 68 leak cases that recover today. @container therefore mis-tokenizes this legal input exactly as origin/main does. It is not a differential regression, but it is not fixed either.

One last case is specific to the new style() coverage. CSS Conditional Rules 5 defines a style query's <style-feature-value> as a <declaration-value>, so a balanced curly block may legally span lines inside it. The line-local guard treats that opening { as the container body when the ) is on a later line, so @container style(--x: { followed by a block is mis-tokenized.

This matches origin/main's generic at-rule header, so it is not a differential regression, though the dedicated style() region does not cover it. Removing the guard from style() would trade this edge case for all recovery on malformed style queries. The guard was kept, but this is the judgment call with the least confidence behind it, and pushback is welcome.

Scope naming

color-mix() moves from meta.function.misc.css to meta.function.color.css, the scope main already gives every other color function, including rgb(). The function name keeps support.function.misc.css.

A theme targeting the meta.function.misc.css container will no longer match color-mix() or its contents. The reclassification looks like the better fit, but maintainers should push back if preserving the old container matters more.

var() and custom function trade-off

var() and custom function calls are not guarded. Both are declaration values, and <declaration-value> admits a balanced curly block that legally spans lines, as in var(--fb, { ... }) and --x: --foo({ ... });. Applying the guard truncated those legal values. origin/main scopes them correctly, so the guard was a regression there.

The legal --foo({ and malformed --foo(a{ are lexically identical within a single line; no line-local test can separate them. Legal CSS wins. main does not recover var(--x{ either, and mutation-verified tests pin both legal forms.

Related work and credit

This work overlaps several open pull requests. Credit belongs to their authors, named below.

Verification

  • 3 files changed, 2,120 insertions, 90 deletions, in one commit

  • 302 tests total: 296 passing, 0 failing, 6 skipped under Node 20.18.0 after a clean npm ci

  • 85 it( blocks added

  • origin/main also has 6 skipped tests, so this branch adds no skips

  • guard-site mutation campaign, at two strengths, each applied to one of the 36 sites at a time:

    • deleting the bail-out alternative so the region can never recover: 35 of 36 caught by a failing test. The one survivor, @document's argument functions, cannot recover with or without the guard, as on main.
    • weakening the guard body to the naive [^)]*$ it replaces: only 10 of 36 caught. The 36 guard bodies are byte-identical, so this says which regions have a test exercising a shielded ), not that there are 26 differing implementations. It also means most sites would not catch a regression to the naive form on their own. The distinction is exercised directly at the sites listed above.

    These totals come from a harness that is not part of this branch, so they are not reproducible from the repository as submitted.

  • malformed-input comparison: main leaks on 8 of 10 sampled cases; this branch fixes 4 and introduces 0

  • backward compatibility, checked mechanically against origin/main: no CSS identifier is lost (244 gained), no scope name is removed (32 added), no #include reference dangles or goes unreferenced, and all 311 begin/end/match patterns compile with every capture index in range

  • scope-loss differential over real stylesheets: across 176,011 lines of Tailwind 2.2.19 output, no character loses a scope main assigned. Bootstrap 5.3.3 differs in five constructs, all gains or corrections: prefers-reduced-motion (32), reduce (31) and no-preference (1) gain media-feature and value scopes instead of plain header text, and ::file-selector-button (6) and :placeholder-shown (4) are corrected from entity.name.tag.custom.css to a pseudo-element and a pseudo-class

  • @container and @scope preludes now carry meta.at-rule.container.header.css and meta.at-rule.scope.header.css respectively, rather than the generic meta.at-rule.header.css, matching the naming main already uses for @media and @supports

  • no catastrophic backtracking: six adversarial inputs, including 200 nested calc( and a 5,000-character prelude, tokenize in 244 ms or less

Performance

Both grammars were loaded in one process and tokenized alternately, taking min-of-7 per grammar, over six full runs. The machine started idle at a load average of 3.2, but the benchmark drives its own load up over the sequence: sampled per-run load ranged from 3.8 to 13.2. Every run is reported, including the ugly ones.

Below are the median wall time per grammar, the median of the six paired per-run percentage deltas, and the full range of those paired deltas.

workload origin/main branch median delta range across 6 runs
Bootstrap 5.3.3 (12k lines) 547 ms 581 ms +4.8% +3.1% to +6.6%
Tailwind 2.2.19 (176k lines) 9,444 ms 9,914 ms +5.3% +4.2% to +7.1%
container-query corpus x1000 4,236 ms 1,484 ms -65.2% -65.4% to -64.1%
20 KB single line, many ; 527 ms 533 ms +0.5% -0.7% to +2.3%
20 KB single line, many { 3,338 ms 3,544 ms +5.5% -2.9% to +16.8%
minified, 52 KB single line 494 ms 492 ms +0.1% -0.5% to +2.3%
scroll-state, 20 KB unclosed 2 ms 16 ms +582% +575% to +605%

Four of the seven workloads have the same sign in all six runs and can be read as measured effects: Bootstrap +4.8%, Tailwind +5.3%, the container-query corpus -65.2%, and the scroll-state case +582%. Neither corpus contains a single @container rule or container property, so the first two measure the cost on ordinary CSS, at roughly 5%.

Three ranges cross zero and resolve nothing. The brace-heavy synthetic line is the noisiest workload measured; its +5.5% median should not be read as a cost.

The scroll-state figure is a large ratio on a small absolute base: 2 ms to 16 ms on a 20 KB file consisting of a single unterminated scroll-state() query. It reflects repeated guard evaluation against a line that never closes.

Be aware that the synthetic workloads above are generated fixtures, and they are not part of this branch. Only the Bootstrap and Tailwind rows can be reproduced from public sources.

The lexical bail-out was also compared with the simpler [^)]*$ form, holding everything else in the grammar constant, over three further interleaved runs. Every workload landed between -6.0% and +2.7%, with most deltas negative, so no workload showed the lexical form to be measurably more expensive. The roughly 5% cost on ordinary CSS comes from the added region coverage, not the guard's complexity.

Container queries come out about 3x faster because precise regions replace the generic fallback's retries.

Add grammar and regression coverage for modern at-rules, functions,
properties, selectors, and media queries. Include `@container` coverage
for the gap first identified in PR microsoft#15, and accept
digit-prefixed `@property` names consistently with PR microsoft#43.

Dedicated prelude regions end only at `)`, so an unclosed parenthesis
would run to end of file where the generic at-rule header on main
recovers at the first `{`. Guard the parenthesised regions reachable
inside a prelude so they recover at `{` only when the rest of the line
does not close the parenthesis, preserving legal balanced blocks in
declaration values. The test is lexical rather than a plain `[^)]*`,
because a `)` inside a comment, string or escape does not close
anything: without that, `@container (width > 1px{ /* ) */` stayed open
where main recovered. The test also accepts an incomplete trailing
escape, both bare and inside an unterminated string, because a line
ending in an odd number of backslashes otherwise matches no alternative
and the newline-escape rule holds the prelude open. Guarding only the
outermost regions is not enough, because an outer `end` cannot be
evaluated while an inner rule is active. Do not use `;` as a bail-out:
`<general-enclosed>` permits top-level semicolons through `<any-value>`,
and legal preludes may span lines.

Leave `var()` and custom function calls unguarded. Both are declaration
values, and `<declaration-value>` admits a balanced curly block that
legally spans lines, as in `--x: --foo({ ... });`. The legal `--foo({`
and the malformed `--foo(a{` are indistinguishable within a single line,
so recovering there would cost legal CSS that main scopes correctly.
Main does not recover an unclosed `var(` either.

Add `#condition-string` to the `@media` and `@supports` condition
regions. The bail-out only inspects text from the candidate `{` onwards,
so with no string rule active it read the closing quote of `(future:
"a{b")` as an opening one, took the real `)` for shielded, and opened
the body at the brace inside the string. `@container` keeps no string
rule, because there an unterminated string would instead shield a
malformed `{` from the bail-out; that case tokenizes as it already does
on main.

`#condition-string` is a condition-local copy of `#string` rather than
`#string` itself. It differs in one respect: its newline escape ends at
`^` instead of the shared rule's `^(?<!\G)`, which cannot match while
the condition is still open and so swallowed the rest of the stylesheet
after a legal `@media (future: "a{\` continuation. Keeping the copy
local leaves string tokenization everywhere else byte-for-byte as it is
on main.

Give the `:lang()` string rules the end-of-line fallback the shared
`#string` rule already uses, so an unterminated language range no longer
runs past its line.

Leave `animation-timeline`, `animation-range`, `animation-range-start`,
and `animation-range-end` entirely to PR microsoft#32.
@kristofer-baxter

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Microsoft"

@romainmenke

Copy link
Copy Markdown
Contributor

Hi Kristofer Baxter (@kristofer-baxter),

It seems that this PR contains three kinds of changes:

  • new feature additions
  • fixes for existing features
  • extra test coverage

Does it make sense to split it up to reduce the PR size?
Especially new features could be added in separate PR's.

@kristofer-baxter

Copy link
Copy Markdown
Author

Yes, that split makes sense. Thanks for reading through it.

Mapping your three buckets onto the diff gives this, with the caveat that the
third bucket does not survive as its own pull request:

Fixes to existing features. One change, applied uniformly at 36 sites: an
unclosed parenthesis in an at-rule prelude or a function call currently leaks
its scope to the end of the stylesheet. The end pattern gains an alternative
that bails out at a { which the construct itself did not open. This affects
@media, @supports, @document, calc(), url(), :lang() and layer()
in @import — all of it CSS that works on main today, none of it dependent
on any new feature here. It is the only part of this branch that fixes
something rather than adding something.

New features. Four at-rules (@container, @property, @scope,
@starting-style), four functions (env(), anchor(), style(),
scroll-state()), plus additions to the pseudo-element, property-name and
media-feature lists.

Test coverage. This is the part that cannot stand alone. Nearly every added
test pins behaviour that a specific grammar change introduces or repairs, so it
has to travel with that change or it fails. Only a small number characterize
existing main behaviour independently.

So the plan is five stacked pull requests:

  1. fix: prelude and function recovery (the 36 guards)
  2. feat: @container, including style() and scroll-state()
  3. feat: @property, @scope, @starting-style
  4. feat: env(), anchor(), and the math and color functions
  5. feat: selectors, property names, media features

Each lands with the tests that pin it. Roughly 300 to 600 lines apiece.

Rather than churn this repository while the shape is still in question, the
split is being staged on the fork first, so the boundaries can be checked
before anything is opened here. This pull request will stay open until that is
done, then be replaced.

One question on ordering: number 1 is independent of the other four and fixes
bugs that affect CSS people write today. Worth sending that one first on its
own, ahead of any of the features?

@romainmenke

Copy link
Copy Markdown
Contributor

One question on ordering: number 1 is independent of the other four and fixes
bugs that affect CSS people write today. Worth sending that one first on its
own, ahead of any of the features?

yes please :)

@kristofer-baxter

Copy link
Copy Markdown
Author

Romain Menke (@romainmenke) Split, as you asked. The fix is up at #58 and the four feature
branches stack on top of it:

  1. Recover from unterminated at-rule preludes and function calls #58, prelude and function recovery. Fixes existing behaviour, depends on
    nothing else.
  2. @container and container query units.
  3. @property and @starting-style.
  4. New value functions.
  5. Selector, property and media feature additions.

Each one is based on the one above it, runs its own test suite green, and does
not regress any scope on 1.02 MB of Bootstrap, Bulma and normalize.css.

Splitting this turned out not to be a packaging exercise. Gating each piece
against the one below it, per character over those three stylesheets plus a
matrix of malformed inputs, caught five things that comparing only this
branch against main had missed:

  • :nth-of-type() was left entirely unscoped in the first four layers. The
    rule split that narrowed :nth-child() landed before its replacement did.
  • One layer reverted the recovery guard on :is(), :not(), :where() and
    :matches(), so those regions swallowed the brace again.
  • The same layer reverted it on :nth-child().
  • Three layers were missing the guard on :dir(), :lang() and the color
    functions.
  • Four layers were missing the end-of-line fallback on the string inside
    :lang(), so :lang('en scoped the rest of the file as a string.

Every one of those existed in some intermediate state only. The endpoint
comparison reported zero regressions the whole time.

The description here was also wrong on two counts, both corrected in #58. The
guard is applied at 16 sites, not the 36 I claimed. Applying it uniformly made
three regions worse than they are on main today, because main recovers
those through the generic at-rule header and giving them a dedicated guarded
rule took that away. And I described the old guard as fixing quadratic
tokenizer behaviour. It never did. I measured it again while writing the split
and every variant is linear. What actually changes on a broken file is that
the grammar keeps parsing the remainder instead of swallowing it.

Closing this in favour of the five. Thanks for pushing back on the size; the
five branches are in better shape than this one was.

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