Skip to content

Fix string boundaries and bound layer() recovery - #58

Open
Kristofer Baxter (kristofer-baxter) wants to merge 8 commits into
microsoft:mainfrom
kristofer-baxter:fix/prelude-recovery
Open

Kristofer Baxter (kristofer-baxter) wants to merge 8 commits into
microsoft:mainfrom
kristofer-baxter:fix/prelude-recovery

Conversation

@kristofer-baxter

@kristofer-baxter Kristofer Baxter (kristofer-baxter) commented Sep 1, 2026 •

Copy link
Copy Markdown

Fixes quoted-string continuations and termination, escaped identifier boundaries, and comment handling. Limits brace recovery to layer() in @import; media conditions and shared value functions keep their existing endings.

Adds regression tests for balanced blocks, escaped function names, multiline strings, form feeds, and following-rule scopes.

First of five changes split from #57. Four follow-ups are waiting on this PR: container queries; @property and @starting-style; new value functions; and selector, property and media-feature additions.

An unclosed parenthesis in an at-rule prelude or a function call leaks its
scope to the end of the stylesheet. `@media (min-width: 40em{` scopes every
remaining line of the file as `meta.at-rule.media.header.css`, so the rest
of the stylesheet stops being highlighted as CSS. A single missing
character while typing takes the whole file with it.

The `end` pattern of each affected region gains one alternative: bail out
at a `{`. In these regions a brace cannot be part of the construct, so the
test is a single lookahead that reads one character. The closing
parenthesis moves to capture group 1 so that the bail-out, which consumes
nothing, does not claim the punctuation scope.

Applied at 16 sites: `@supports` conditions, media features, `@document`
argument functions, `layer()` in `@import`, `calc()`, the gradient, shape,
timing-function, transform and misc value functions, `url()`, the colour
functions, and the functional pseudo-classes.

The `@media` condition is the one place a brace can be legal, because
`<general-enclosed>` is `( <any-value>? )` and `<any-value>` admits a
balanced curly block. There the bail is narrowed to a `{` that is not
closed before the next brace, so `@media (a: {b}) {` still parses while the
unterminated form still recovers. The scan it needs is bounded by the
distance to that brace rather than by the length of the line.

Two regions are deliberately left alone. A `var()` fallback and a custom
function argument are declaration values, and `<declaration-value>` admits
a balanced curly block that legally spans lines, as in `--x: --foo({ ... })`.
The legal and the malformed forms are indistinguishable within one line, so
recovering there would cost legal CSS. Both are commented in place.

A string in an `@media` or `@supports` condition is now parsed by a
condition-local copy of the string rule rather than the shared one. Without
it, a legal brace inside a general-enclosed string, `@media (a: "x{")`,
opens the body early. The copy is local because the shared newline-escape
rule is used by every string in CSS; changing it altered tokenization in 12
of 15 measured contexts.

Verification: the 217 existing tests pass unchanged, and every character of
1.02M of Bootstrap, Bulma and normalize.css keeps the scope stack it has on
`main`. A broken-input matrix covers all ten regions that can hold an
unclosed parenthesis.

The guard costs nothing on well-formed CSS: `@media (min-width: 40em) {`
followed by 5000 rules takes 260ms here and 277ms on `main`, which is
within the noise between runs. On a file whose prelude is unterminated the
cost does change, because this grammar highlights the rest of the file
where `main` does not: 251ms against 26ms. That is the price of the fix. It
is linear in the size of the file, and it is the same work that
highlighting a well-formed file of that size already costs.
Six of the guards this branch adds could be removed without any test
failing, and two of the deliberate exemptions could be guarded without
any test failing. The existing recovery test only rejected scopes
containing `header`, `meta.function` or `scope.limit`, and a leaked
functional pseudo-class region is still `meta.selector.css`, so it
passed either way.

Pin the exact scopes instead, and cover:

- recovery for `:dir()`, `:lang()`, the `:is()` family and
  `:nth-of-type()`, none of which had a distinguishing test
- recovery for a transform function in an at-rule condition
- both sides of the narrowed general-enclosed bail-out, so that
  `@media (a: {b}) {` stays legal and `@media (a: {b {` recovers
- the declaration-value exemption, so that adding a guard to
  `attr()`/`if()`/`style()`/`cycle()` fails
- a url token containing a brace, in `url()` and `url-prefix()`

Also replace the `of` clause assertion, which checked for the absence
of a scope name no rule can produce and so passed on main as well,
with one that pins the tokens.
@kristofer-baxter

Kristofer Baxter (kristofer-baxter) commented Sep 1, 2026 •

Copy link
Copy Markdown
Author

Four follow-ups are staged on the fork, waiting on this PR:

  1. Container queries: @container and container query units.
  2. @property and @starting-style.
  3. New value functions.
  4. Selector, property and media-feature additions.

They will be opened in this order, each rebased onto main after the preceding PR lands so its diff contains only that change.

@romainmenke

Copy link
Copy Markdown
Contributor

An unclosed parenthesis in an at-rule prelude or a function call leaks its

Can you describe what problem you are trying to solve?

This change would make the syntax highlighter work considerable different from how CSS works in browsers, as you can see in this codepen: https://codepen.io/romainmenke/pen/KwWdMLQ

It might give CSS authors the idea that there is an error recovery mechanism that doesn't exist. They might for example miss a single malformed block at the start of a stylesheet and be confused as to why it isn't working in a browser.

@kristofer-baxter

Kristofer Baxter (kristofer-baxter) commented Sep 1, 2026 •

Copy link
Copy Markdown
Author

If I am not mistaken, main already recovers from five of the thirteen malformed constructs this PR touches, and leaks on the other eight. The five are all functional pseudo-classes. Which side a construct falls on looks incidental rather than chosen.

Both of these are malformed, and both are discarded by the browser. This is main painting them, in Dark+:

main paints two equally malformed constructs differently

The eight are what I set out to fix, six of them in this PR. One unclosed parenthesis takes the rest of the file with it. Selectors, values and strings below the error lose their scopes, and a word that happens to be a media feature name, color for instance, gets painted as one. While you are typing that state is common, and losing the rest of the file to it seemed worth fixing.

the same source under main and under this PR

Your objection holds up. I put .after { color: red; } after each of the thirteen constructs, parsed them with Chromium's own CSS parser, and asked whether that rule survives as a top level rule:

region browser main this PR
@media (min-width: 40em{ discards rest leaks recovers
@supports (display: grid{ discards rest leaks leaks
@document url-prefix(x{ discards rest leaks leaks
@import url(a.css) layer(x{ discards rest leaks recovers
a { width: calc(1px{ discards rest leaks recovers
a { color: rgb(0 0 0{ discards rest leaks recovers
a { background: linear-gradient(red{ discards rest leaks recovers
@media (x: translate(1px{ discards rest leaks recovers
a:is(.b{ discards rest recovers recovers
a:dir(ltr{ discards rest recovers recovers
a:lang(en{ discards rest recovers recovers
a:nth-child(2n{ discards rest recovers recovers
a:nth-of-type(2{ discards rest recovers recovers

Thirteen of thirteen discard everything that follows. That includes the unterminated string cases this PR also changes, :lang('en among them.

Six of the thirteen leak on main and recover here. In those six the highlighting implies an error recovery mechanism that browsers do not have.

That is true of main in five places already, so the grammar has never mirrored the parser here. The choice is which direction to be consistent in, not whether to imply recovery at all.

I picked recovery because of the editing case above. The other direction is available: drop the bail-outs here and make those five pseudo-classes leak as well, so that a malformed construct always signals that everything below it is dead. That is a change to scopes that exist today, which is why I did not reach for it first. Say which you prefer and I will send that instead.

The four that follow add scopes for @container, @property, @starting-style, new value functions and new selectors, which stands on its own. They are not clean of this question though: each one applies the same guard to the new regions it introduces, three in the first, then two, one and five. Whichever direction you pick, I will match it there and rebase them onto main directly rather than onto this branch.

@romainmenke

Copy link
Copy Markdown
Contributor

That is true of main in five places already, so the grammar has never
mirrored the parser here. The choice is which direction to be consistent in,
not whether to imply recovery at all.

I personally prefer consistency with the spec and browsers.

Deviating from the spec has two downsides:

  • it signals to CSS authors that there is some error recovery mechanic, while in reality this isn't true
  • it increases the maintenance burden as any of these constructs might support inner blocks at some point in the future

I think that doing this kind of error recovery to aid in editing is self defeating as either:

  • the author is still typing and the malformed syntax will be fixed in a few keystrokes
  • the author made an actual mistake and the syntax highlighting is a useful signal to catch that mistake

Ensuring correct highlighting for any CSS that follows after a partial entry (author still typing, midway through a file) doesn't seem feasible to me.


Screenshot 2026-09-01 at 05 43 53

Interestingly I couldn't replicate the cases you describe with selectors.
It seems that these don't (fully?) recover.


But I would also like to hear from other maintainers :)

@kristofer-baxter

Copy link
Copy Markdown
Author

I am happy to follow whatever the maintainers settle on. If that is spec alignment, I will close this and send a smaller PR that makes the leaking uniform, so a malformed construct always ends highlighting for everything after it, including the pseudo-class cases that half return today.

The four PRs behind this one add scopes for @container, @property, @starting-style, new value functions and new selectors, which stands on its own. Each applies the same guard to the regions it introduces. I will take that out and rebase them onto main directly, so the direction question does not travel with them.

Waiting to hear from the other maintainers sounds right to me. I will hold off on changes until then.

@alexdima

Alexandru Dima (alexdima) commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

👋 VS Code maintainer here. I haven’t been directly involved in maintaining this grammar so far, but my team has, and I’d like to help unblock this.

Thanks both for the thoughtful investigation here. I understand the concern about differing from browser error recovery. However, editor-facing parsers and tokenizers have somewhat different requirements: while someone is typing, the document will routinely be temporarily malformed, and we generally try to recover so that one incomplete construct does not degrade the editing experience for the remainder of the file. We follow this principle in several other parts of VS Code, including TypeScript parsing and bracket-pair colorization.

Syntax highlighting is also not a guarantee that the source is valid or will be accepted by a browser. Correctness is better communicated through diagnostics and browser behavior than by allowing an incomplete construct to cause the rest of the file to lose meaningful highlighting.

This PR appears to be quite careful about that trade-off: it preserves the existing behavior for valid CSS, exempts cases where recovery could conflict with legal syntax, adds tests for those boundaries, and makes the behavior more consistent with recovery that already happens on main.

Given that this package exists to provide CSS support in VS Code, the editing experience should take precedence here. The change is careful, well tested, and I believe this recovery behavior is the right trade-off for an editor.

I’m going to approve and merge this PR once the required checks are green. Thanks Kristofer Baxter (@kristofer-baxter) for the thorough implementation, and Romain Menke (@romainmenke) for pushing on the browser semantics -- the discussion helped clarify the trade-off and the boundaries of the change.

@alexdima

Copy link
Copy Markdown
Member

Thanks everyone. I did some additional verification before merging and found valid CSS cases that change my conclusion about this particular implementation.

The brace recovery in media conditions misidentifies braces inside valid <general-enclosed> values, including nested and multiline blocks and braces inside strings. The shared function recovery also regresses valid custom-property values such as:

a {
  --x: calc({foo});
  --y: rgb({foo});
  --z: var(--fallback, calc({foo}));
}

Exempting only var(), custom functions, or other declaration-value-taking functions is not sufficient, because their arguments can contain nested recognized functions that still use the recovering rule.

I still believe the product principle discussed above holds: syntax highlighting is an editing aid rather than a browser-validity signal, and recovery from temporary malformed states is desirable where it can be done safely. However, preserving valid and forward-compatible CSS has to take precedence, and the current shared brace guard does not meet that requirement.

My recommendation is therefore to narrow this PR:

  • remove brace recovery from media and other general-enclosed condition contexts;
  • remove it from shared value-function and nested-function rules;
  • retain only independently safe recovery where { cannot be legal, such as layer() and applicable selector/at-rule contexts;
  • retain the independent string-handling fixes.

This means accepting that the motivating @media (min-width: 40em{ case remains unfixed, and that most of the original recovery matrix will continue to leak. Given the ambiguity between an at-rule body opener and a balanced block inside <general-enclosed>, I do not think that case can be fixed reliably in a line-oriented TextMate grammar.

Ordinary-property function recovery may still be possible by separating strict property-value contexts from unrestricted custom-property and declaration-value contexts, but that is a larger architectural change and should be considered separately rather than added to this PR.

Apologies for initially moving too quickly toward merging. The additional regression cases materially change the implementation assessment. If narrowing the PR leaves too little of the original change, I’m also fine with closing it and extracting the safe fixes into a smaller follow-up.

@romainmenke

Romain Menke (romainmenke) commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

I'd say we first take this to an issue.
I fully understand the intent behind this change and applaud trying to create a better editing experience.

It is much simpler to have syntax highlighting that actually matches the CSS spec.
Any deviation from that spec is a bug that should be fixed.

If we deviate from it with an error recovery mechanism that isn't backed by the spec and isn't documented anywhere we will make harder to maintain this package.

  • what if there is a bug in this mechanism? how does one fix that?
  • what if new constructs are added (new at-rules, ...) how would contributors know to add this error recovery and if they did so correctly?
  • what if there is a conflict between a future (and unknowable) spec change and this mechanism, do we then remove it, invent a new mechanism, ...?

syntax highlighting is an editing aid

Signaling broken syntax is aiding the editor ;)

Keep balanced blocks in media conditions and shared value functions. Retain the string fixes and recovery in selector and layer arguments.

Replace the broad recovery matrix with tests for valid nested and multiline blocks. Remove unnecessary rule splits and document where recovery applies.
@kristofer-baxter Kristofer Baxter (kristofer-baxter) changed the title Recover from unterminated at-rule preludes and function calls Limit brace recovery to selectors and layer() Sep 18, 2026
Add the exact custom-property example from the review. Cover balanced blocks in supports conditions, recognized functions in media conditions, and functions nested in var(), custom-function, attr() and if() arguments.

Check continued-string scopes and preserve the decision not to recover shared value functions at a brace.
Keep brace recovery only in layer(). Restore selector endings to preserve comments, forgiving selector lists and general-enclosed supports conditions.

Handle newlines after hexadecimal escapes and continued language strings. Add regressions for the adversarial review findings and remove the README policy section for separate discussion.
@kristofer-baxter Kristofer Baxter (kristofer-baxter) changed the title Limit brace recovery to selectors and layer() Fix condition strings and bound layer() recovery Sep 18, 2026
Use shared string escape handling so hexadecimal-newline escapes and backslash continuations stay inside strings reached through nested functions. Keep unclosed-string diagnostics for unescaped newlines.

Protect escaped quotes in media identifiers and unquoted function arguments. Recognize comments and escapes in language ranges without interpreting escaped slashes as comment openers.

Keep comment openers out of arithmetic operators and miscellaneous argument matches so quotes inside comments cannot open strings.

Add regressions for both quote styles, escape parity, line endings, nested functions, comments and malformed strings. Keep brace recovery limited to layer().
@kristofer-baxter Kristofer Baxter (kristofer-baxter) changed the title Fix condition strings and bound layer() recovery Fix string boundaries and bound layer() recovery Sep 18, 2026
Remove the continuation region from non-string escapes so a backslash at the end of an unquoted token cannot swallow subsequent declarations. Keep quoted-string continuations and layer-only brace recovery unchanged.

Add regressions for nested functions, forgiving selectors, conditions, URL and document arguments, backslash parity, line endings, blank lines and EOF.
Keep escaped argument tokens together so identifier suffixes cannot start recognized functions. Leave unescaped block openers to the existing balanced-block rule, including after hexadecimal escape whitespace.

End shared and language strings at raw form feeds while preserving escaped form feeds and hexadecimal continuation. Consume complete escapes before identifying unclosed strings.

Add regressions for each review finding and controls for real function boundaries, escaped braces and valid string continuations. Keep brace recovery limited to layer().
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.

4 participants