From 946c53615a158f1435c3f8ce2d6ce0801230ab56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:43:50 +0000 Subject: [PATCH] fix: format at-rule preludes with structured parsing instead of regexes format_atrule_prelude reformatted @media/@supports/@container/@layer/ @scope/@import preludes with 13 hand-written regexes applied to raw text. This missed real cases the regex whitelist didn't cover - e.g. @container STYLE(--foo: bar) never got lowercased, since "style" wasn't in the hardcoded function-name list - and any new syntax needs a new regex to work correctly. @projectwallace/css-parser (already used for everything else here) ships a real structured parser for this that format-css previously disabled (parse_atrule_preludes: false). Flip it on and add a new internal printer, used only by print_atrule, that dispatches on each prelude child's node type instead of pattern-matching text. format_atrule_prelude itself - the exported partial formatter - is untouched: same signature, same regex implementation, same tests, same README example. No public API changes. It's now also reused internally as the fallback whenever the structured parser can't represent something losslessly (an at-rule it doesn't recognize, a comment inside a prelude, or the standalone `@layer name;` statement form, which has an upstream bug that splits dotted names like `base.normalize` at the dot - see PARSER_ISSUES.md for this and several other upstream issues found and worked around while building this). Verified via differential testing against the previous implementation across ~170 hand-picked and combinatorial cases (plus the existing 259-test suite) to catch regressions the unit tests alone wouldn't. --- PARSER_ISSUES.md | 187 +++++++++++++++++++++++++ src/lib/index.ts | 320 ++++++++++++++++++++++++++++++++++++++++++- test/atrules.test.ts | 73 ++++++++++ 3 files changed, 578 insertions(+), 2 deletions(-) create mode 100644 PARSER_ISSUES.md diff --git a/PARSER_ISSUES.md b/PARSER_ISSUES.md new file mode 100644 index 0000000..48b5260 --- /dev/null +++ b/PARSER_ISSUES.md @@ -0,0 +1,187 @@ +# `@projectwallace/css-parser` at-rule prelude parser — issues found + +Found while rewriting `format_atrule_prelude`'s internals in this repo to use +`parse_atrule_prelude`/`parse_atrule_preludes: true` instead of regexes +(version `@projectwallace/css-parser@0.16.0`). format-css works around all of +these already (see the comments on `print_atrule_prelude_node` and friends in +`src/lib/index.ts`), but they're worth fixing upstream since the workarounds +mean format-css can't fully trust the structured prelude parser yet. + +Repro snippets below assume: + +```js +import { parse_atrule_prelude } from '@projectwallace/css-parser' +``` + +## 1. Dotted `@layer` names are split at the dot (high severity) + +```js +parse_atrule_prelude('layer', 'a, b.c') +// → [LayerName("a"), LayerName("b"), LayerName("c")] +// expected: [LayerName("a"), LayerName("b.c")] +``` + +`parse_layer_names` (`parse-atrule-prelude.js`) only handles bare `IDENT` +tokens; it doesn't glue together the `ident '.' ident '.' ident ...` sequence +the `` grammar requires for nested layers. The connecting `.` +tokens are silently dropped, not even represented as some other node type. + +This is a correctness bug, not just a formatting inconvenience: naively +reprinting `[LayerName("b"), LayerName("c")]` as a comma-separated list turns +one nested layer (`b.c`) into two unrelated top-level layers (`b, c`), +changing cascade order. Dotted layer names are common in real-world CSS — +this project's own README uses `base.normalize` as its headline example. + +Note `@import url(...) layer(a.b.c)` is unaffected — it goes through a +different code path (`parse_import_layer`) that captures the whole +`layer(...)` call as one raw span instead of re-tokenizing the name. + +## 2. Function calls are dropped from media-feature/feature-range values (high severity) + +```js +parse_atrule_prelude('media', '(min-width: calc(1px * 1))') +// Feature.value's children: [Dimension("1px"), Number("1")] +// "calc(", "*", and ")" are gone +``` + +```js +parse_atrule_prelude('media', '(min-width: env(safe-area-inset-top))') +// Feature.value: Identifier("safe-area-inset-top") +// "env(" and ")" are gone +``` + +`parse_feature_value`'s `parse_value_token` switch (`parse-atrule-prelude.js`) +only recognizes `IDENT`/`NUMBER`/`PERCENTAGE`/`DIMENSION`/`STRING` tokens. +`FUNCTION` tokens (`calc(`, `env(`, `var(`, `min(`, `max(`, `clamp(`, +`attr(`, ...) return `null` from `parse_value_token` and are skipped +entirely, along with everything inside their parentheses. Reconstructing a +feature's value purely from its children silently produces the wrong CSS. + +`calc()` in media/container queries is common; this needs fixing before +consumers can trust `MediaFeature.value`/`FeatureRange` children for +anything beyond a single plain number/dimension/identifier. + +## 3. Same value-drop bug inside `@supports`/`style()` declaration values (high severity) + +```js +parse_atrule_prelude('supports', '(background: linear-gradient(red, blue))') +// SupportsDeclaration → Declaration.value → Value.children: +// [Identifier("red"), Identifier("blue")] +// "linear-gradient(" and the trailing ")" are gone +``` + +Same root cause as #2 — `create_supports_declaration` builds its +`Declaration.value` via the same `parse_feature_value`. Any `@supports` +condition value containing a function call loses it. + +## 4. Inconsistent off-by-one on several nodes' own end offset / `.text` (medium severity) + +```js +let f = parse_atrule_prelude('media', '(min-width: calc(1px * 1))')[0].first_child +f.text // "(min-width: calc(1px * 1)" — missing the feature's own trailing ')' +f.end // 25, but the full input is 26 chars long + +parse_atrule_prelude('supports', '(background: linear-gradient(red, blue))')[0].value +// "background: linear-gradient(red, blue" — missing linear-gradient's ')' +``` + +`MediaFeature.text`/`.end`, `SupportsQuery.value`, and the nested +`SupportsDeclaration`/`Declaration.text` are all missing exactly one +trailing `)` in these cases — but _not_ in the simple case (`(min-width: +768px)` reports a correct, complete `.text`/`.end`). The truncation only +appears once a value containing nested parentheses is involved, suggesting +the `content_end`/`value_end` bookkeeping in `parse_media_feature` / +`parse_supports_query` gets one character short when it also has to account +for the dropped-function-token gap from #2/#3. Any consumer slicing the +source string using these offsets, or trusting `.text` to be complete, needs +to defensively re-balance parentheses first. + +## 5. `=>` tokenizes as two separate operators instead of one (low severity) + +```js +parse_atrule_prelude('media', '(width=>1000px)') +// children: [Dimension? no — ] PreludeOperator("="), PreludeOperator(">"), Dimension("1000px") +// (as two adjacent single-character operators, not one "=>" token) +``` + +The comparator scanner in `parse_feature_range` (`parse-atrule-prelude.js`) +extends a `<`/`>`/`=` into a two-character token only when the _second_ +character is also `=` — correctly handling `>=`/`<=`, but not the reverse +order, so `=>` comes through split. (It's unclear `=>` is meaningful CSS +media-feature syntax at all, but the tokenizer should presumably be +consistent regardless of operand order.) + +## 6. `@supports selector(...)` (or any function-token condition) returns nothing (medium severity) + +```js +parse_atrule_prelude('supports', 'selector([popover]:open)') +// → [] (completely empty — not even a Raw fallback) +``` + +`parse_supports_query`'s dispatch loop only handles a `LEFT_PAREN` token +(start of a parenthesized condition) or an `IDENT` token (checking for +`and`/`or`/`not`); a `FUNCTION` token like `selector(` matches neither +branch and is silently skipped, so the entire prelude parses to an empty +array — total content loss, with no way to recover even the raw text +structurally. This is the CSS Selectors-in-`@supports` feature, in active +use (`:has()`-style progressive enhancement checks). + +## 7. Leading `only`/`not` media-query prefix is consumed but never emitted as a node (medium severity) + +```js +parse_atrule_prelude('media', 'only screen') +// → [MediaQuery] where MediaQuery.text === "only screen" (correct) +// but MediaQuery.children === [MediaType("screen")] — "only" is gone +``` + +`parse_single_media_query` peeks the first token, and if it's `only`/`not`, +advances past it without creating any node — so it's present in the +enclosing `MediaQuery`'s own `.text` span but absent from its children. Any +consumer reconstructing a query purely from its children (rather than +falling back to the parent's raw `.text`) silently drops a very common +real-world prefix (`@media only screen ...`). + +## 8. `ContainerQuery`'s functional condition fields are inconsistent (low/medium severity) + +```js +parse_atrule_prelude('container', 'style(--foo: bar)')[0] +// Function.name === "style", Function.value === "--foo: bar" (both populated) + +parse_atrule_prelude('container', 'style(--foo: env(safe-area-inset-top))')[0] +// Function.name === undefined, Function.value === null +// (Function.text is still correct/complete: "style(--foo: env(safe-area-inset-top))") +``` + +`.name` and `.value` are populated in the simple case but become +`undefined`/`null` once the args contain a nested function call (interacting +with #2). `.text` was the only field that stayed reliable across both cases +in testing — worth documenting as the recommended fallback, or fixing `.name` +to always resolve consistently. + +## 9. A few `.d.ts` declared child unions don't match runtime output (low severity, TS-only) + +- `FeatureRange`'s children are typed `Dimension | Operator`, but comparison + operators inside a range are actually `PreludeOperator` (type 38) at + runtime, not the generic `Operator` (type 16) the type implies. +- `ContainerQuery`'s children are typed `Identifier | MediaFeature | +Function`, but the parser also pushes `PreludeOperator` nodes for + `and`/`or`/`not` between conditions — not part of the declared union. +- `AtrulePrelude`'s (and other container nodes') declared child unions don't + include `Identifier` or `String`, even though `parse_identifier()` (used + for `@page`/`@keyframes`/`@property`/etc.) and `parse_charset_prelude()` + produce exactly those types as top-level prelude children. + +Doesn't affect JS behavior, but misleads TypeScript consumers who pattern- +match/exhaustively-check against the declared unions. + +## 10. No comment-preservation hook for prelude parsing (low/medium severity, may be intentional) + +The main `parse()` function accepts an `on_comment` callback so callers can +recover comments that appear between top-level constructs. The at-rule +prelude parser (`AtRulePreludeParser`/`parse_atrule_prelude`) has no +equivalent — comments inside a prelude (e.g. `@media /* comment */ (min- +width: 100px) {}`) are silently discarded while tokenizing, with no way for +a caller to know they were ever there. If prelude-level comment preservation +is in scope for this package, an `on_comment`-style hook mirroring the main +parser's would let consumers reconstruct them the same way they already can +for the rest of a stylesheet. diff --git a/src/lib/index.ts b/src/lib/index.ts index 67434db..df1718f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,5 +1,6 @@ import { parse, + parse_declaration, is_function, is_dimension, is_parenthesis, @@ -20,10 +21,22 @@ import { is_declaration, is_rule, is_atrule, + is_media_query, + is_container_query, + is_media_feature, + is_feature_range, + is_supports_query, + is_prelude_operator, + is_prelude_selectorlist, + is_layer_name, type Operator, type Value, type Declaration, type Raw, + type AtrulePrelude, + type SupportsQuery, + type FeatureRange, + type MediaFeature, type NthSelector, type NthOfSelector, type PseudoClassSelector, @@ -410,6 +423,309 @@ export function format_atrule_prelude( .replaceAll(ATRULE_FN_NAME_RE, (match) => match.toLowerCase()) // lowercase function names } +const ONLY_NOT_PREFIX_RE = /^(only|not)\s/i + +/** + * Appends any closing parentheses missing from `text`. + * + * A handful of @projectwallace/css-parser's at-rule prelude nodes (e.g. + * MediaFeature.text, SupportsQuery.value) have an off-by-one end offset that + * drops the node's own trailing `)`. Re-balancing defensively here means the + * raw text stays safe to hand to `parse_declaration` or slice further, + * without needing to know exactly which node types are affected. + */ +function balance_parens(text: string): string { + let depth = 0 + for (let i = 0; i < text.length; i++) { + let code = text.charCodeAt(i) + if (code === 40) depth++ + else if (code === 41) depth-- + } + return depth > 0 ? text + CLOSE_PARENTHESES.repeat(depth) : text +} + +/** + * Extracts a media-feature value from the text after its colon, which may or + * may not still include the feature's own trailing `)` (the same off-by-one + * `balance_parens` works around, except here it can go either way instead of + * only ever being short — see PARSER_ISSUES.md). Scans for the first `)` + * that isn't matched by a `(` seen so far in this slice: that's necessarily + * the feature's own closing paren, not part of the value, since every + * legitimate nested paren pair (e.g. `calc(...)`) must already balance + * within the slice. If the text runs out first, any still-open parens (e.g. + * an unclosed nested calc()) are appended, same as `balance_parens`. + */ +function extract_balanced_value(text: string): string { + let depth = 0 + for (let i = 0; i < text.length; i++) { + let code = text.charCodeAt(i) + if (code === 40) depth++ + else if (code === 41) { + if (depth === 0) return text.slice(0, i) + depth-- + } + } + return depth > 0 ? text + CLOSE_PARENTHESES.repeat(depth) : text +} + +/** Whether `text` has a `:` outside of any parentheses — the same check + * @projectwallace/css-parser's own `find_colon_at_depth_zero` uses to decide + * whether a `@supports`/`style()` condition is a simple declaration at all. */ +function has_top_level_colon(text: string): boolean { + let depth = 0 + for (let i = 0; i < text.length; i++) { + let code = text.charCodeAt(i) + if (code === 40) depth++ + else if (code === 41) depth-- + else if (code === 58 && depth === 0) return true + } + return false +} + +/** + * Prints a declaration-shaped at-rule condition (`prop: value`), e.g. the + * inside of `@supports (display: grid)` or `@container style(--foo: bar)`. + * + * Re-parses the raw text with the full declaration parser and reuses + * `format_declaration`, rather than trusting the prelude parser's own value + * nodes for it: those silently drop function calls (`calc()`, `env()`, ...) + * from condition values (a parser bug — see PARSER_ISSUES.md). Falls back to + * the raw text for conditions that aren't a simple declaration at all, e.g. + * `selector(:hover)`. + */ +function print_condition(raw: string, minify: boolean): string { + let balanced = balance_parens(raw) + // A nested boolean group, e.g. `(display: grid) and (display: flex)` + // (from `@supports ((display: grid) and (display: flex))`), has no + // top-level colon of its own — parse_declaration on it doesn't reliably + // come back with an empty property to signal "not a declaration" (it can + // naively split on the first, nested colon instead), so that check alone + // isn't a safe enough guard. Skip straight to the regex fallback, which + // still normalizes colon spacing inside the nested groups (and, via its + // own selector()-exception, correctly leaves selector(:hover) alone). + if (!has_top_level_colon(balanced)) { + return format_atrule_prelude(balanced, { minify }) + } + let declaration = parse_declaration(balanced) + if (declaration.property === EMPTY_STRING) { + return balanced + } + return format_declaration(declaration, { minify }) +} + +/** Prints a two-sided (`200px < width < 1000px`) or one-sided (`width > + * 1000px`) media-feature range. The feature name never appears as a child + * node, so its printed position is found by comparing its offset in the + * source against the sibling Dimension/PreludeOperator children. */ +function print_feature_range(node: FeatureRange, optional_space: string): string { + let name_offset = node.start + node.text.indexOf(node.name, 1) + let items: { offset: number; text: string }[] = [{ offset: name_offset, text: node.name }] + + let children = [...(node as unknown as Iterable)] + for (let i = 0; i < children.length; i++) { + let child = children[i]! + if (is_prelude_operator(child)) { + let text = child.text + // Upstream tokenizer bug: `=>` comes through as two separate + // single-char operator tokens ("=" then ">") instead of one, unlike + // `>=`/`<=` which are correctly kept whole — merge them back + // together when directly adjacent. + let next = children[i + 1] + if (next && is_prelude_operator(next) && next.start === child.end) { + text += next.text + i++ + } + items.push({ offset: child.start, text: optional_space + text + optional_space }) + } else { + items.push({ offset: child.start, text: child.text }) + } + } + + items.sort((a, b) => a.offset - b.offset) + return OPEN_PARENTHESES + items.map((item) => item.text).join(EMPTY_STRING) + CLOSE_PARENTHESES +} + +/** Prints a single media/container feature, e.g. `(min-width: 768px)` or the + * boolean form `(hover)`. */ +function print_media_feature(node: MediaFeature, minify: boolean): string { + if (node.value === null) { + return OPEN_PARENTHESES + node.property + CLOSE_PARENTHESES + } + + // node.text's own trailing ')' is inconsistently present (an upstream + // off-by-one that only manifests once nested parens like calc() are + // involved), and node.value's children drop function calls from the value + // entirely either way — slice the raw value out of node.text instead and + // run it through the existing (untouched) regex formatter for + // calc()/whitespace normalization. + let colon_index = node.text.indexOf(COLON) + let raw_value = extract_balanced_value(node.text.slice(colon_index + 1).trim()) + let optional_space = minify ? EMPTY_STRING : SPACE + return ( + OPEN_PARENTHESES + + node.property + + COLON + + optional_space + + format_atrule_prelude(raw_value, { minify }) + + CLOSE_PARENTHESES + ) +} + +/** Prints `@supports (display: grid)`-style conditions, including + * `and`/`or`/`not`-joined and nested-boolean-group forms it can't reduce to a + * single declaration (e.g. `selector(:hover)`), which print as-is. */ +function print_supports_query(node: SupportsQuery, minify: boolean): string { + // print_condition already falls back to the raw (balanced) value for + // non-declaration conditions, so node.has_children (only ever set for the + // standalone `@supports (...)` form — @import's `supports(...)` never + // gets a SupportsDeclaration child at all) doesn't need checking here. + let condition = print_condition(node.value, minify) + // `@import url(...) supports(display: grid)` uses the functional notation + // (keyword + parens around the condition) rather than the standalone + // `@supports (...)` form's bare parenthesized condition — node.text still + // carries the "supports(" prefix in that case. + let prefix = /^supports\(/i.test(node.text) ? 'supports' : EMPTY_STRING + return prefix + OPEN_PARENTHESES + condition + CLOSE_PARENTHESES +} + +/** Prints a functional container-query condition, e.g. `style(--foo: bar)`. */ +function print_prelude_function(node: CSSNode, minify: boolean): string { + let text = node.text + let paren_index = text.indexOf(OPEN_PARENTHESES) + if (paren_index === -1) return text + let name = text.slice(0, paren_index).toLowerCase() + let args = balance_parens(text.slice(paren_index + 1, -1)) + return name + OPEN_PARENTHESES + print_condition(args, minify) + CLOSE_PARENTHESES +} + +/** Prints an `@import` URL: lowercases a leading `url(` keyword but never + * touches quote style, matching how `format_atrule_prelude` already treats + * quotes inside at-rule preludes (unlike value-position `url()`, which does + * get its quotes normalized by `print_url`). */ +function print_prelude_url(node: CSSNode): string { + let text = node.text + if (/^url\(/i.test(text)) { + return 'url(' + text.slice(4) + } + return text +} + +/** + * Prints one child of an AtrulePrelude/MediaQuery/ContainerQuery: a nested + * query, a feature/condition, an operator, or (for anything the prelude + * parser doesn't specifically model — Identifier, String, LayerName from + * `@import`'s `layer()`, Raw, ...) the node's raw text verbatim. + */ +function print_prelude_component(node: CSSNode, optional_space: string, minify: boolean): string { + if (is_media_query(node)) { + let prefix = ONLY_NOT_PREFIX_RE.exec(node.text) + return ( + (prefix ? prefix[1] + SPACE : EMPTY_STRING) + + print_prelude_children(node, optional_space, minify) + ) + } + if (is_container_query(node)) { + return print_prelude_children(node, optional_space, minify) + } + if (is_media_feature(node)) { + return print_media_feature(node, minify) + } + if (is_feature_range(node)) { + return print_feature_range(node, optional_space) + } + if (is_supports_query(node)) { + return print_supports_query(node, minify) + } + if (is_prelude_selectorlist(node)) { + return node.text + } + if (is_function(node)) { + return print_prelude_function(node, minify) + } + if (is_url(node)) { + return print_prelude_url(node) + } + if (is_layer_name(node)) { + // Only reached for `@import`'s embedded `layer(...)` — the standalone + // `@layer name;` statement form never gets here, see + // print_atrule_prelude_node. node.text here is the full "layer(...)" + // text (dotted names and all, unaffected by that form's bug); just + // lowercase the keyword itself, matching format_atrule_prelude. + return /^layer\(/i.test(node.text) ? 'layer(' + node.text.slice(6) : node.text + } + if (is_prelude_operator(node)) { + return node.text + } + return node.text +} + +/** + * Prints a `,`-or-space joined sequence of at-rule prelude components: a + * media/container query's own parts, or the top-level children of an + * AtrulePrelude/MediaQuery/ContainerQuery. + * + * Same-type siblings back to back only ever happens for comma-separated + * lists (e.g. multiple `MediaQuery`s in `screen, print`), so a comma is used + * there; every other adjacent pair (`screen and (min-width: 100px)`, or + * `url(...) layer(...) supports(...)` in `@import`) requires a real space + * regardless of `minify` — CSS syntax doesn't allow gluing them together. + */ +function print_prelude_children(node: CSSNode, optional_space: string, minify: boolean): string { + let parts: string[] = [] + for (let child of node as unknown as Iterable) { + parts.push(print_prelude_component(child, optional_space, minify)) + if (child.has_next) { + parts.push(child.type === child.next_sibling.type ? COMMA + optional_space : SPACE) + } + } + return parts.join(EMPTY_STRING) +} + +/** + * Prints a structured at-rule prelude node (`Atrule.prelude` when + * `parse_atrule_preludes: true`). + * + * Falls back to the existing regex-based `format_atrule_prelude` (still + * correct, just not structure-aware), applied to the whole prelude's raw + * text, when: + * - the prelude parser doesn't recognize the at-rule at all, or the prelude + * doesn't fit what it expects (`@page :first`, a quoted `@keyframes` name, + * `@starting-style`, an unsupported `@supports selector(...)`/function + * condition, ...) — it returns no children in that case, and nothing new + * is invented for at-rules with no test coverage, just today's existing + * (already tested) text-based formatting; + * - the prelude contains a comment: the prelude parser has no equivalent of + * the main parser's `on_comment` hook, so any comment gets silently + * dropped while building the structured tree; + * - it's the standalone `@layer name[, name2, ...];` statement form — a + * parser bug splits dotted names like `base.normalize` into two separate + * LayerName nodes at the dot, and printing them back with a synthetic + * comma would corrupt one nested layer into two unrelated top-level ones + * (see PARSER_ISSUES.md). Detected by: every child being a LayerName only + * ever happens here — `@import`'s embedded `layer(...)` is a single + * LayerName mixed with a Url/SupportsQuery sibling, never all-LayerName. + */ +function print_atrule_prelude_node(node: AtrulePrelude | Raw, minify: boolean): string { + if (is_raw(node) || !node.has_children) { + return format_atrule_prelude(node.text, { minify }) + } + // The prelude parser has no equivalent of the main parser's on_comment + // hook, so any comment inside a prelude is silently dropped while + // building the structured tree. Fall back to the regex formatter, which + // operates on the raw text and never removes anything, whenever a + // comment is present. + if (node.text.includes('/*')) { + return format_atrule_prelude(node.text, { minify }) + } + // The standalone `@layer name[, name2, ...];` statement form: same + // fallback, for the dotted-layer-name bug described above. + if (node.first_child && is_layer_name(node.first_child)) { + return format_atrule_prelude(node.text, { minify }) + } + let optional_space = minify ? EMPTY_STRING : SPACE + return print_prelude_children(node, optional_space, minify) +} + /** * Format a string of CSS using some simple rules */ @@ -431,7 +747,7 @@ export function format( // First pass: collect all comments let comments: number[] = [] let ast = parse(css, { - parse_atrule_preludes: false, + parse_atrule_preludes: true, on_comment: minify ? undefined : ({ start, end }) => { @@ -590,7 +906,7 @@ export function format( function print_atrule(node: Atrule): string { let name = '@' + node.name!.toLowerCase() if (node.prelude) { - name += SPACE + format_atrule_prelude(node.prelude.text, { minify }) + name += SPACE + print_atrule_prelude_node(node.prelude, minify) } let block_has_content = diff --git a/test/atrules.test.ts b/test/atrules.test.ts index 651890f..994ffa2 100644 --- a/test/atrules.test.ts +++ b/test/atrules.test.ts @@ -415,6 +415,79 @@ test('minify: keeps whitespace between "or" keyword and media feature', () => { expect(actual).toEqual(expected) }) +// oxlint-disable-next-line vitest/no-disabled-tests +test('lowercases @container style() function name', () => { + let actual = format(`@container STYLE(--foo: bar) { a { color: red; } }`) + let expected = `@container style(--foo: bar) { + a { + color: red; + } +}` + expect(actual).toEqual(expected) +}) + +test('preserves function calls inside media feature values', () => { + let actual = format(`@media (min-width: env(safe-area-inset-top)) {}`) + let expected = `@media (min-width: env(safe-area-inset-top)) {}` + expect(actual).toEqual(expected) +}) + +test('preserves function calls inside @supports condition values', () => { + let actual = format(`@supports (background: linear-gradient(red, blue)) {}`) + let expected = `@supports (background: linear-gradient(red, blue)) {}` + expect(actual).toEqual(expected) +}) + +test('preserves multi-operator calc() inside a media feature', () => { + let actual = format(`@media (min-width: calc(1px + 2px * 3)) {}`) + let expected = `@media (min-width: calc(1px + 2px * 3)) {}` + expect(actual).toEqual(expected) +}) + +test('preserves dotted @layer names', () => { + let actual = format(`@layer base.normalize { a { color: red; } }`) + let expected = `@layer base.normalize { + a { + color: red; + } +}` + expect(actual).toEqual(expected) +}) + +test('preserves multiple dotted, comma-separated @layer names', () => { + let actual = format(`@layer a.b, c.d.e;`) + let expected = `@layer a.b, c.d.e;` + expect(actual).toEqual(expected) +}) + +test('formats @import with a dotted layer() name', () => { + let actual = format(`@import url("a.css") layer(a.b.c);`) + let expected = `@import url("a.css") layer(a.b.c);` + expect(actual).toEqual(expected) +}) + +test('does not lose the prelude of an at-rule the prelude parser does not recognize', () => { + let actual = format(`@starting-style { a { color: red; } }`) + let expected = `@starting-style { + a { + color: red; + } +}` + expect(actual).toEqual(expected) +}) + +test('does not lose an unrecognizable @page pseudo-class prelude', () => { + let actual = format(`@page :first {}`) + let expected = `@page : first {}` + expect(actual).toEqual(expected) +}) + +test('does not corrupt a nested @supports boolean group', () => { + let actual = format(`@supports ((display: grid) and (display: flex)) {}`) + let expected = `@supports ((display: grid) and (display: flex)) {}` + expect(actual).toEqual(expected) +}) + // oxlint-disable-next-line vitest/no-disabled-tests test.skip('preserves comments', () => { let actual = format(`