diff --git a/Cargo.lock b/Cargo.lock index 33a7219a7f6e5..4affb58c0bbd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2184,7 +2184,7 @@ dependencies = [ [[package]] name = "oxc_formatter_css" -version = "0.54.0" +version = "0.56.0" dependencies = [ "cow-utils", "insta", @@ -2192,6 +2192,7 @@ dependencies = [ "oxc_diagnostics", "oxc_formatter_core", "oxc_span", + "phf 0.14.0", "pico-args", "raffia", ] diff --git a/crates/oxc_formatter_css/AGENTS.md b/crates/oxc_formatter_css/AGENTS.md index 0d5aeb6ddd820..d373d70e6209a 100644 --- a/crates/oxc_formatter_css/AGENTS.md +++ b/crates/oxc_formatter_css/AGENTS.md @@ -2,484 +2,214 @@ ## Overview -Prettier compatible CSS/SCSS/Less formatter, using the `oxc_formatter_core` APIs. +Prettier compatible CSS/SCSS/Less formatter (`oxfmt`'s Tier 1 backend), using the `oxc_formatter_core` APIs. - Built on `oxc_formatter_core` for the language-agnostic IR + Printer + builders + macros -- Parses with [raffia](https://docs.rs/raffia) 0.12 (AST with full spans + `raw` text; NOT a CST) - - A FORK (git dep on `leaysgur/raffia` in workspace deps): adds - `ParserOptions::tolerate_at_keyword_placeholders`, which accepts at-keywords - in declaration values (`ComponentValue::TokenWithSpan`) and selectors - (type/class selector idents whose `raw` INCLUDES the leading `@`). - Only `format_to_ir` enables it; `format()` stays strict - - The fork also fixes valid-syntax coverage gaps found in the skipped-fixture - survey (rev `711e372`): dash-prefixed ID selectors (`#-a-b-c-`), - Sass interpolation in media-in-parens (`@media x and #{...}` → `MediaInParensKind::SassInterpolation`), - `@import url()` with SassScript (`ImportPreludeHref::Function`), - interpolated strings in progid / custom-property values, progid on any - property name, and nameless vendor `@keyframes {}` - - The fork also makes Less `+`/`-` binary operators WHITESPACE-SENSITIVE - (matching lessc): a `+`/`-` is a `LessBinaryOperation` operator only when - followed by whitespace; `@a -@b` is two values (`-@b` is a - `LessNegativeValue` sign — `margin: -@a -@b` = two values, NOT `(-@a) - @b` - subtraction), `@a - @b` is subtraction. Without this the AST miss-attaches - shorthand signs and reprinting it would corrupt `margin`/`padding` - (prettier/prettier#6082, #10399). This is what makes the value-position - `LessBinaryOperation` structured printer (`write_less_binary_operation`) - safe — every remaining operator is a real whitespace-delimited one - - Comments are NOT in the AST: collected via `ParserBuilder::comments()` into - a positional cursor (`src/comments.rs`, mirrors `oxc_formatter_graphql`) - - The canonical reference is Prettier's `src/language-css/printer-postcss.js` - (in `tasks/prettier_conformance/prettier`, v3.8.x) — port its layout - decisions, do not invent new ones -- Two entry points: `format()` (standalone) and `format_to_ir()` (embedded/dispatcher) -- Line endings: `parse_stylesheet` normalizes `\r\n` / lone `\r` to `\n` BEFORE - parsing (like Prettier's endOfLine pre-pass) — verbatim slices reach the core - `text()` builder, which panics on raw `\r`. Parse and print both use the - normalized arena copy, so spans stay consistent. Output is always LF + - See `crates/oxc_formatter_core/AGENTS.md` for the IR/pipeline details +- Two entry points: + - `format()`: standalone files (returns a printable `Formatted`) + - `format_to_ir()`: embedded use via the dispatcher (e.g. css-in-js); + tolerates `${}` placeholders and `TopLevelDeclaration` +- The canonical reference is Prettier's `src/language-css/printer-postcss.js` + - port its layout decisions, do not invent new ones -### Tailwind `@apply` sorting (`CssFormatOptions::sort_tailwindcss`) +### Forked parser -Ports prettier-plugin-tailwindcss's `transformCss`: the ONLY CSS construct it -sorts is `@apply` params (`name == "apply"`, case-sensitive). This crate only -COLLECTS — `write_apply_prelude` (at_rule.rs) splits off the `!important` tail -(`/\s+(?:!important|#{(['"]*)!important\1})\s*$/`, see `split_important_tail`) -and Less `~"..."` wrappers, then emits the class list as one -`FormatElement::TailwindClass(index)`. Sorting (order, dedup, whitespace -collapse, `{{` skip) is the host-supplied sorter's job: `format()` takes an -`Option` and bakes the result into the `Document`; -`format_to_ir()` returns an `EmbeddedIr` and its classes travel to the -parent in `DispatchResult::tailwind_classes`, where the parent merges them -with `DispatchResult::remap_tailwind_into` (a dangling index trips a printer -debug_assert). Params containing comments fall back to the normal printers -(sorting would corrupt them). +Parses with a fork of [`raffia`](https://docs.rs/raffia), pinned via `rev` in the workspace `Cargo.toml`. -### Error semantics +The fork adds: -`format()` / `format_to_ir()` return `Err` on ANY parse error, including -raffia's recoverable ones (`parser.recoverable_errors()`). -Except `TopLevelDeclaration`, which postcss accepts (the dominant css-in-js shape). +- `tolerate_at_keyword_placeholders` option (for the css-in-js dispatcher) + - Accepts at-keywords in declaration values (`ComponentValue::TokenWithSpan`) and selectors (type/class selector idents whose `raw` INCLUDES the leading `@`) + - Only `format_to_ir` enables it; `format()` stays strict +- Whitespace-sensitive Less `+`/`-` operators (matching `lessc`) + - A `+`/`-` is a `LessBinaryOperation` operator only when followed by whitespace; + - `@a -@b` is two values (`-@b` is a `LessNegativeValue` sign + - `margin: -@a -@b` = two values, NOT `(-@a) - @b` subtraction), `@a - @b` is subtraction +- Various bug fixes for valid CSS/SCSS/Less syntax `raffia` miss-parses or rejects + - selector / at-rule / value-token coverage gaps -NOTHING falls back to Prettier: standalone files report the -`Err` as a diagnostic, and a css-in-js dispatch `Err` makes the parent print -the template as-is. -Since the raffia fork, value/selector-position `${}` placeholders parse on the Rust path, -so what still `Err`s is garbage Prettier's embed throws on too (e.g. `foo\n${a}\n${b}` bare words). +On the other hand, Prettier operates on `postcss` + three sub-parsers (`postcss-selector-parser`, `postcss-values-parser`, `postcss-media-query-parser`) and depends on `raws` (source gaps). -### Intentionally unsupported: postcss plugin syntax +`raffia` parses everything structurally in one pass; source gaps are recovered by comparing span boundaries (`hasEmptyRawBefore(x)` == "no gap between spans"). + +### Error semantics + +`format()` / `format_to_ir()` return `Err` whenever they cannot produce output they can stand behind: -Syntax that only "works" in Prettier because postcss parses without validation -and a build-time plugin interprets it later is NOT supported in `.css` files — -the parse-error diagnostic is the correct behavior, not a coverage gap: - -- postcss-simple-vars: `$blue: #056ef0;` declarations, value-position `$blue`, - `$(dir)` interpolation (the plugin's entire core) -- postcss-mixins: parametered `@define-mixin x $a, $b` and `$var` inside the - body. Plain `@define-mixin icon {}` / `@mixin icon;` / `@mixin icon a, b;` - DO parse fine -- postcss-nested-props (`font: { ... }`), ICSS nested `:export { nest: {} }`, - `--element(...)` (CSS Extensions, zero implementations) - -Verified UNAFFECTED (parse clean, recoverable errors empty — 2026-06-12): -every Tailwind v3/v4 at-rule (`@tailwind` `@apply` `@layer` `@theme` `@utility` -`@variant` `@config` `@custom-media`), every CSS Modules construct (`@value` -incl. `from`, `:global`/`:local`, `composes`, plain `:import`/`:export`), and -standard CSS nesting. The big postcss-ecosystem user bases are all safe; -the unsupported plugins are ~1% of Prettier's npm weekly downloads and -fail LOUD (diagnostic), with ignore-listing as the escape hatch. -If demand materializes, a raffia-fork tolerate option (same pattern as -`tolerate_at_keyword_placeholders`) could accept `$var` tokens. - -Value-position `@{var}` (Less) also stays unsupported: lessc itself rejects -it (verified) — raffia's "interpolation is disallowed in declaration values" -is lessc-accurate, and the fixture exercising it (postcss-less PR #159) is a -tolerance test, not valid Less. Same for whitespace inside a Less lookup -(`@config [ option1]` — gluing works, spacing is near-zero-use). - -NOTE: the genuine coverage gaps found in the same survey were FIXED in the -raffia fork (2026-06-12, rev `711e372`; the six fixes are listed under -Overview above). Remaining skipped conformance fixtures are all -intentional-invalid / mixed-language / plugin-syntax, plus `---lang` front -matter (plan Step 6-2, oxfmt pre-pass) and `>>>` (reassess at plan Step 8-1, -Vue scoped styles). Upstreaming the fork fixes to g-plane/raffia is planned -(see the plan's cross-cutting items); minimal repros exist for all of them. - -### Architecture notes (Prettier mapping) - -Prettier's CSS printer operates on postcss + three sub-parsers -(postcss-selector-parser → `selector-*`, postcss-values-parser → `value-*`, -postcss-media-query-parser → `media-*`) and depends on `raws` (source gaps). -raffia parses everything structurally in one pass; source gaps are recovered -by comparing span boundaries (`hasEmptyRawBefore(x)` == "no gap between spans"). - -- `src/print/mod.rs` — statement sequences (hardline-separated, one blank line - preserved via `classify_gap`), trailing same-line comments -- `src/print/statement.rs` — qualified rules, declarations, blocks, dispatch. - `stmt_end()` extends spans over whitespace/comments + `;` (postcss `locEnd` - includes the semicolon; blank-line detection counts from after it) -- `src/print/value.rs` — the port of `printCommaSeparatedValueGroup` / - `printParenthesizedValueGroup` over flat `ComponentValue` streams - (raffia keeps `Delimiter` commas/solidi inline, like postcss-values tokens). - Key rules ported: solidus tightness (font sizes, leading `/`), grid hardlines - (+ leading hardline when the source breaks), `printNumber`/`printCssNumber`, - `printString` re-quoting, CSS_UNITS canonical casing, wide-keywords/hex - lowercase, `composes` removeLines, `progid:` verbatim, url() inner verbatim. - Interpolation rules (2026-06-12, from the un-skipped fixtures): - `SassInterpolatedIdent` reprints structurally (`#{ $a + $b }` → - `#{$a + $b}` — postcss-values tokenizes through `#{}`); a neighbor glued - to an interpolated ident stays glued (`1#{$var}` is one word); a unary - `+`/`-` glues to its operand even across a gap EXCEPT before a function - call; in `write_sass_binary`, `*` always spaces and word-like `+`/`-` - operands space on both sides (each ends the glued run) — EXCEPT an - asymmetric `+`/`-` (whitespace BEFORE, glued AFTER), which is a signed - operand in postcss-values lexing and stays glued (`$a -$b` → `$a -$b`, NOT - `$a - $b`; matters for the ambiguous Sass `margin: -$a -$b` list/subtraction - case dart-sass deprecates). Fixture: `scss/binary-operation-spacing.scss` -- `src/print/selector.rs` — selectors; combinators carry the break point - BEFORE themselves; `maybeToLowerCase` for pseudos; attribute values are - quoted via `printString` -- `src/print/at_rule.rs` — prelude dispatch; media query port; - **unknown at-rule params print VERBATIM** (2026-06-12, ecosystem-CI): - Prettier's parser hands params to sub-parsers only for a fixed allowlist - (parser-postcss.js; see `is_value_parsed_at_rule`) and everything else — - `@apply`, `@tailwind`, `@custom-variant`, `@variant`, `@source`, ICSS - `@value`, plus `@warn`/`@error` (media-unknown) — stays a plain string the - printer emits raw (`write_verbatim_at_rule_tail`, also used by - `UnknownSassAtRule`; slice runs from the NAME so gap comments stay - embedded; a trailing `//` line pushes `{` down à la - `lastLineHasInlineComment`). Re-spacing those tokens CORRUPTS Tailwind - syntax (`dark:bg-x` → `dark: bg-x`, `py-1.5` → `py-10.5`, - `@custom-variant dark (&:is(...))` → `dark(&: is(...))`). - The TokenSeq mini-printer (gap-based separators, break AFTER math - operators) now only serves SCSS-family names parsed AS CSS - (`@include` etc., raffia: Unknown / Prettier: parseValue); - "fused" preludes (`@page:first` stays tight when the source has no gap); - SCSS control directives wrap `[space, prelude, line]` in a group so `{` - drops to its own line when the prelude breaks — EXCEPT fully parenthesized - conditions (Prettier's `hasParensAroundNode` → `{` stays on the `)` line); - a no-comment `@import` path list fills (long lists wrap at the width) -- `src/print/scss.rs` — `$var` declarations, maps (always break, one item per - line, trailing comma per option), lists, `@each`/`@for`/`@if` chains - (`} @else` joined), mixin/include/function params, `@use`/`@forward` with - always-broken `with (...)` configs -- `src/print/less.rs` — `@var` declarations, mixin definitions/calls, guards, - lookups (`[@result]` tight), detached rulesets +- `raffia` is error-tolerant via `parser.recoverable_errors()`, but any parse error bails out; + - Never format a broken AST + - Exception: `TopLevelDeclaration`, the dominant css-in-js shape (postcss accepts it) +- print-stage internal errors are also `Err` +- The caller (oxfmt) decides what happens next + (diagnostics for standalone files, template-as-is for embedded) ### Comments -Positional cursor over `CssComment { span, inline }` (`inline` = `//`). +`raffia` does not attach comments to the AST; +they are collected via `ParserBuilder::comments()` into a positional cursor over `CssComment { span, inline }` +(`inline` = `//`). - Statement-level comments: flushed before each statement (`flush_leading_comments`); consecutive same-line comments stay glued (`*/ /*!`), but a comment is always followed by a line break before a node - Value-level comments: flushed inside fill entries before the component they - precede (`flush_value_comments`); `//` comments expand the parent group and - force a hardline after -- Trailing (`value /* c */;`): flushed by `write_declaration` with the source - gap before `;` preserved + precede (`flush_value_comments`); `//` comments expand the parent group and force a hardline after +- Trailing (`value /* c */;`): flushed by `write_declaration` with the source gap before `;` preserved - After each statement, the sequence DISCARDS unclaimed comments inside the statement span (cursor must never point before a printed position) -## Status (2026-06-11) +### Line endings -`cargo run -p oxc_prettier_conformance` — -**css 114/114, scss 85/85, less 39/39 — ALL 100%.** +`parse_stylesheet` normalizes `\r\n` / lone `\r` to `\n` BEFORE parsing. -Wired into oxfmt: standalone css/scss/less files AND the -css-in-js dispatcher route (`apps/oxfmt`). The embedded suite -(`pnpm conformance` in `apps/oxfmt`) also exercises this crate via -`format_to_ir`; re-run it for printer changes too. +Unlike other formatters that normalize locally where needed, CSS has too many verbatim slices to handle case by case. +And without this, raw `\r` reaching the core `text()` builder would panic. +Parse and print both use the normalized arena copy, so spans stay consistent. -Keep them that way: any change here must re-run the conformance suite. +The configured `end_of_line` option still applies, the printer emits the chosen line ending when materializing multiline `Text` IR. ### css-in-js specifics -- `format_to_ir` input contains `@prettier-placeholder-N-id` markers for - `${}` interpolations. raffia parses statement-position markers as - at-rules (a `;`-less one SWALLOWS the following statements into its - prelude); value/selector-position markers parse via the fork option - (see Overview) — `format_to_ir` passes `tolerate_placeholders: true` -- Value-position placeholders are `ComponentValue::TokenWithSpan` and mostly - ride the existing gap-based separator rules (glued → `Tight`, spaced → - `Line`). ONE added rule: placeholder glued to a paren group separates with - `Separator::SoftBreak` (Prettier's `isAtWordPlaceholderNode + -isParenGroupNode → softline`: `${fn}(30px)` breaks BEFORE the parens) -- Selector-position placeholders trigger "garbage mode" - (`write_selector_list`): postcss-selector-parser degrades on at-words, so - from the selector containing the FIRST placeholder onwards Prettier prints - near-verbatim — our port emits the raw source slice with whitespace runs - collapsed to single spaces, never breaking. Commas BEFORE the first - placeholder still split selectors normally. Detection is a source-text - scan for `@prettier-placeholder-` -- Ignored (`prettier-ignore`) `;`-less placeholder at-rule at EOF VANISHES - (`write_statement_sequence`): postcss leaves no `source.end` on it, so - Prettier's `printIgnored` slices an empty string. Reproduced for - placeholder at-rules ONLY — the resulting placeholder-count mismatch makes - the embed fall back to plain template printing, like Prettier. For real - code (`@foobar`) we deliberately DIVERGE and keep the verbatim text - (Prettier silently deletes it; that's a data-loss bug, not a behavior to - port) -- Placeholder at-rules get Prettier's `isTemplatePlaceholderNode` - treatment (`write_placeholder_at_rule` in `at_rule.rs`): verbatim - prelude (newlines stay literal), gap comments printed not discarded, - name-glued `:` collapses following whitespace to one space, `;` only - when the source has one -- `TopLevelDeclaration` is the ONE recoverable error format() accepts - (postcss accepts it; it is the dominant css-in-js shape) -- Custom property values arrive as raw token streams; they are re-parsed - as a plain declaration at the same source offsets (prefix blanked - BYTE-wise — multi-byte chars! — prop replaced by `a`s) so the value - gets the normal group/break layout (`reparse_custom_property_value`) -- raffia's `Calc` spans EXCLUDE operand parens; `write_calc_operand` - recovers them from the source (children account for unbalanced parens - inside the span — see the `need_left`/`need_right` math) -- A source trailing comma in function args survives for `var()` ONLY - (Prettier's `printTrailingComma`) -- Trailing same-line comments are plain content, NOT `line_suffix`: - Prettier counts them towards the line width, so they can break the - preceding value group — EXCEPT a single interpolated component - (`--p: #{fn(...)}; // c`): Prettier's value parser splits `#{` into - multiple fill chunks and a fill chunk's fit ignores the rest of the line, - so the comment never breaks it (2026-06-12, mastodon; routed through a - single fill entry in `write_comma_group` + an extra `indent` in the - `SassInterpolated` arm for the +2/+1 broken layout — both derived from - `--debug-print-doc`) -- The 2026-06-12 ecosystem-CI sweep (fixture - `format/scss/layout-major-diffs.scss` covers all of it) also fixed: - grid separator = plain SPACE (single-line grid values NEVER re-wrap, - Prettier pushes `" "` not `line`); custom property `!important` lives on - the REPARSED declaration (`reparse_custom_property_value`) — the printer - reads it from there or it silently vanishes; media feature values are - flat text (`media-value` = `adjustNumbers(adjustStrings(...))` → - `ValueContext::no_break`, honored by - `write_function`/`write_calc`/`write_sass_binary` — e.g. a media feature - value `map-get(...) - 1px` never breaks, however long the `@media` line); - **calc is a FLAT operator fill** (`write_calc` flattens nested - unparenthesized `Calc` nodes into chunks — operator glued to its LEFT - operand, break after, ONE uniform indent; parenthesized sub-expressions - stay single chunks). The calc flattening also fixed the webawesome - `page.styles.ts` diff at printWidth 100; the printWidth-80 webawesome - diffs REMAIN — they are the deliberate rule-B divergence (see the fill - VERDICT below), not a calc-layout artifact. (A first conformance run - seemed to clear them all, but `pnpm conformance` imports `dist/index.js` - WITHOUT rebuilding — it had measured a stale binary. Always run - `pnpm --dir apps/oxfmt build-dev` first.) - -Layout machinery notes discovered en route: - -- Our core `fill` breaks the separator AFTER a hard-broken entry (biome - semantics) and measures fits only up to a hardline; Prettier's fill - fit-checks `[item, sep, next-item]` and treats a hardline-bearing chunk - as never fitting. Where that diverges, separator breaks are SIMULATED - with static source widths (`write_commented_value_params` / - `write_commented_media_params` in `at_rule.rs`, the SassImport path, - the lead-comment fill in `write_value_groups`) - - VERDICT on Prettier's fill semantics (assessed 2026-06-11, keep the - sims as-is): it is really TWO separate rules with different merit. - (A) "a hardline-bearing chunk never fits → it starts on its own line" - is RATIONAL — it keeps a comment visually attached to the item it - annotates (`// Comment` ends up on its own line BEFORE the next - import path, not dangling after the previous one). The sims reproduce - rule A, so they are reasoned behavior, not quirk-for-bytes emulation. - (B) the pairwise lookahead's side effect of breaking INSIDE short - paren groups (`var(\n --x\n ) * 2`, `::slotted(\n *\n )` — the two - webawesome diffs at printWidth 80, still accepted as of 2026-06-12; - the flat-calc rewrite resolved only the printWidth-100 instance) - is IRRATIONAL and we deliberately do NOT follow it. Removing the sims - was measured (3 fixtures fail: css 112/114, scss 84/85 — all - comment-torture tests) and rejected: it would drop rational behavior A - for consistency's sake. The principled long-term fix, if ever needed, - is to teach rule A ALONE to the core fill fit-check (NOT full Prettier - fill) — that retires all three sims; it requires a JS-conformance - impact experiment first since core fill is shared with `oxc_formatter`. - Note the sims sit INSIDE the source-rebuild layer for comment-bearing - params; the rebuild itself can never be removed (raffia's AST drops - params-embedded comments — removing it loses comments, not layout) -- Prettier's printer counts a multi-line string doc at its FULL width (no - newline reset), so after a multi-line `raws.between` the first trailing - comment always wraps → `ValueContext::tail_break` -- `css-decl` prints the WHOLE trimmed `raws.between` (prop → value, colon - and comments included) verbatim; a trailing `//` line drops the value to - `indent([hardline, dedent(value)])`; same-line space runs before `//` - collapse to one (postcss-less keeps inline comments out of between) -- At-rule params containing block comments are rebuilt from the source - (postcss keeps them inside the params string): `@keyframes`-style names → - whitespace-normalized verbatim; `@media` → media-token reconstruction - (`)` ends a single-line paren token; spaced `feature : value` re-spaces, - glued/multi-line stays verbatim); `@import`/`@supports` → value-token - fill simulation with always-broken comment-bearing parens. `//` comments - stay on the structural printers -- Selectors containing `//` comments are `selector-unknown` in Prettier: - raw verbatim, `{` pushed to the next line after a trailing `//` -- SCSS control-directive conditions are `group(indent(parts))`, NOT a fill: - space before every operator, breakable line after it, all-or-nothing - (`write_condition_chain`); source-glued `$a==b` stays glued -- `isSCSSMapItemNode` is ported as two ctx flags: `map_break` (SassMap in - `$var:`/function-arg/map-item positions always breaks) and `paren_break` - (paren groups break ONLY as direct map-item/config values); outside those - positions maps stay inline, preserve source blank lines between items, - and print no trailing comma -- A function call directly after a `//` comment gets Prettier's quirky - double indent (args +2 levels, `)` +1 — `ValueContext::after_inline_comment`) -- An interpolated string whose outer quote re-appears inside (`'#{f('a')}'`) - splits in postcss → every piece requotes to the preferred quote -- YAML front matter: best-effort normalization in `format.rs` - (`try_format_yaml_front_matter`) — plain mappings/sequences/comments only, - anything else verbatim. Removed at plan Step 7 (front matter handling - moves up to oxfmt's shared pre-pass; `oxc_formatter_yaml` formats it) -- TokenSeq mini-printer is recursive: top-level commas → fill; - balanced paren regions → groups; `name(`/`$k: (` glue; math ops break - after, comparisons stand alone; numbers/strings normalized -- Static-width simulations assume top-level at-rules (column 0); deeply - nested commented at-rule params would mismeasure (not in the suite). - `prettier@3.8.4` runs directly via `npx prettier@3.8.4 --parser css` — - invaluable for verifying layout hypotheses against small repros +`format_to_ir()` accepts SCSS-like source with `@prettier-placeholder-N-id` markers in place of `${}` interpolations. +`raffia` parses them via the fork option `tolerate_at_keyword_placeholders` (`format_to_ir` passes `tolerate_placeholders: true`). + +Per-position handling: + +- Statement position: parses as an at-rule (`write_placeholder_at_rule` in `at_rule.rs`) + - A `;`-less marker SWALLOWS the following statements into its prelude +- Value position: `ComponentValue::TokenWithSpan`, rides existing gap-based separator rules + - One added rule: glued to a paren group → `Separator::SoftBreak` (`${fn}(30px)` breaks BEFORE the parens) +- Selector position: triggers "garbage mode" in `write_selector_list` + - Emits the raw source slice with whitespace runs collapsed, never breaking + - Mirrors `postcss-selector-parser` degrading on at-words + +## Prettier mapping + +### Unknown at-rule params print VERBATIM + +Prettier's parser hands params to sub-parsers only for a fixed allowlist (see `parser-postcss.js`, `is_value_parsed_at_rule`); +everything else (`@apply`, `@tailwind`, `@custom-variant`, `@variant`, `@source`, ICSS `@value`, etc) stays a plain string the printer emits raw (`write_verbatim_at_rule_tail`). + +Re-spacing those tokens CORRUPTS Tailwind syntax: `dark:bg-x` → `dark: bg-x`, `py-1.5` → `py-10.5`, `@custom-variant dark (&:is(...))` → `dark(&: is(...))`. + +We also follow this to keep Prettier compatibility. + +### Tailwind `@apply` sorting (`CssFormatOptions::sort_tailwindcss`) + +Ports prettier-plugin-tailwindcss's `transformCss`: with the option on, `@apply` params become `FormatElement::TailwindClass(index)` elements, and a host-supplied `TailwindSorter` performs the actual ordering/dedup outside this crate. + +See `write_apply_prelude` in `at_rule.rs` (collection + `!important` / Less `~"..."` extraction) and `format.rs` (sorter dispatch). + +### Intentionally unsupported: postcss plugin syntax + +These syntax that only "works" in Prettier because `postcss` parses without validation and a build-time plugin interprets it later is mostly NOT supported. +(`postcss` is permissive with any syntax it doesn't understand, while we parse strictly with `raffia`.) + +These plugins were once common but are now mostly legacy. Representative examples we do NOT support: + +- `postcss-simple-vars`: `$blue: #056ef0;` declarations, value-position `$blue`, `$(dir)` interpolation +- `postcss-mixins`: parametered `@define-mixin x $a, $b` and `$var` inside body + - Plain `@define-mixin icon {}` / `@mixin icon;` / `@mixin icon a, b;` DO parse fine +- `postcss-nested-props` (`font: { ... }`), ICSS nested `:export { nest: {} }`, `--element(...)` (CSS Extensions, zero implementations) + +Failures emit a LOUD diagnostic; ignore-listing is the escape hatch. + +We DO support, however, the following popular plugin-flavored syntaxes: + +- Tailwind v3/v4 at-rules: `@tailwind`, `@apply`, `@layer`, `@theme`, `@utility`, `@variant`, `@config`, `@custom-media` +- CSS Modules constructs: `@value` (incl. `from`), `:global`/`:local`, `composes`, plain `:import`/`:export` +- Standard CSS nesting + +Less also rejects (matching `lessc`): + +- Value-position `@{var}` interpolation +- Whitespace inside a Less lookup (`@config [ option1]`) + +If there is high demand, we can also consider making some parts acceptable by updating the `raffia` parser side. + +### Known divergences + +Deliberate divergences from Prettier (impact does not justify the matching cost): + +- Less `func(x, + 20px)` unary gluing + - Prettier prints `+20px`; `raffia` ASTs `, +` as a comma-left binary operation, so matching is ad-hoc for a torture-test-only shape +- Nested Less math in a function arg / multi-value shorthand + - Prettier's fill fit-check breaks INSIDE the wide chunk; our core `fill` (biome semantics) breaks the SEPARATOR instead. + - Principled fix is the shared core-fill fit-check change (needs JS-conformance impact experiment first) +- Broken `:not(...)` selector args indent at +2 + - Prettier lands at +4 (arg) / +2 (`)`) + - Layout-only, rare trigger (selector longer than line width) +- Selector-position Sass interpolation normalizes inner spaces (`#{ $name }` → `#{$name}`) + - We normalize BOTH positions for output consistency + - Prettier keeps SELECTOR interpolation verbatim +- A function call directly after a `//` comment in nested-args position + - Prettier double-indents it + - We print the normal indent (prettier/prettier#19427) ## Verification ```sh -cargo check -p oxc_formatter_css -cargo test -p oxc_formatter_css # fixture snapshots (see below) -cargo run -p oxc_prettier_conformance # pass rates -cargo run -p oxc_prettier_conformance -- --filter css/atrule # diff a fixture +cargo c -p oxc_formatter_css +``` + +Run `clippy` and resolve all warnings. + +### Fixture tests + +Snapshot tests driven by fixture files; covers what the Prettier conformance suite does not (placeholder at-rules, custom-property re-parsing, embedded css-in-js, etc.). + +Fixtures are grouped per language (`format/{css,scss,less}/`; test modules mirror the directories), with the shared `options.json` at the `format/` / `embedded/` level (the harness walks up to the nearest one). +`embedded/scss/` is explicit about the dispatcher's variant=Scss hardcoding. + +Unit tests in `tests/fixtures/mod.rs` cover parse-error `Err` semantics (`parse_error_is_err`). +Fixtures under `embedded/` route through `format_to_ir` instead of `format()`; the `embedded_debug` example formats files the same way for quick comparison. + +Every expected output must be verified against Prettier (3.8.4, the current submodule). +`npx prettier@3.8.4 --parser ` at both `--print-width 80` and `100` (the harness snapshots both). + +```sh +cargo test -p oxc_formatter_css +# Review / accept snapshots after intentional changes +cargo insta review -p oxc_formatter_css +``` + +### Prettier conformance + +Compares output against Prettier's snapshots and tracks failures (not passes); +results live in `tasks/prettier_conformance/snapshots/prettier.css.snap.md` / `prettier.scss.snap.md` / `prettier.less.snap.md`. + +```sh +cargo run -p oxc_prettier_conformance +# Debug a specific test +cargo run -p oxc_prettier_conformance -- --filter css/atrule +``` + +At the current version (v3.8.4), the divergences of two files has been confirmed in the SCSS conformance, but this is intentional. + +### Embedded conformance (`apps/oxfmt`) + +The embedded-language features (css-in-js) are validated end-to-end through the Oxfmt. + +Requires a dev build first. + +```sh +pnpm --dir apps/oxfmt build-dev +pnpm --dir apps/oxfmt conformance +``` + +### Manual checks + +```sh cargo run -p oxc_formatter_css --example css_formatter file.css cargo run -p oxc_formatter_css --example parse_debug -- --syntax scss file.scss # dump raffia AST +cargo run -p oxc_formatter_css --example embedded_debug file.scss # format_to_ir entry ``` -### Fixture tests (`tests/fixtures/format/` and `tests/fixtures/embedded/`) +## Roadmap (TODO: Follow Prettier main) -Fixtures are grouped per language (`format/{css,scss,less}/`; test modules -mirror the directories, e.g. `fixtures::format::scss::case_normalize`). -`embedded/scss/` is explicit about the dispatcher's variant=Scss hardcoding. -The shared `options.json` sits at `format/` / `embedded/` level (the harness -walks up to the nearest one). - -Cases the Prettier conformance suite does NOT cover live here as insta -snapshots (same harness as `oxc_formatter_json`/`_graphql`): placeholder -at-rules, custom-property value re-parsing (incl. the multi-byte-comment -span regression), calc operand parens, An+B normalization, `:has(>)` -relative combinators, `var()` trailing commas, group-breaking trailing -comments, top-level declarations, `@import ... supports(...)` (the prelude -`supports` arm was a data-loss stub emitting empty `supports()`; now routed -through the structured `@supports` printers — `at-rule-import-supports.css`). -Parse-error fallback triggers are unit -tests in `tests/fixtures/mod.rs` (`parse_error_is_err`). - -The skipped-fixture survey (2026-06-12) added 11 fixtures extracted from -the valid parts of permanently-skipped conformance files (`@import`/ -`@charset`/`@supports` variants, selector variants, ICSS, SCSS control -directives / case / values / CRLF / comma imports, Less case / operations / -lookups) — every block machine-compared against prettier@3.8.4 (the -pre-survey fixtures also match 3.8.4, re-verified after the per-language -reorganization). The survey -exposed 10 divergences; 9 are FIXED (comment-bearing selectors verbatim, -ICSS `@value`/property casing, `URL(` casing, keyframe interpolations, -`shouldBreakList` multi-node chunks incl. non-initial `-`-led idents, -`@import`/`@supports` break points, division spacing per postcss-values -lexing — each rule is documented at its implementation site). Known -divergences, all deliberate (impact does not justify the matching cost): - -- Less `func(x, + 20px)` unary gluing → Prettier prints `+20px`; raffia - ASTs the `, +` as a comma-left binary operation, so matching it would be - ad-hoc for a torture-test-only shape. Revisit if hit by real code -- Nested Less math in a function arg / multi-value shorthand - (`max((round(...) / 10) - @bw, 0)`, `margin: (a + b) -@x -@y`): Prettier's - fill fit-check breaks INSIDE the wide chunk (the leading paren goes to its - own lines); our core `fill` (biome semantics) breaks the SEPARATOR instead - (between the paren and the next value). Same root as the webawesome - `*.styles.ts` divergences — the principled fix is the shared core-fill - fit-check change (needs a JS-conformance impact experiment first). Standalone - `@var:`/value-position Less math (incl. a parenthesized op that breaks onto - its own lines) DOES match Prettier now — see below -- Broken `:not(...)` selector args indent at +2 relative to the selector; - Prettier lands at +4 (arg) / +2 (`)`) via its selector-printer indent - stack. Layout-only, rare trigger (selector longer than the line width) -- Selector-position Sass interpolation normalizes inner spaces - (`#{ $name }` → `#{$name}`, `selector.rs` `write_interpolable_ident` routes - through `write_sass_interpolated_ident`). Prettier keeps SELECTOR - interpolation verbatim while normalizing VALUE-position interpolation - (postcss-selector-parser treats it as an opaque token; same token-stream - limitation as the `#1811` operator spacing). We normalize BOTH positions so - oxfmt's output is internally consistent — at the cost of one conformance - fixture (`scss/map/function-argument/functional-argument.scss`, the only - `.text #{ $name }` spaced case). String literals inside the interpolation - re-quote either way (`#{'x'}` → `#{"x"}`), which DOES match Prettier; - see `tests/fixtures/format/scss/interpolation-quotes.scss` - -(The former second divergence — `@foo 'one'` requoting — was RESOLVED by -the unknown-at-rule verbatim contract (2026-06-12): ALL unknown at-rule -params now print raw, so the `raw_at_rule_strings` ICSS special-case cell -is gone too.) - -The 2026-06-12 ecosystem-CI sweep added `format/css/tailwind-at-rules.css` -(unknown at-rule verbatim: `@apply`/`@custom-variant`/`@variant`/`@source`), -`format/css/custom-property-important.css` (custom property `!important` -survival), `format/scss/layout-major-diffs.scss` (grid no-rewrap, -`@import` fill, interpolation fit/indent — custom AND normal props —, flat -calc, media-value no-break, `@warn`/`@error` raw) and -`format/scss/unknown-at-rule-edges.scss` (fused `@a:b`, glued `@foo (x)`, -comments embedded in verbatim params, trailing `//` pushing `{` down, -single-quote preservation in `@error`/`@warn`, interpolated-name -`@#{$name}` = the `UnknownSassAtRule` statement path) — all -machine-compared against prettier@3.8.4 at both widths. - -The 2026-06-13 Less ecosystem sweep (ng-zorro-antd 409 / vant 107 `.less` -files vs Prettier; vant now diffs ZERO, ng-zorro only the two deliberate -divergences above plus its `insert_final_newline=false` editorconfig) -established the **Less selector-side verbatim contract** (`less.rs`): -Prettier re-parses mixin-definition preludes, statement-position mixin -calls and `when` guards with postcss-selector-parser and prints the raw -text — so spacing/newlines survive, nothing width-breaks, and the ONLY -transforms are `adjustNumbers` + `adjustStrings` -(`value::adjust_numbers_and_strings`). `LessConditionalQualifiedRule` is a -`css-rule`: NO trailing `;` after the block (it used to hit the verbatim -catch-all and gain one). Also fixed: function-argument source parens that -raffia drops are restored by bounded balance-scan -(`group_own_paren_layers` — `max(((a - b) / 2), 0)`, `min(((@a)), @b)`, -`calc((a))`); Less `@var:` values get NO softline after the colon -(`ValueContext::no_leading_softline` — Prettier's -`shouldPrecededBySoftline` matches `css-decl` only, not atrule-variables); -`~'...'` re-quotes like a plain string AND counts as a multi-node -comma_group for `shouldBreakList`. Fixtures: `less/guards.less`, -`less/mixin-verbatim.less`, `less/variable-values.less`, -`css/calc-parens.css` — all machine-compared against prettier@3.8.4 at -both widths. - -The 2026-06-18 oxfmt-conformance sweep (apps/oxfmt ng-zorro 409 `.less`) -fixed three quote-normalization gaps where a Less string variant fell into a -verbatim catch-all and skipped `printString` re-quoting (Prettier -`adjustStrings` always normalizes the quote): (1) `@import (options) '...'` -parses as `AtRulePrelude::LessImport` — it had no prelude arm and hit the -`_ => verbatim` catch-all; now `write_less_import_prelude` prints -`(names)` + href via the shared `write_import_href`; (2) an interpolated -import path (`@import './@{var}.less'`) was verbatim in -`write_import_prelude_inner`; now both plain and Less imports route the href -through `write_import_href`, which `write_requoted_verbatim`s the -interpolated case; (3) attribute-selector `[class^=~'...']` -(`AttributeSelectorValue::LessEscapedStr`) was verbatim — now `~` + -`write_str` like the value-position handler. Fixture: -`less/import-quotes.less`. - -The same sweep replaced the verbatim `LessBinaryOperation` printer with a -structured one (`write_less_binary_operation` + `write_less_parenthesized_operation` -in `value.rs`): a flat operator fill (break AFTER the operator, one uniform -indent; nested unparenthesized ops flatten into the same fill; a parenthesized -sub-expression is its own group that drops `(`/`)` onto their own lines when it -breaks). This is only safe because of the raffia whitespace-sensitive `+`/`-` -fix above (signs never reach this printer as operands). It matches Prettier for -value-position math — `line-height: @a - 2*@b - (@c/2)` (was overflowing -printWidth), `@c-y: (long + expr)` paren-on-own-lines — and is output-neutral -when the expression fits. Fixture: `less/math-operations.less`. The remaining -ng-zorro divergences are: `:not()` wrap indent (above), and nested math in -function-arg / multi-value-shorthand contexts (the core-fill fit-check -divergence — see the Known-divergences list). - -Fixtures under `embedded/` route through `format_to_ir` (the css-in-js -dispatcher entry, placeholders tolerated) instead of `format()`: value / -selector / paren-softline / ignore-vanish placeholder cases. The -`embedded_debug` example formats a file the same way for quick comparison -(`cargo run -p oxc_formatter_css --example embedded_debug file.scss`). - -**Every expected output was verified against Prettier (3.8.4, the current submodule); -do the same when adding fixtures** (`npx prettier@3.8.4 --parser `, -at both `--print-width 80` and `100` — the harness snapshots both). -Update snapshots with `cargo insta review` (or `INSTA_UPDATE=always cargo test`). +The guiding axis is Prettier compatibility, matching what is in Prettier's unreleased changelog (main has them, next stable will). + +- [#18605](https://github.com/prettier/prettier/blob/main/changelog_unreleased/css/18605.md): + Don't break a selector when its attribute value contains an escaped literal newline (`foo="long\\continuation"`). + We currently break before the long span; Prettier main keeps the selector on one line. diff --git a/crates/oxc_formatter_css/Cargo.toml b/crates/oxc_formatter_css/Cargo.toml index a0892d10fadb9..428407205679a 100644 --- a/crates/oxc_formatter_css/Cargo.toml +++ b/crates/oxc_formatter_css/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxc_formatter_css" -version = "0.54.0" +version = "0.56.0" authors.workspace = true categories.workspace = true edition.workspace = true @@ -22,6 +22,7 @@ oxc_allocator = { workspace = true } oxc_diagnostics = { workspace = true } oxc_formatter_core = { workspace = true } oxc_span = { workspace = true } +phf = { workspace = true, features = ["macros"] } raffia = { workspace = true } [dev-dependencies] diff --git a/crates/oxc_formatter_css/src/comments.rs b/crates/oxc_formatter_css/src/comments.rs index 76da77ce9528f..70dfd0bce0721 100644 --- a/crates/oxc_formatter_css/src/comments.rs +++ b/crates/oxc_formatter_css/src/comments.rs @@ -126,7 +126,7 @@ pub fn write_single_comment(comment: CssComment, f: &mut CssFormatter<'_, '_>) { } /// Emits the formatter element that reproduces the vertical spacing implied by `gap`. -fn write_gap(gap: &[u8], f: &mut CssFormatter<'_, '_>) { +pub fn write_gap(gap: &[u8], f: &mut CssFormatter<'_, '_>) { match classify_gap(gap) { Gap::None => write!(f, space()), Gap::Line => write!(f, hard_line_break()), @@ -148,14 +148,9 @@ pub fn write_leading_comments( match comments.get(i + 1) { // Comment followed by another comment: keep same-line pairs // (`*/ /*!`) together. - Some(next) => { - match classify_gap(source.bytes_range(comment.span.end, next.span.start)) { - Gap::None => write!(f, space()), - Gap::Line => write!(f, hard_line_break()), - Gap::Blank => write!(f, empty_line()), - } - } - // Comment followed by the node: always on its own line. + Some(next) => write_gap(source.bytes_range(comment.span.end, next.span.start), f), + // Comment followed by the node: always on its own line (a blank + // line in the source is preserved, otherwise a single hardline). None => { if classify_gap(source.bytes_range(comment.span.end, value_start)) == Gap::Blank { write!(f, empty_line()); diff --git a/crates/oxc_formatter_css/src/format.rs b/crates/oxc_formatter_css/src/format.rs index 2c446b877dd6b..2a30f59001485 100644 --- a/crates/oxc_formatter_css/src/format.rs +++ b/crates/oxc_formatter_css/src/format.rs @@ -1,3 +1,5 @@ +use raffia::{ParserBuilder, ParserOptions, ast::Stylesheet}; + use oxc_allocator::{Allocator, Vec as ArenaVec}; use oxc_diagnostics::OxcDiagnostic; use oxc_formatter_core::{ @@ -6,7 +8,6 @@ use oxc_formatter_core::{ write, }; use oxc_span::Span; -use raffia::{ParserBuilder, ast::Stylesheet}; use crate::{ comments::CssComment, @@ -17,6 +18,9 @@ use crate::{ /// Host-supplied batch sorter for `@apply` Tailwind classes /// (one pre-sort string in, one sorted string out, index-aligned). +/// +/// The sorter owns: ordering, dedup, whitespace collapse, +/// and skipping `{{...}}` template interpolations (Vue/Angular templates). pub type TailwindSorter<'s> = &'s dyn Fn(Vec) -> Vec; /// Parse `source_text` as a stylesheet and build its formatter IR. @@ -26,11 +30,9 @@ pub type TailwindSorter<'s> = &'s dyn Fn(Vec) -> Vec; /// prints them as-is. /// /// # Errors -/// Returns an [`OxcDiagnostic`] when the parse produces any error, including -/// recoverable ones. raffia can recover from some syntax errors, but a tree -/// with errors cannot be formatted faithfully, so a single error is enough to -/// bail out. The caller (oxfmt) decides what to do next -/// (report, or fall back to Prettier). +/// Returns an [`OxcDiagnostic`] when the parse produces any error, including recoverable ones. +/// `raffia` can recover from some syntax errors, but a tree with errors cannot be formatted faithfully, +/// so a single error is enough to bail out. pub fn format<'a>( allocator: &'a Allocator, source_text: &str, @@ -38,6 +40,7 @@ pub fn format<'a>( sort_tailwind_classes: Option>, ) -> Result>, OxcDiagnostic> { let has_bom = source_text.starts_with('\u{feff}'); + let (stylesheet, source, comments) = parse_stylesheet(allocator, source_text, options, /* tolerate_placeholders */ false)?; let front_matter = front_matter_end(source).map(|end| &source[..end]); @@ -45,6 +48,7 @@ pub fn format<'a>( let context = CssFormatContext::new(options, source, comments, /* template_placeholders */ false); let mut state = FormatState::new(context, allocator); + // TODO: Use `with_capacity` for perf, like `oxc_formatter` does let mut buffer = VecBuffer::new(&mut state); write!(&mut buffer, FormatCssRoot { stylesheet: &stylesheet, has_bom, front_matter }); @@ -74,9 +78,10 @@ pub fn format<'a>( /// /// The returned [`EmbeddedIr`] also carries the pre-sort `@apply` Tailwind /// classes the IR's `TailwindClass(index)` elements refer to (empty unless -/// [`CssFormatOptions::sort_tailwindcss`] is on). The parent document owns -/// the batch sort, so the caller must re-index the elements into the parent's -/// class space (`DispatchResult::remap_tailwind_into`). +/// [`CssFormatOptions::sort_tailwindcss`] is on). +/// The parent document owns the batch sort, +/// so the caller must re-index the elements into the parent's class space +/// (`DispatchResult::remap_tailwind_into`). /// /// # Errors /// Same as [`format()`]. @@ -86,14 +91,22 @@ pub fn format_to_ir<'a>( options: CssFormatOptions, ) -> Result, OxcDiagnostic> { let allocator = ctx.allocator; - // The dispatcher input substitutes `${}` interpolations with - // `@prettier-placeholder-N-id` markers, which may sit in value or - // selector position — tolerate them (raffia fork option). - let (stylesheet, source, comments) = - parse_stylesheet(allocator, source_text, options, /* tolerate_placeholders */ true)?; + // css-in-js: The dispatcher input substitutes `${}` interpolations + // with `@prettier-placeholder-N-id` markers, which may sit in value or selector position. + let allow_placeholders = true; + let (stylesheet, source, comments) = parse_stylesheet( + allocator, + source_text, + options, + /* tolerate_placeholders */ allow_placeholders, + )?; - let context = - CssFormatContext::new(options, source, comments, /* template_placeholders */ true); + let context = CssFormatContext::new( + options, + source, + comments, + /* template_placeholders */ allow_placeholders, + ); let mut state = FormatState::new(context, allocator); let mut buffer = VecBuffer::new(&mut state); @@ -107,8 +120,8 @@ pub fn format_to_ir<'a>( /// Parse the source into an AST and collect comments, bailing out on any error. /// -/// Copies the source into the arena (minus any BOM, which raffia would skip -/// anyway) so every slice taken from it carries `'a`. +/// Copies the source into the arena (minus any BOM, which `raffia` would skip anyway) +/// so every slice taken from it carries `'a`. fn parse_stylesheet<'a>( allocator: &'a Allocator, source_text: &str, @@ -116,16 +129,17 @@ fn parse_stylesheet<'a>( tolerate_placeholders: bool, ) -> Result<(Stylesheet<'a>, &'a str, &'a [CssComment]), OxcDiagnostic> { let source_text = source_text.strip_prefix('\u{feff}').unwrap_or(source_text); - // Normalize `\r\n` / lone `\r` to `\n` BEFORE parsing (like Prettier's - // endOfLine pre-pass): the printer slices verbatim text from the source - // in many places (comments, progid, custom properties, ...) and a raw - // `\r` reaching the core `text()` builder panics. Spans stay consistent - // because parse and print both use the normalized copy. + // NOTE: Normalize line endings BEFORE parsing like Prettier, unlike other `oxc_formatter_xxx`. + // For CSS formatter, the printer slices verbatim text from the source in many places. + // (comments, progid, custom properties, ...etc) + // And a raw `\r` reaching the core `text()` builder panics. + // Spans stay consistent because parse and print both use the normalized copy. let source_text = oxc_formatter_core::normalize_newlines(source_text, ['\r']); let source: &'a str = allocator.alloc_str(&source_text); - // Front matter is not CSS: blank it out (preserving line structure so - // spans and gaps stay aligned) and print it verbatim from `source`. + // Front matter is not CSS: + // blank it out (preserving line structure so spans and gaps stay aligned) + // and print it verbatim from `source`. let parse_source: &'a str = match front_matter_end(source) { Some(end) => { let mut blanked = String::with_capacity(source.len()); @@ -141,17 +155,17 @@ fn parse_stylesheet<'a>( let mut comments = vec![]; let mut parser = ParserBuilder::new(parse_source) .syntax(options.variant.to_raffia()) - .options(raffia::ParserOptions { + .options(ParserOptions { tolerate_at_keyword_placeholders: tolerate_placeholders, - ..raffia::ParserOptions::default() + ..ParserOptions::default() }) .comments(&mut comments) .build(); let stylesheet = parser.parse::().map_err(|error| to_diagnostic(&error))?; - // Top-level declarations are recoverable AND accepted by Prettier (postcss - // prints them as-is) — the dominant css-in-js shape (`css`display: flex;``), - // so they must not bail out. Everything else recoverable still does. + // Top-level declarations are recoverable AND accepted by Prettier. (postcss prints them as-is) + // The dominant css-in-js shape (`` css`display: flex;` ``), so they must not bail out. + // Everything else recoverable still does. if let Some(error) = parser .recoverable_errors() .iter() @@ -211,10 +225,10 @@ fn front_matter_end(source: &str) -> Option { None } -/// Best-effort YAML front-matter normalization (Prettier reformats it with -/// its YAML printer): `key: value` spacing and 2-space nesting indents. -/// Returns `None` (→ verbatim) for any construct beyond plain mappings, -/// sequence items and comments — e.g. block scalars, quoted keys. +/// Best-effort YAML front-matter normalization (Prettier reformats it with its YAML printer): +/// `key: value` spacing and 2-space nesting indents. +/// Returns `None` (verbatim) for any construct beyond plain mappings, +/// sequence items and comments, e.g. block scalars, quoted keys. fn try_format_yaml_front_matter(front_matter: &str) -> Option { let inner = front_matter.strip_prefix("---")?.strip_suffix("---")?; let mut out = String::with_capacity(front_matter.len()); diff --git a/crates/oxc_formatter_css/src/lib.rs b/crates/oxc_formatter_css/src/lib.rs index 9a7840db726bc..8fd404365d2f7 100644 --- a/crates/oxc_formatter_css/src/lib.rs +++ b/crates/oxc_formatter_css/src/lib.rs @@ -23,11 +23,11 @@ mod print; /// /// The parent (JS) formatter substitutes each interpolation with /// `@prettier-placeholder-N-id` before dispatching to [`format_to_ir`]. -/// This is Prettier's wire format — its embed (`replacePlaceholders`) -/// expects exactly this shape, so the Prettier fallback path relies on it -/// staying in sync. The producer-side constant lives in `oxc_formatter`'s -/// `embed/css.rs` (which doesn't depend on this crate); orchestrator-side -/// consumers (oxfmt) should use these. +/// This is Prettier's wire format, its embed (`replacePlaceholders`) +/// expects exactly this shape. +/// The producer-side constant lives in `oxc_formatter`'s `embed/css.rs` +/// (which doesn't depend on this crate); +/// Orchestrator-side consumers (oxfmt) should use these. pub const TEMPLATE_PLACEHOLDER_PREFIX: &str = "@prettier-placeholder-"; /// See [`TEMPLATE_PLACEHOLDER_PREFIX`]. pub const TEMPLATE_PLACEHOLDER_SUFFIX: &str = "-id"; diff --git a/crates/oxc_formatter_css/src/options.rs b/crates/oxc_formatter_css/src/options.rs index ce11c1b824dbb..66a7b215b8b84 100644 --- a/crates/oxc_formatter_css/src/options.rs +++ b/crates/oxc_formatter_css/src/options.rs @@ -1,20 +1,20 @@ +use std::borrow::Cow; + +use cow_utils::CowUtils; + use oxc_formatter_core::{ FormatOptions, IndentStyle, IndentWidth, LineEnding, LineWidth, PrinterOptions, }; /// CSS dialect variant. -/// -/// Mirrors Prettier's `css` / `scss` / `less` parsers. #[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] pub enum CssVariant { /// Prettier's `parser: css` equivalent. #[default] Css, /// Prettier's `parser: scss` equivalent. - /// `//` comments, `$var`, maps, control directives, the module system. Scss, /// Prettier's `parser: less` equivalent. - /// `//` comments, `@var`, mixins, guards, detached rulesets. Less, } @@ -26,14 +26,6 @@ impl CssVariant { Self::Less => raffia::Syntax::Less, } } - - pub fn is_scss(self) -> bool { - matches!(self, Self::Scss) - } - - pub fn is_less(self) -> bool { - matches!(self, Self::Less) - } } /// Format options for CSS/SCSS/Less. @@ -47,13 +39,11 @@ pub struct CssFormatOptions { pub line_width: LineWidth, pub line_ending: LineEnding, pub variant: CssVariant, - /// Prefer single quotes for strings. Mirrors Prettier's `singleQuote`. + // Used by: CSS, SCSS, Less pub single_quote: SingleQuote, - // Used by: SCSS (maps only) + // Used by: SCSS pub trailing_commas: TrailingCommas, - /// Collect `@apply` classes as `FormatElement::TailwindClass` for batch - /// sorting (the sort itself is host-supplied). Mirrors - /// `prettier-plugin-tailwindcss`'s CSS transform. + // Used by: CSS, SCSS, Less pub sort_tailwindcss: bool, } @@ -63,6 +53,26 @@ impl CssFormatOptions { pub fn allow_trailing_comma(self) -> bool { matches!(self.trailing_commas, TrailingCommas::Always) } + + /// The quote byte (`b'"'` / `b'\''`) to enclose a string literal whose body is `inner` + /// (the content between the quotes), per Prettier's `getPreferredQuote`: + /// start from the configured preference (`singleQuote`) and flip to the alternate + /// when that reduces escapes (i.e. when the preferred quote occurs more often in `inner` than the alternate). + pub fn preferred_quote(&self, inner: &str) -> u8 { + let (preferred, alternate) = + if self.single_quote.value() { (b'\'', b'"') } else { (b'"', b'\'') }; + // Count every occurrence (escaped ones included, matching `getPreferredQuote`). + let (mut preferred_count, mut alternate_count) = (0u32, 0u32); + for byte in inner.bytes() { + if byte == preferred { + preferred_count += 1; + } else if byte == alternate { + alternate_count += 1; + } + } + + if preferred_count > alternate_count { alternate } else { preferred } + } } /// Whether string literals prefer single quotes (`'`) over double (`"`). @@ -74,6 +84,26 @@ impl SingleQuote { pub fn value(self) -> bool { self.0 } + + pub fn as_char(self) -> char { + if self.0 { '\'' } else { '"' } + } + + pub fn as_str(self) -> &'static str { + if self.0 { "'" } else { "\"" } + } + + /// Prettier's `adjustStrings` for a single token: + /// if `token` contains only the alternate quote and not the preferred one, + /// replace alternates with preferreds. + /// Returns the slice borrowed when no rewrite is needed. + pub fn requote(self, token: &str) -> Cow<'_, str> { + let (preferred, other) = if self.0 { ('\'', '"') } else { ('"', '\'') }; + if !token.contains(other) || token.contains(preferred) { + return Cow::Borrowed(token); + } + token.cow_replace(other, preferred.encode_utf8(&mut [0; 4])) + } } impl From for SingleQuote { diff --git a/crates/oxc_formatter_css/src/print/at_rule.rs b/crates/oxc_formatter_css/src/print/at_rule.rs index c7cca2e8b9d0c..64154d5d21619 100644 --- a/crates/oxc_formatter_css/src/print/at_rule.rs +++ b/crates/oxc_formatter_css/src/print/at_rule.rs @@ -1,68 +1,73 @@ -//! At-rule printing. Ports Prettier's `css-atrule` case and the -//! postcss-media-query-parser printing (`media-*` cases). +//! At-rule printing. +//! Ports Prettier's `css-atrule` case and the `postcss-media-query-parser` printing (`media-*` cases). + +use std::borrow::Cow; use cow_utils::CowUtils; -use oxc_formatter_core::{ - Buffer, FormatElement, - builders::{empty_line, group, hard_line_break, indent, soft_line_break_or_space, space, text}, - write, -}; use raffia::{ Spanned, ast::{ - AtRule, AtRulePrelude, ComponentValue, ImportPrelude, ImportPreludeSupportsKind, - InterpolableStr, KeyframesName, MediaCondition, MediaConditionKind, MediaFeature, - MediaFeatureComparisonKind, MediaFeatureName, MediaInParens, MediaInParensKind, MediaQuery, - MediaQueryList, SupportsCondition, SupportsConditionKind, SupportsInParens, - SupportsInParensKind, + AtRule, AtRulePrelude, ComponentValue, CustomMediaValue, ImportPrelude, ImportPreludeHref, + ImportPreludeSupportsKind, InterpolableStr, KeyframesName, LessImportPrelude, + MediaCondition, MediaConditionKind, MediaFeature, MediaFeatureComparisonKind, + MediaFeatureName, MediaInParens, MediaInParensKind, MediaQuery, MediaQueryList, + NamespacePreludeUri, SassAtRootKind, SimpleBlock, SupportsCondition, SupportsConditionKind, + SupportsInParens, SupportsInParensKind, UnknownAtRulePrelude, + }, + token::{Token, TokenWithSpan}, +}; + +use oxc_formatter_core::{ + Buffer, FormatElement, SourceText, arena_cow_str, + builders::{ + empty_line, group, hard_line_break, indent, soft_line_break, soft_line_break_or_space, + space, text, }, + write, }; use crate::{ - comments::{Gap, classify_gap}, + comments, format::to_span, print::{ - CssFormatter, format_with, scss, selector, - statement::{write_block, write_maybe_lowercase}, + CssFormatter, format_with, normalize_whitespace, scss, selector, statement, value::{self, ValueContext}, + write_maybe_lowercase, }, }; /// Mirrors Prettier's `css-atrule`. -pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); write!(f, "@"); let name_span = to_span(at_rule.name.span()); write_maybe_lowercase(source.text_for(&name_span), f); // css-in-js `${}` markers at statement position parse as at-rules. - // Prettier's `isTemplatePlaceholderNode` rules: the prelude is kept - // verbatim (postcss leaves params containing `@` markers as an unparsed - // string), the gap after the name maps to nothing/space/hardline/blank - // line, and the `;` is printed only when the source has one. + // Prettier's `isTemplatePlaceholderNode` rules: + // the prelude is kept verbatim (postcss leaves params containing `@` markers as an unparsed string), + // the gap after the name maps to nothing/space/hardline/blank line, + // and the `;` is printed only when the source has one. if at_rule.name.raw.starts_with("prettier-placeholder") { write_placeholder_at_rule(at_rule, f); return; } - // Comments inside the params: postcss keeps them embedded in the params - // string / media tokens; reconstruct from the source. - let region_end = crate::print::statement::params_region_end( - at_rule.block.as_ref(), - to_span(at_rule.span()).end, - f, - ); + // Comments inside the params: + // postcss keeps them embedded in the params string / media tokens; reconstruct from the source. + let region_end = + statement::params_region_end(at_rule.block.as_ref(), to_span(at_rule.span()).end, f); let has_params_comments = f .context() .comments() .peek() .is_some_and(|c| c.span.start >= name_span.end && c.span.end <= region_end); - // `@apply` with Tailwind sorting enabled: the class list becomes one - // `TailwindClass` element, sorted in a host-supplied batch after IR - // construction (mirrors prettier-plugin-tailwindcss's `transformCss`, - // which matches `name === "apply"` case-sensitively). Params containing - // comments are left to the normal printers — sorting would corrupt them. + // `@apply` with Tailwind sorting enabled: + // the class list becomes one `TailwindClass` element, + // sorted in a host-supplied batch after IR construction + // (mirrors prettier-plugin-tailwindcss's `transformCss`, which matches `name === "apply"` case-sensitively). + // Params containing comments are left to the normal printers, sorting would corrupt them. if f.options().sort_tailwindcss && at_rule.name.raw == "apply" && at_rule.block.is_none() @@ -75,23 +80,23 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { return; } } + if let Some(prelude) = &at_rule.prelude { - // Prettier's parser hands at-rule params to sub-parsers only for a - // fixed allowlist (parser-postcss.js); for everything else - // `node.params` stays a plain STRING that the printer emits verbatim. + // Prettier's parser hands at-rule params to sub-parsers + // only for a fixed allowlist (`parser-postcss.js`); + // for everything else `node.params` stays a plain STRING that the printer emits verbatim. // raffia's Unknown prelude mostly maps to that "everything else" - // (`@apply`, `@tailwind`, `@custom-variant`, `@source`, …) — - // re-spacing its tokens corrupts constructs like Tailwind's - // `dark:bg-x` or `py-1.5`. The exception: SCSS-family names parsed - // AS CSS (raffia: Unknown, Prettier: parseValue/parseSelector) keep - // the structural printers below. + // (`@apply`, `@tailwind`, `@custom-variant`, `@source`, …), + // re-spacing its tokens corrupts constructs like Tailwind's `dark:bg-x` or `py-1.5`. + // The exception: SCSS-family names parsed AS CSS + // (raffia: Unknown, Prettier: parseValue/parseSelector) keep the structural printers below. let unknown_string_params = matches!(prelude, AtRulePrelude::Unknown(_)) && !is_value_parsed_at_rule(at_rule.name.raw); - // `@warn` / `@error` are the REVERSE exception: raffia parses their - // prelude structurally, but Prettier still keeps the params as a raw - // string (`media-unknown`). - let warn_or_error = matches!(at_rule.name.raw, "warn" | "error"); - if unknown_string_params || warn_or_error { + // NOTE: Prettier also keeps `@warn` / `@error` params as a raw string (`media-unknown`), + // but `raffia` parses their prelude structurally (`SassExpr`), + // so we route them through the normal structured printer + // for internal consistency over Prettier byte-equality. + if unknown_string_params { let prelude_start = to_span(prelude.span()).start; write_verbatim_at_rule_tail( name_span.end, @@ -103,6 +108,7 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { return; } } + // `//` comments have their own layout rules (e.g. less `selector(...)`) // handled by the structural printers below. let has_inline_params_comment = f @@ -135,7 +141,7 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { write_block_or_semicolon(at_rule, f); return; } - // String params: whitespace-normalized verbatim, one line. + // String params: whitespace-normalized verbatim, one line "keyframes" | "page" | "font-feature-values" @@ -148,7 +154,7 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { let _ = f.context().comments().take_before(region_end); if !raw.is_empty() { write!(f, space()); - let normalized = raw.split_whitespace().collect::>().join(" "); + let normalized = normalize_whitespace(raw); write!(f, text(f.allocator().alloc_str(&normalized))); } write_block_or_semicolon(at_rule, f); @@ -158,8 +164,8 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { } } - // SCSS control directives wrap the prelude and the gap before `{` in one - // group: when the prelude breaks, `{` moves to its own line. + // SCSS control directives wrap the prelude and the gap before `{` in one group: + // when the prelude breaks, `{` moves to its own line. let is_control_directive = at_rule.block.is_some() && matches!(at_rule.prelude, Some(AtRulePrelude::SassEach(_) | AtRulePrelude::SassFor(_))) || (matches!(at_rule.prelude, Some(AtRulePrelude::SassExpr(_))) @@ -180,7 +186,7 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { let has_parens = matches!( prelude, AtRulePrelude::SassExpr(value) - if matches!(&**value, raffia::ast::ComponentValue::SassParenthesizedExpression(_)) + if matches!(&**value, ComponentValue::SassParenthesizedExpression(_)) ); if has_parens { write!(f, space()); @@ -208,7 +214,7 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { if f.context().comments().peek().is_some_and(|c| c.inline && c.span.end <= block_start) { for &comment in f.context().comments().take_before(block_start) { write!(f, hard_line_break()); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); } write!(f, hard_line_break()); wrote_comment = true; @@ -216,18 +222,18 @@ pub fn write_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { if !is_control_directive && !wrote_comment { write!(f, space()); } - write_block(block, f); + statement::write_block(block, f); } else { write!(f, ";"); } } -/// Emits `@apply` params with the sortable class list as a single -/// `FormatElement::TailwindClass`. Returns `false` (nothing written) when -/// there is nothing sortable, leaving the caller on the normal path. +/// Emits `@apply` params with the sortable class list as a single `FormatElement::TailwindClass`. +/// Returns `false` (nothing written) when there is nothing sortable, +/// leaving the caller on the normal path. /// -/// Ports prettier-plugin-tailwindcss's `transformCss` pre-processing; the -/// sorter itself (ordering, dedup, whitespace collapse) is host-supplied: +/// Ports prettier-plugin-tailwindcss's `transformCss` pre-processing; +/// the sorter itself (ordering, dedup, whitespace collapse) is host-supplied: /// - a `!important` tail (incl. SCSS `#{!important}` interpolation forms) /// is kept out of the sortable part and re-attached verbatim /// - a Less `~"..."` / `~'...'` escaped-string wrapper is kept and only the @@ -269,8 +275,8 @@ fn write_apply_prelude<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) -> bool { } /// Splits off the `!important` tail the Tailwind plugin ignores when sorting: -/// `/\s+(?:!important|#{(['"]*)!important\1})\s*$/` (whitespace before the -/// tail is required; matching is case-sensitive like the plugin's). +/// `/\s+(?:!important|#{(['"]*)!important\1})\s*$/` +/// (whitespace before the tail is required; matching is case-sensitive like the plugin's). /// Returns `(class part, tail text)` when present. fn split_important_tail(raw: &str) -> Option<(&str, &str)> { let trimmed = raw.trim_end(); @@ -282,8 +288,9 @@ fn split_important_tail(raw: &str) -> Option<(&str, &str)> { ) } -/// `@prettier-placeholder-N-id` at-rule body: verbatim prelude, source-driven -/// spacing, `;` only when the source has one. See `write_at_rule`. +/// `@prettier-placeholder-N-id` at-rule body: +/// verbatim prelude, source-driven spacing, `;` only when the source has one. +/// See `write_at_rule`. fn write_placeholder_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); if let Some(prelude) = &at_rule.prelude { @@ -305,28 +312,29 @@ fn write_placeholder_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, } } } else { - // A `;`-less placeholder swallows the FOLLOWING statements into - // its prelude, so their leading comments land in this gap; print - // them with source line structure instead of discarding them. + // A `;`-less placeholder swallows the FOLLOWING statements into its prelude, + // so their leading comments land in this gap; + // print them with source line structure instead of discarding them. for &comment in &f.context().comments().take_before(prelude_span.start).to_vec() { write_placeholder_gap(source, pos, comment.span.start, f); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); pos = comment.span.end; } write_placeholder_gap(source, pos, prelude_span.start, f); pos = prelude_span.start; } - // The rest is verbatim; embedded newlines stay literal (both Prettier - // and the parent template printer treat them as `literalline`s). + // The rest is verbatim; embedded newlines stay literal + // (both Prettier and the parent template printer treat them as `literalline`s). write!(f, text(source.slice_range(pos, prelude_span.end))); let _ = f.context().comments().take_before(prelude_span.end); } + if at_rule.block.is_some() { write_block_or_semicolon(at_rule, f); } else { let end = to_span(at_rule.span()).end; - if crate::print::statement::end_with_semicolon(end, f) > end { + if statement::end_with_semicolon(end, f) > end { write!(f, ";"); } } @@ -334,7 +342,7 @@ fn write_placeholder_at_rule<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, /// Source-driven separator inside a placeholder at-rule (see above). fn write_placeholder_gap( - source: oxc_formatter_core::SourceText<'_>, + source: SourceText<'_>, start: u32, end: u32, f: &mut CssFormatter<'_, '_>, @@ -342,18 +350,19 @@ fn write_placeholder_gap( if start == end { return; } - match classify_gap(source.bytes_range(start, end)) { - Gap::None => write!(f, space()), - Gap::Line => write!(f, hard_line_break()), - Gap::Blank => write!(f, empty_line()), + match comments::classify_gap(source.bytes_range(start, end)) { + comments::Gap::None => write!(f, space()), + comments::Gap::Line => write!(f, hard_line_break()), + comments::Gap::Blank => write!(f, empty_line()), } } /// Names whose params Prettier's parser DOES hand to a sub-parser -/// (parseValue / parseSelector / parseMediaQuery — parser-postcss.js), so a -/// raffia Unknown prelude for them must keep the structural printers. -/// Case-sensitivity mirrors Prettier: bare `name` comparisons for the SCSS -/// family, lowercased for module/media rules. +/// (parseValue / parseSelector / parseMediaQuery — parser-postcss.js), +/// so a `raffia` Unknown prelude for them must keep the structural printers. +/// +/// Case-sensitivity mirrors Prettier: +/// bare `name` comparisons for the SCSS family, lowercased for module/media rules. fn is_value_parsed_at_rule(name: &str) -> bool { matches!( name, @@ -385,10 +394,10 @@ fn is_value_parsed_at_rule(name: &str) -> bool { /// plain string (see the Unknown-prelude early return in `write_at_rule`). /// The slice runs from the at-rule NAME to the block/`;` so comments stay /// embedded exactly like postcss's `afterName + params` string. -pub fn write_verbatim_at_rule_tail<'a>( +pub(super) fn write_verbatim_at_rule_tail<'a>( name_end: u32, prelude_start: u32, - block: Option<&raffia::ast::SimpleBlock<'a>>, + block: Option<&SimpleBlock<'a>>, region_end: u32, f: &mut CssFormatter<'_, 'a>, ) { @@ -396,22 +405,22 @@ pub fn write_verbatim_at_rule_tail<'a>( let raw = source.slice_range(name_end, region_end).trim(); let _ = f.context().comments().take_before(region_end); if !raw.is_empty() { - // postcss keeps a no-gap prelude fused to the NAME (`@a:b` stays - // tight) — but a leading `(` still gets the printer's space. + // postcss keeps a no-gap prelude fused to the NAME (`@a:b` stays tight), + // but a leading `(` still gets the printer's space. if name_end != prelude_start || raw.starts_with('(') { write!(f, space()); } write!(f, text(raw)); } if let Some(block) = block { - // Prettier's `lastLineHasInlineComment`: a trailing `//` line pushes - // `{` to the next line (it would be swallowed by the comment). + // Prettier's `lastLineHasInlineComment`: + // a trailing `//` line pushes `{` to the next line (it would be swallowed by the comment). if raw.split('\n').next_back().is_some_and(|line| line.contains("//")) { write!(f, hard_line_break()); } else { write!(f, space()); } - write_block(block, f); + statement::write_block(block, f); } else { write!(f, ";"); } @@ -421,17 +430,17 @@ pub fn write_verbatim_at_rule_tail<'a>( fn write_block_or_semicolon<'a>(at_rule: &AtRule<'a>, f: &mut CssFormatter<'_, 'a>) { if let Some(block) = &at_rule.block { write!(f, space()); - write_block(block, f); + statement::write_block(block, f); } else { write!(f, ";"); } } /// `@media` params containing comments, rebuilt the way -/// postcss-media-query-parser + Prettier's `media-*` cases lay them out: -/// queries split on top-level commas (`,` + line in a group), tokens joined -/// by single spaces, `( feature : value )` re-spaced when the parser would -/// have recognized it (spaces around the `:`), kept verbatim otherwise. +/// `postcss-media-query-parser` + Prettier's `media-*` cases lay them out: +/// queries split on top-level commas (`,` + line in a group), +/// tokens joined by single spaces, `( feature : value )` re-spaced +/// when the parser would have recognized it (spaces around the `:`), kept verbatim otherwise. fn write_commented_media_params<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) { let queries = split_top_level(raw, b','); let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { @@ -440,7 +449,7 @@ fn write_commented_media_params<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) write!(f, ","); write!(f, soft_line_break_or_space()); } - let tokens = media_tokens(query.trim()); + let tokens = tokenize(query.trim(), TokenizeMode::AbsorbComments); for (j, token) in tokens.iter().enumerate() { if j > 0 { write!(f, space()); @@ -452,18 +461,19 @@ fn write_commented_media_params<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) write!(f, group(&indent(&body))); } -/// `@import` / `@supports` params containing comments, laid out the way -/// Prettier's value parser + fill does. Separator breaks are simulated with -/// static widths (Prettier's fill fit-checks the next item; our core fill -/// measures only up to a hardline). `allow_commas`: comma chunks get an -/// extra indent level for their internal wraps. +/// `@import` / `@supports` params containing comments, +/// laid out the way Prettier's value parser + fill does. +/// (Required because `raffia` drops params-embedded comments, they only survive via this source-rebuild.) +/// Separator breaks are simulated with static widths +/// (Prettier's fill fit-checks the next item; our core fill measures only up to a hardline). +/// `allow_commas`: comma chunks get an extra indent level for their internal wraps. fn write_commented_value_params<'a>( raw: &'a str, start_col: u32, allow_commas: bool, f: &mut CssFormatter<'_, 'a>, ) { - // One value-parser token (the comma glues to its chunk's tail). + // One value-parser token (the comma glues to its chunk's tail) struct Tok<'a> { text: &'a str, comma: bool, @@ -476,7 +486,7 @@ fn write_commented_value_params<'a>( let mut tokens: Vec> = vec![]; for (ci, chunk) in chunks.iter().enumerate() { - let toks = value_tokens(chunk.trim()); + let toks = tokenize(chunk.trim(), TokenizeMode::SplitComments); let last = toks.len().saturating_sub(1); for (ti, t) in toks.into_iter().enumerate() { tokens.push(Tok { @@ -538,7 +548,7 @@ fn write_commented_value_params<'a>( } if t.hard { write_structured_paren(t.text, x, f); - // `)` lands back at column x. + // `)` lands back at column x x += 1; } else { let printed = requote_token(t.text, f); @@ -554,9 +564,6 @@ fn write_commented_value_params<'a>( write!(f, indent(&body)); } -/// Value-parser tokens: comments are standalone nodes (split even when -/// touching their neighbors), paren groups glue to their leading word -/// (`url(...)`) and end at `)`. /// `start` points at the `/` of a `/*`; returns the index of the closing `/` /// (clamped to the last byte when unterminated). fn block_comment_end(bytes: &[u8], start: usize) -> usize { @@ -567,7 +574,22 @@ fn block_comment_end(bytes: &[u8], start: usize) -> usize { (i + 1).min(bytes.len() - 1) } -fn value_tokens(raw: &str) -> Vec<&str> { +#[derive(Clone, Copy, PartialEq, Eq)] +enum TokenizeMode { + /// `@import` / `@supports` values: + /// a top-level `/* ... */` is its own token (postcss-values splits comments out). + SplitComments, + /// `@media` queries: + /// a top-level `/* ... */` absorbs into the surrounding /// token + /// (`postcss-media-query-parser` keeps comments inline). + AbsorbComments, +} + +/// Whitespace-separated tokens; +/// comments and balanced paren regions glue to adjacent touching text. +/// A `)` closing a single-line paren region ends the token (postcss-media-query-parser splits there); +/// a multi-line region is a `media-unknown` and keeps its touching suffix. +fn tokenize(raw: &str, mode: TokenizeMode) -> Vec<&str> { let bytes = raw.as_bytes(); let mut tokens = vec![]; let mut depth = 0i32; @@ -575,16 +597,22 @@ fn value_tokens(raw: &str) -> Vec<&str> { let mut i = 0usize; while i < bytes.len() { match bytes[i] { - b'/' if bytes.get(i + 1) == Some(&b'*') && depth == 0 => { - if let Some(s) = start.take() { - tokens.push(&raw[s..i]); - } - let end = block_comment_end(bytes, i); - tokens.push(&raw[i..=end]); - i = end; - } b'/' if bytes.get(i + 1) == Some(&b'*') => { - i = block_comment_end(bytes, i); + if depth == 0 && mode == TokenizeMode::SplitComments { + if let Some(s) = start.take() { + tokens.push(&raw[s..i]); + } + let end = block_comment_end(bytes, i); + tokens.push(&raw[i..=end]); + i = end; + } else { + // depth > 0 OR AbsorbComments at depth 0: + // comment becomes part of the current token (or starts one if between tokens). + if start.is_none() { + start = Some(i); + } + i = block_comment_end(bytes, i); + } } b'(' => { if start.is_none() { @@ -619,16 +647,12 @@ fn value_tokens(raw: &str) -> Vec<&str> { tokens } -/// Re-quotes `'...'` strings in a token to the preferred quote (Prettier's -/// `adjustStrings`). +/// Re-quotes `'...'` strings in a token to the preferred quote (Prettier's `adjustStrings`). fn requote_token<'a>(token: &'a str, f: &CssFormatter<'_, 'a>) -> &'a str { - let preferred = if f.options().single_quote.value() { '\'' } else { '"' }; - let other = if preferred == '"' { '\'' } else { '"' }; - if !token.contains(other) || token.contains(preferred) { - return token; + match f.options().single_quote.requote(token) { + Cow::Borrowed(s) => s, + Cow::Owned(s) => f.allocator().alloc_str(&s), } - let replaced = token.cow_replace(other, preferred.encode_utf8(&mut [0; 4])); - f.allocator().alloc_str(&replaced) } /// A comment-bearing paren group always breaks: @@ -645,18 +669,11 @@ fn write_structured_paren<'a>(token: &'a str, open_col: u32, f: &mut CssFormatte }; write!(f, text(&token[..=open])); let inner = &token[open + 1..close]; - // Words, requoted up front; a lone `:` glues to the previous word. - let preferred = if f.options().single_quote.value() { '\'' } else { '"' }; - let other = if preferred == '"' { '\'' } else { '"' }; - let requote = |w: &str| -> String { - if w.contains(other) && !w.contains(preferred) { - w.cow_replace(other, preferred.encode_utf8(&mut [0; 4])).into_owned() - } else { - w.to_string() - } - }; + // Words, requoted up front; a lone `:` glues to the previous word + let sq = f.options().single_quote; + let requote = |w: &str| -> String { sq.requote(w).into_owned() }; let mut words: Vec = vec![]; - for w in value_tokens(inner.trim()) { + for w in tokenize(inner.trim(), TokenizeMode::SplitComments) { if (w == ":" || w.starts_with(':')) && let Some(last) = words.last_mut() { @@ -722,57 +739,6 @@ fn split_top_level(raw: &str, sep: u8) -> Vec<&str> { parts } -/// Whitespace-separated tokens; comments and balanced paren regions glue to -/// adjacent touching text. A `)` closing a single-line paren region ends the -/// token (postcss-media-query-parser splits there); a multi-line region is a -/// `media-unknown` and keeps its touching suffix. -fn media_tokens(raw: &str) -> Vec<&str> { - let bytes = raw.as_bytes(); - let mut tokens = vec![]; - let mut depth = 0i32; - let mut start: Option = None; - let mut i = 0usize; - while i < bytes.len() { - match bytes[i] { - b'/' if bytes.get(i + 1) == Some(&b'*') => { - if start.is_none() { - start = Some(i); - } - i = block_comment_end(bytes, i); - } - b'(' => { - if start.is_none() { - start = Some(i); - } - depth += 1; - } - b')' => { - depth -= 1; - if depth == 0 - && let Some(s) = start.take() - { - tokens.push(&raw[s..=i]); - } - } - b' ' | b'\t' | b'\n' | b'\r' if depth == 0 => { - if let Some(s) = start.take() { - tokens.push(&raw[s..i]); - } - } - _ => { - if start.is_none() { - start = Some(i); - } - } - } - i += 1; - } - if let Some(s) = start { - tokens.push(&raw[s..]); - } - tokens -} - /// One media token: a paren region (with glued prefix/suffix) gets the /// `( feature: value )` re-spacing when the `:` has space around it. fn write_media_token<'a>(token: &'a str, f: &mut CssFormatter<'_, 'a>) { @@ -792,7 +758,7 @@ fn write_media_token<'a>(token: &'a str, f: &mut CssFormatter<'_, 'a>) { before_ws || after_ws }); if !respace { - // Unparsable for postcss-media-query-parser: verbatim. + // Unparsable for postcss-media-query-parser: verbatim write!(f, text(token)); return; } @@ -800,11 +766,7 @@ fn write_media_token<'a>(token: &'a str, f: &mut CssFormatter<'_, 'a>) { write!(f, text(&token[..=open])); let feature = inner[..colon].trim(); let value = inner[colon + 1..].trim(); - let normalized = format!( - "{}: {}", - feature.split_whitespace().collect::>().join(" "), - value.split_whitespace().collect::>().join(" ") - ); + let normalized = format!("{}: {}", normalize_whitespace(feature), normalize_whitespace(value)); write!(f, text(f.allocator().alloc_str(&normalized))); write!(f, text(&token[close..])); } @@ -835,11 +797,10 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' write!(f, text(source.text_for(&name_span))); write!(f, space()); match &custom.value { - raffia::ast::CustomMediaValue::MediaQueryList(list) => { + CustomMediaValue::MediaQueryList(list) => { write_media_query_list(list, f); } - raffia::ast::CustomMediaValue::True(ident) - | raffia::ast::CustomMediaValue::False(ident) => { + CustomMediaValue::True(ident) | CustomMediaValue::False(ident) => { let span = to_span(ident.span()); write!(f, text(source.text_for(&span))); } @@ -849,7 +810,7 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' AtRulePrelude::CustomSelector(custom) => { let custom_span = to_span(custom.custom_selector.span()); let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { - // raffia's CustomSelector span excludes the leading `:`. + // raffia's CustomSelector span excludes the leading `:` write!(f, ":"); write!(f, text(source.text_for(&custom_span))); write!(f, soft_line_break_or_space()); @@ -878,17 +839,20 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' write!(f, space()); } match &namespace.uri { - raffia::ast::NamespacePreludeUri::Str(InterpolableStr::Literal(str)) => { + NamespacePreludeUri::Str(InterpolableStr::Literal(str)) => { value::write_str(str, f); } - raffia::ast::NamespacePreludeUri::Url(url) => value::write_url(url, f), - uri @ raffia::ast::NamespacePreludeUri::Str(_) => { + NamespacePreludeUri::Url(url) => value::write_url(url, f), + // Interpolated strings (`'#{$url}'` etc.): + // outer quote is still requoted per `singleQuote`, content is verbatim + // (postcss-values' `value-unknown` path, same as `ComponentValue::InterpolableStr` in value position). + uri @ NamespacePreludeUri::Str(_) => { let span = to_span(uri.span()); - write!(f, text(source.text_for(&span))); + value::write_requoted_verbatim(source.text_for(&span), f); } } } - // Prettier keeps `@page` params verbatim (e.g. `@page:first` stays). + // Prettier keeps `@page` params verbatim (e.g. `@page:first` stays) AtRulePrelude::Page(page) => { let span = to_span(page.span()); write!(f, text(source.text_for(&span))); @@ -915,10 +879,10 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' selector::write_selector_list(list, selector::SelectorListStyle::Line, f); } AtRulePrelude::SassAtRoot(at_root) => match &at_root.kind { - raffia::ast::SassAtRootKind::Selector(list) => { + SassAtRootKind::Selector(list) => { selector::write_selector_list(list, selector::SelectorListStyle::Line, f); } - raffia::ast::SassAtRootKind::Query(query) => { + SassAtRootKind::Query(query) => { let span = to_span(query.span()); write!(f, text(source.text_for(&span))); } @@ -934,19 +898,18 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' AtRulePrelude::SassUse(sass_use) => scss::write_sass_use(sass_use, f), AtRulePrelude::SassForward(forward) => scss::write_sass_forward(forward, f), AtRulePrelude::SassImport(import) => { - // Comments force the path list to break, one path per line. + // Comments force the path list to break, one path per line let last_end = to_span(import.span()).end; let has_comments = f.context().comments().iter_before(last_end).next().is_some(); if has_comments && import.paths.len() > 1 { - // Comments fuse with the following path into ONE fill chunk - // (Prettier's comma_group). Prettier's fill treats a chunk - // with a hardline as never-fitting — our core fill measures - // up to the hardline and calls it fit — so the separator - // breaks are simulated here with static widths. - let all: Vec = + // Comments fuse with the following path into ONE fill chunk (Prettier's `commaGroup`). + // Prettier's fill treats a chunk with a hardline as never-fitting, + // our core fill measures up to the hardline and calls it fit. + // So the separator breaks are simulated here with static widths. + let all: Vec = f.context().comments().iter_before(last_end).collect(); let n = import.paths.len(); - let mut leads: Vec> = Vec::with_capacity(n); + let mut leads: Vec> = Vec::with_capacity(n); for (i, path) in import.paths.iter().enumerate() { let path_start = to_span(path.span()).start; leads.push( @@ -960,8 +923,7 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' ); } // Prettier fill: separator stays flat only when - // [chunk, ", ", next chunk] fits and neither chunk has a - // comment (hardline). + // [chunk, ", ", next chunk] fits and neither chunk has a comment (hardline). let width = u32::from(f.options().line_width.value()); let indent_w = u32::from(f.options().indent_width.value()); let chunk_w: Vec = import @@ -1000,7 +962,7 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' } for &comment in &leads[i] { f.context().comments().take_before(comment.span.end); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { write!(f, hard_line_break()); } else { @@ -1017,10 +979,10 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' } else if has_comments { let path = &import.paths[0]; let path_start = to_span(path.span()).start; - let lead: Vec = + let lead: Vec = f.context().comments().take_before(path_start).to_vec(); for &comment in &lead { - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { write!(f, hard_line_break()); } else { @@ -1029,9 +991,9 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' } value::write_str(path, f); } else { - // Comma-separated path list: Prettier value-parses `@import` - // params (module rule) and fills them — long lists wrap at - // the line width with a continuation indent. + // Comma-separated path list: + // Prettier value-parses `@import` params (module rule) and fills them, + // long lists wrap at the line width with a continuation indent. let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut filler = f.fill(); let n = import.paths.len(); @@ -1049,11 +1011,10 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' write!(f, group(&indent(&body))); } } - // Only reached for SCSS-family names parsed AS CSS (see - // `is_value_parsed_at_rule`); other Unknown preludes print verbatim - // via the `write_at_rule` early return. + // Only reached for SCSS-family names parsed AS CSS (see `is_value_parsed_at_rule`); + // other `Unknown` preludes print verbatim via the `write_at_rule` early return. AtRulePrelude::Unknown(unknown) => match &**unknown { - raffia::ast::UnknownAtRulePrelude::ComponentValue(value) => { + UnknownAtRulePrelude::ComponentValue(value) => { if matches!(value, ComponentValue::InterpolableStr(_)) { let span = to_span(value.span()); write!(f, text(source.text_for(&span))); @@ -1061,11 +1022,14 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' value::write_component_value(value, ValueContext::default(), f); } } - raffia::ast::UnknownAtRulePrelude::TokenSeq(seq) => { - write_token_seq(seq, f); + UnknownAtRulePrelude::TokenSeq(seq) => { + // Prints a raw token sequence, + // collapsing whitespace runs to a single breakable space and keeping tight tokens tight + // (mirrors how Prettier's `parseValue` + comma-group printing treats unknown at-rule params). + write_token_value(&seq.tokens, true, f); } }, - // Sass/Less and not-yet-ported preludes: verbatim. + // Sass/Less and not-yet-ported preludes: verbatim _ => { let span = to_span(prelude.span()); write!(f, text(source.text_for(&span))); @@ -1073,22 +1037,7 @@ fn write_at_rule_prelude<'a>(prelude: &AtRulePrelude<'a>, f: &mut CssFormatter<' } } -/// Prints a raw token sequence, collapsing whitespace runs to a single -/// breakable space and keeping tight tokens tight (mirrors how Prettier's -/// `parseValue` + comma-group printing treats unknown at-rule params). -pub fn write_token_seq<'a>(seq: &raffia::ast::TokenSeq<'a>, f: &mut CssFormatter<'_, 'a>) { - write_tokens(&seq.tokens, f); -} - -/// Mini value printer over a raw token stream, normalizing whitespace the way -/// postcss-values-parser + Prettier do: `, ` after commas (no space before), -/// `(`/`)` hug their contents, `:` hugs left, math operators break after. -fn write_tokens<'a>(tokens: &[raffia::token::TokenWithSpan<'a>], f: &mut CssFormatter<'_, 'a>) { - write_token_value(tokens, true, f); -} - -fn token_depth_delta(token: &raffia::token::Token<'_>) -> i32 { - use raffia::token::Token; +fn token_depth_delta(token: &Token<'_>) -> i32 { match token { Token::LParen(_) | Token::LBracket(_) | Token::LBrace(_) => 1, Token::RParen(_) | Token::RBracket(_) | Token::RBrace(_) => -1, @@ -1096,17 +1045,16 @@ fn token_depth_delta(token: &raffia::token::Token<'_>) -> i32 { } } -/// Comma-separated token value: groups joined by `, ` (breakable), blank -/// lines preserved after multi-token groups. +/// Comma-separated token value: +/// groups joined by `, ` (breakable), blank lines preserved after multi-token groups. fn write_token_value<'a>( - tokens: &[raffia::token::TokenWithSpan<'a>], + tokens: &[TokenWithSpan<'a>], top_level: bool, f: &mut CssFormatter<'_, 'a>, ) { - use raffia::token::Token; let source = f.context().source_text(); - // Split at top-level commas. - let mut groups: Vec<&[raffia::token::TokenWithSpan<'a>]> = vec![]; + // Split at top-level commas + let mut groups: Vec<&[TokenWithSpan<'a>]> = vec![]; let mut depth = 0i32; let mut start = 0; for (i, tok) in tokens.iter().enumerate() { @@ -1123,8 +1071,8 @@ fn write_token_value<'a>( if groups.len() == 1 { let only = groups[0]; - // `name( ... )` covering the whole group: the parens govern - // breaking/indent; anything else gets the continuation indent. + // `name( ... )` covering the whole group: + // the parens govern breaking/indent; anything else gets the continuation indent. let whole_call = only.len() > 2 && matches!(&only[only.len() - 1].token, Token::RParen(_)) && (matches!(&only[0].token, Token::LParen(_)) @@ -1144,7 +1092,7 @@ fn write_token_value<'a>( } let groups_ref = &groups; if top_level { - // Top level: fill (as many groups per line as fit). + // Top level: fill (as many groups per line as fit) let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut filler = f.fill(); for (i, group_tokens) in groups_ref.iter().enumerate() { @@ -1161,9 +1109,9 @@ fn write_token_value<'a>( }); write!(f, group(&indent(&body))); } else { - // Inside parens: `join(line, ...)` — when the paren group breaks, - // every group goes on its own line; blank lines are preserved after - // key-value-ish groups. + // Inside parens: `join(line, ...)` when the paren group breaks, + // every group goes on its own line; + // blank lines are preserved after key-value-ish groups. for (i, group_tokens) in groups_ref.iter().enumerate() { if i > 0 { write!(f, ","); @@ -1173,11 +1121,11 @@ fn write_token_value<'a>( groups_ref[i - 1].last().map_or(0, |t| to_span(t.span()).end); let next_start = group_tokens.first().map_or(prev_end, |t| to_span(t.span()).start); - crate::comments::classify_gap(source.bytes_range(prev_end, next_start)) - == crate::comments::Gap::Blank + comments::classify_gap(source.bytes_range(prev_end, next_start)) + == comments::Gap::Blank }; if sep_blank { - write!(f, oxc_formatter_core::builders::empty_line()); + write!(f, empty_line()); } else { write!(f, soft_line_break_or_space()); } @@ -1187,13 +1135,9 @@ fn write_token_value<'a>( } } -/// Space-separated tokens within one comma group; balanced paren regions are -/// printed as breakable groups. -fn write_token_comma_group<'a>( - tokens: &[raffia::token::TokenWithSpan<'a>], - f: &mut CssFormatter<'_, 'a>, -) { - use raffia::token::Token; +/// Space-separated tokens within one comma group; +/// balanced paren regions are printed as breakable groups. +fn write_token_comma_group<'a>(tokens: &[TokenWithSpan<'a>], f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let hug_lparen = tokens.len() > 1 @@ -1204,10 +1148,10 @@ fn write_token_comma_group<'a>( let mut filler = f.fill(); let mut i = 0; while i < tokens.len() { - // A run: tokens glued by gap/punctuation rules; a paren region opener - // ends the run scan (the region is appended to the same run). + // A run: tokens glued by gap/punctuation rules; + // a paren region opener ends the run scan (the region is appended to the same run). let mut run_end = i + 1; - // A run starting at an opener swallows its balanced region. + // A run starting at an opener swallows its balanced region if matches!(&tokens[i].token, Token::LParen(_) | Token::LBracket(_) | Token::LBrace(_)) { let mut depth = 0i32; let mut j = i; @@ -1224,15 +1168,15 @@ fn write_token_comma_group<'a>( let prev = &tokens[run_end - 1]; let curr = &tokens[run_end]; if matches!(&curr.token, Token::LParen(_) | Token::LBracket(_) | Token::LBrace(_)) { - // Opener glues to the run when fused in source, after a - // colon (`$arg: (...)`), or as a call (`name(...)`). + // Opener glues to the run when fused in source, + // after a colon (`$arg: (...)`), or as a call (`name(...)`). let glued = to_span(prev.span()).end == to_span(curr.span()).start || matches!(&prev.token, Token::Colon(_)) || (run_end == 1 && hug_lparen); if !glued { break; } - // Append the whole balanced region (and continue the run). + // Append the whole balanced region (and continue the run) let mut depth = 0i32; let mut j = run_end; while j < tokens.len() { @@ -1283,22 +1227,18 @@ fn write_token_comma_group<'a>( filler.finish(); } -/// Wrapper: a comma group is its own breakable group with indent — -/// except when it contains paren regions, which provide their own -/// indentation (avoids double-indenting `name(...)` contents). -fn write_token_comma_group_grouped<'a>( - tokens: &[raffia::token::TokenWithSpan<'a>], - f: &mut CssFormatter<'_, 'a>, -) { - use raffia::token::Token; - // A group that IS one call/paren region delegates breaking to the parens. +/// Wrapper: a comma group is its own breakable group with indent. +/// Except when it contains paren regions, +/// which provide their own indentation (avoids double-indenting `name(...)` contents). +fn write_token_comma_group_grouped<'a>(tokens: &[TokenWithSpan<'a>], f: &mut CssFormatter<'_, 'a>) { + // A group that IS one call/paren region delegates breaking to the parens let whole_region = !tokens.is_empty() && matches!(&tokens[tokens.len() - 1].token, Token::RParen(_)) && (matches!(&tokens[0].token, Token::LParen(_)) || (tokens.len() > 1 && matches!(&tokens[0].token, Token::Ident(_)) && matches!(&tokens[1].token, Token::LParen(_)))); - // A `$key: (region)` pair also delegates to the parens. + // A `$key: (region)` pair also delegates to the parens let kv_region = !whole_region && matches!(tokens.last().map(|t| &t.token), Some(Token::RParen(_))) && tokens @@ -1318,17 +1258,12 @@ fn write_token_comma_group_grouped<'a>( } /// One run: spacing normalized; paren regions recurse as breakable groups. -fn write_token_run<'a>( - run: &[raffia::token::TokenWithSpan<'a>], - hug_lparen: bool, - f: &mut CssFormatter<'_, 'a>, -) { - use raffia::token::Token; +fn write_token_run<'a>(run: &[TokenWithSpan<'a>], hug_lparen: bool, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let mut j = 0; while j < run.len() { let tok = &run[j]; - // A paren region: recurse. + // A paren region: recurse if matches!(&tok.token, Token::LParen(_) | Token::LBracket(_) | Token::LBrace(_)) { let mut depth = 0i32; let mut k = j; @@ -1339,7 +1274,7 @@ fn write_token_run<'a>( break; } } - // Unbalanced region: print the opener verbatim and move on. + // Unbalanced region: print the opener verbatim and move on if depth != 0 || k < j + 2 { if j > 0 { write_token_pair_space(run, j, hug_lparen, f); @@ -1362,7 +1297,7 @@ fn write_token_run<'a>( write!(f, [text(open), text(close)]); } else { let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { - write!(f, oxc_formatter_core::builders::soft_line_break()); + write!(f, soft_line_break()); write_token_value(inner, false, f); }); write!( @@ -1370,7 +1305,7 @@ fn write_token_run<'a>( group(&format_with(move |f: &mut CssFormatter<'_, 'a>| { write!(f, text(open)); write!(f, indent(&body)); - write!(f, oxc_formatter_core::builders::soft_line_break()); + write!(f, soft_line_break()); write!(f, text(close)); })) ); @@ -1386,22 +1321,15 @@ fn write_token_run<'a>( Token::Str(_) => { write_raw_str(source.text_for(&span), f); } - Token::Number(n) => match value::print_css_number(n.raw) { - std::borrow::Cow::Borrowed(s) => write!(f, text(s)), - std::borrow::Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - }, + Token::Number(n) => { + write!(f, text(arena_cow_str(&value::print_css_number(n.raw), f))); + } Token::Dimension(d) => { - match value::print_css_number(d.value.raw) { - std::borrow::Cow::Borrowed(s) => write!(f, text(s)), - std::borrow::Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + write!(f, text(arena_cow_str(&value::print_css_number(d.value.raw), f))); write!(f, text(d.unit.raw)); } Token::Percentage(pct) => { - match value::print_css_number(pct.value.raw) { - std::borrow::Cow::Borrowed(s) => write!(f, text(s)), - std::borrow::Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + write!(f, text(arena_cow_str(&value::print_css_number(pct.value.raw), f))); write!(f, "%"); } _ => write!(f, text(source.text_for(&span))), @@ -1413,12 +1341,11 @@ fn write_token_run<'a>( /// Normalized spacing between `run[j-1]` and `run[j]`. #[expect(clippy::match_same_arms)] fn write_token_pair_space<'a>( - run: &[raffia::token::TokenWithSpan<'a>], + run: &[TokenWithSpan<'a>], j: usize, hug_lparen: bool, f: &mut CssFormatter<'_, 'a>, ) { - use raffia::token::Token; let prev = &run[j - 1]; let tok = &run[j]; let gap = to_span(prev.span()).end != to_span(tok.span()).start; @@ -1463,27 +1390,16 @@ fn write_token_pair_space<'a>( } fn write_raw_str<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) { - let single_quote = f.options().single_quote.value(); if raw.len() < 2 { write!(f, text(raw)); return; } let content = &raw[1..raw.len() - 1]; - let (preferred, alternate) = if single_quote { ('\'', '"') } else { ('"', '\'') }; - let mut preferred_count = 0usize; - let mut alternate_count = 0usize; - for b in content.bytes() { - if b == preferred as u8 { - preferred_count += 1; - } else if b == alternate as u8 { - alternate_count += 1; - } - } - let enclosing = if preferred_count > alternate_count { alternate } else { preferred }; - if raw.as_bytes()[0] == enclosing as u8 { + let enclosing = f.options().preferred_quote(content); + if raw.as_bytes()[0] == enclosing { write!(f, text(raw)); } else { - let out = format!("{enclosing}{content}{enclosing}"); + let out = format!("{ch}{content}{ch}", ch = enclosing as char); write!(f, text(f.allocator().alloc_str(&out))); } } @@ -1516,53 +1432,52 @@ fn write_import_prelude_inner<'a>(import: &ImportPrelude<'a>, f: &mut CssFormatt write!(f, text(source.text_for(&span))); } if let Some(supports) = &import.supports { - // `@import ... supports()`. Prettier value-parses `@import` - // params (a token stream); we instead reprint through the `@supports` - // structured printers (raffia parses it structurally). Identical for - // real-world cases — the divergences are all edge cases absent from - // real CSS: inherited from `write_supports_condition` (uppercase props - // lowercase; a source-glued `not(`/`and(` gains a space), plus one of - // our own (a width-overflowing condition with no trailing media breaks - // INSIDE the parens, not before `supports`). Empty `supports()` was the - // prior data-loss stub. + // `@import ... supports()`. + // Prettier value-parses `@import` params (a token stream); + // we instead reprint through the `@supports` structured printers (`raffia` parses it structurally). + // Identical for real-world cases, the divergences are all edge cases absent from real CSS: + // inherited from `write_supports_condition` + // (uppercase props lowercase; a source-glued `not`/`and` gains a space), + // plus one of our own (a width-overflowing condition with no trailing media breaks INSIDE the parens, not before `supports`). + // Empty `supports()` was the prior data-loss stub. write!(f, [space(), "supports("]); match &supports.kind { // `supports(not (display: inline-grid))`, `supports(font-format(woff2))` ImportPreludeSupportsKind::SupportsCondition(condition) => { write_supports_condition(condition, f); } - // `supports(display: flex)` — a bare declaration (no inner parens) + // `supports(display: flex)`: a bare declaration (no inner parens) ImportPreludeSupportsKind::Declaration(decl) => { - crate::print::statement::write_declaration_inline(decl, f); + statement::write_declaration(decl, f); } } write!(f, ")"); } if let Some(media) = &import.media { - write!(f, oxc_formatter_core::builders::soft_line_break_or_space()); - // No own group/indent: the queries share the prelude-level group. + write!(f, soft_line_break_or_space()); + // No own group/indent: the queries share the prelude-level group write_media_query_list_inner(media, f); } } /// Prints an `@import` href; the quote of a string path is normalized per -/// `singleQuote` like Prettier's `adjustStrings` (interpolated paths re-quote -/// the OUTER quotes only, keeping `@{var}` / `#{}` content verbatim). -fn write_import_href<'a>(href: &raffia::ast::ImportPreludeHref<'a>, f: &mut CssFormatter<'_, 'a>) { +/// `singleQuote` like Prettier's `adjustStrings` +/// (interpolated paths re-quote the OUTER quotes only, keeping `@{var}` / `#{}` content verbatim). +fn write_import_href<'a>(href: &ImportPreludeHref<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); match href { - raffia::ast::ImportPreludeHref::Str(InterpolableStr::Literal(str)) => { + ImportPreludeHref::Str(InterpolableStr::Literal(str)) => { value::write_str(str, f); } - raffia::ast::ImportPreludeHref::Url(url) => value::write_url(url, f), + ImportPreludeHref::Url(url) => value::write_url(url, f), // `url()` with SassScript content reprints structurally: // `url($dir+"/path")` → `url($dir + "/path")` like Prettier. - raffia::ast::ImportPreludeHref::Function(func) => { - value::write_function(func, crate::print::value::ValueContext::default(), f); + ImportPreludeHref::Function(func) => { + value::write_function(func, ValueContext::default(), f); } - // Interpolated string path (`@import './@{var}.less'`): re-quote the - // outer quotes, keep the interpolation content verbatim. - href @ raffia::ast::ImportPreludeHref::Str(_) => { + // Interpolated string path (`@import './@{var}.less'`): + // re-quote the outer quotes, keep the interpolation content verbatim. + href @ ImportPreludeHref::Str(_) => { let span = to_span(href.span()); value::write_requoted_verbatim(source.text_for(&span), f); } @@ -1570,12 +1485,9 @@ fn write_import_href<'a>(href: &raffia::ast::ImportPreludeHref<'a>, f: &mut CssF } /// Less `@import (options) href media` (e.g. `@import (reference) "x";`). -/// raffia parses the options form as a dedicated `LessImportPrelude`, which -/// otherwise falls into the verbatim catch-all and skips quote normalization. -fn write_less_import_prelude<'a>( - import: &raffia::ast::LessImportPrelude<'a>, - f: &mut CssFormatter<'_, 'a>, -) { +/// raffia parses the options form as a dedicated `LessImportPrelude`, +/// which otherwise falls into the verbatim catch-all and skips quote normalization. +fn write_less_import_prelude<'a>(import: &LessImportPrelude<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { if !import.options.names.is_empty() { @@ -1591,24 +1503,24 @@ fn write_less_import_prelude<'a>( } write_import_href(&import.href, f); if let Some(media) = &import.media { - write!(f, oxc_formatter_core::builders::soft_line_break_or_space()); + write!(f, soft_line_break_or_space()); write_media_query_list_inner(media, f); } }); write!(f, group(&indent(&body))); } -/// Mirrors Prettier's `media-query-list`: queries joined by `,` + line, -/// wrapped in `group(indent(...))`. -pub fn write_media_query_list<'a>(list: &MediaQueryList<'a>, f: &mut CssFormatter<'_, 'a>) { +/// Mirrors Prettier's `media-query-list`. +/// Queries joined by `,` + line, wrapped in `group(indent(...))`. +fn write_media_query_list<'a>(list: &MediaQueryList<'a>, f: &mut CssFormatter<'_, 'a>) { let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { write_media_query_list_inner(list, f); }); write!(f, group(&indent(&body))); } -/// The query list without its own group/indent, for callers that provide -/// their own break scope (`@import` preludes). +/// The query list without its own group/indent, +/// for callers that provide their own break scope (`@import` preludes). fn write_media_query_list_inner<'a>(list: &MediaQueryList<'a>, f: &mut CssFormatter<'_, 'a>) { for (i, query) in list.queries.iter().enumerate() { if i > 0 { @@ -1719,8 +1631,8 @@ fn write_comparison(kind: &MediaFeatureComparisonKind, f: &mut CssFormatter<'_, } fn write_media_feature_value<'a>(value: &ComponentValue<'a>, f: &mut CssFormatter<'_, 'a>) { - // Prettier's `media-value` is flat TEXT (`adjustNumbers(adjustStrings(...))`) - // — a media query never breaks inside a feature value, however long. + // Prettier's `media-value` is flat TEXT (`adjustNumbers(adjustStrings(...))`), + // a media query never breaks inside a feature value, however long. value::write_component_value(value, ValueContext { no_break: true, ..Default::default() }, f); } @@ -1756,9 +1668,9 @@ fn write_media_feature<'a>(feature: &MediaFeature<'a>, f: &mut CssFormatter<'_, } fn write_supports_condition<'a>(condition: &SupportsCondition<'a>, f: &mut CssFormatter<'_, 'a>) { - // A fill of keywords and parenthesized terms: a long condition breaks - // AFTER `and`/`or`, one indent in (postcss-values prints the params as a - // value group, so each word/paren is its own fill entry). + // A fill of keywords and parenthesized terms: + // a long condition breaks AFTER `and`/`or`, one indent in + // (`postcss-values` prints the params as a value group, so each word/paren is its own fill entry). let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut filler = f.fill(); for kind in &condition.conditions { @@ -1773,18 +1685,18 @@ fn write_supports_condition<'a>(condition: &SupportsCondition<'a>, f: &mut CssFo let span = to_span(keyword.span()); write_maybe_lowercase(f.context().source_text().text_for(&span), f); }); - filler.entry(&oxc_formatter_core::builders::soft_line_break_or_space(), &kw); + filler.entry(&soft_line_break_or_space(), &kw); } let term = format_with(move |f: &mut CssFormatter<'_, 'a>| { write_supports_in_parens(in_parens, f); }); - filler.entry(&oxc_formatter_core::builders::soft_line_break_or_space(), &term); + filler.entry(&soft_line_break_or_space(), &term); } filler.finish(); }); - // Only a multi-term condition gets the indent: a lone term may carry - // hardlines of its own (`selector(\n :focus-visible // c\n)`) that must - // not be re-indented. + // Only a multi-term condition gets the indent: + // a lone term may carry hardlines of its own (`selector(\n :focus-visible // c\n)`) + // that must not be re-indented. if condition.conditions.len() > 1 { write!(f, group(&indent(&body))); } else { @@ -1802,7 +1714,7 @@ fn write_supports_in_parens<'a>(in_parens: &SupportsInParens<'a>, f: &mut CssFor } SupportsInParensKind::Feature(feature) => { write!(f, "("); - crate::print::statement::write_declaration_inline(&feature.decl, f); + statement::write_declaration(&feature.decl, f); write!(f, ")"); } SupportsInParensKind::Selector(list) => { @@ -1824,7 +1736,7 @@ fn write_supports_in_parens<'a>(in_parens: &SupportsInParens<'a>, f: &mut CssFor selector::write_selector_list(list, selector::SelectorListStyle::Line, f); for &comment in f.context().comments().take_before(r_paren) { write!(f, space()); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); } }); write!(f, [indent(&body), hard_line_break(), ")"]); @@ -1834,8 +1746,7 @@ fn write_supports_in_parens<'a>(in_parens: &SupportsInParens<'a>, f: &mut CssFor } } SupportsInParensKind::Function(func) => { - let func_value = raffia::ast::ComponentValue::Function(func.clone()); - value::write_component_value(&func_value, ValueContext::default(), f); + value::write_function(func, ValueContext::default(), f); } } } diff --git a/crates/oxc_formatter_css/src/print/less.rs b/crates/oxc_formatter_css/src/print/less.rs index 94638f92cf80b..dba23d515ac2e 100644 --- a/crates/oxc_formatter_css/src/print/less.rs +++ b/crates/oxc_formatter_css/src/print/less.rs @@ -1,19 +1,22 @@ //! Less-specific printing: variable declarations, mixins, lookups, guards. -use oxc_formatter_core::{ - Buffer, - builders::{space, text}, - write, -}; use raffia::{ Spanned, ast::{ - ComponentValue, LessCondition, LessMixinArgument, LessMixinCall, LessMixinDefinition, - LessMixinName, LessVariableDeclaration, + ComponentValue, LessCondition, LessConditionalQualifiedRule, LessDetachedRuleset, + LessMixinArgument, LessMixinCall, LessMixinDefinition, LessMixinName, LessNamespaceValue, + LessNamespaceValueCallee, LessVariableDeclaration, SimpleBlock, }, }; +use oxc_formatter_core::{ + Buffer, arena_cow_str, + builders::{hard_line_break, space, text}, + write, +}; + use crate::{ + comments::{last_line_has_inline_comment, write_single_comment}, format::to_span, print::{ CssFormatter, @@ -23,20 +26,20 @@ use crate::{ }; /// `@name: value`. Returns `true` when the caller should append `;`. -pub fn write_less_variable_declaration<'a>( +pub(super) fn write_less_variable_declaration<'a>( decl: &LessVariableDeclaration<'a>, f: &mut CssFormatter<'_, 'a>, ) -> bool { let source = f.context().source_text(); - // Prettier's `shouldPrecededBySoftline` matches `css-decl` only, never - // atrule-variables — see `ValueContext::no_leading_softline`. + // Prettier's `shouldPrecededBySoftline` matches `css-decl` only, + // never atrule-variables; see `ValueContext::no_leading_softline`. let value_ctx = ValueContext { no_leading_softline: true, ..ValueContext::default() }; write!(f, "@"); let name_span = to_span(decl.name.name.span()); write!(f, text(source.text_for(&name_span))); let colon_end = to_span(&decl.colon_span).end; - // Inline comments around the colon make postcss-less treat this as a - // plain at-rule: the raw text is kept and the block loses its `;`. + // Inline comments around the colon make postcss-less treat this as a plain at-rule: + // the raw text is kept and the block loses its `;`. let value_start_pos = to_span(decl.value.span()).start; let inline_before_colon = f.context().comments().iter_before(colon_end).any(|c| c.inline); let inline_after_colon = f @@ -44,13 +47,13 @@ pub fn write_less_variable_declaration<'a>( .comments() .iter_before(value_start_pos) .any(|c| c.inline && c.span.start >= colon_end); - // Inline comment AFTER the colon only: still a variable; the comment and - // line structure are kept (`@var: // c\n{`). + // Inline comment AFTER the colon only: still a variable; + // the comment and line structure are kept (`@var: // c\n{`). if !inline_before_colon && inline_after_colon { write!(f, [":", space()]); for &comment in f.context().comments().take_before(value_start_pos) { - crate::comments::write_single_comment(comment, f); - write!(f, oxc_formatter_core::builders::hard_line_break()); + write_single_comment(comment, f); + write!(f, hard_line_break()); } crate::print::scss::write_top_level_value(&decl.value, value_ctx, f); return true; @@ -61,11 +64,11 @@ pub fn write_less_variable_declaration<'a>( let raw = source.slice_range(name_span.end, value_start); write!(f, text(raw.trim_end())); if let ComponentValue::LessDetachedRuleset(ruleset) = &decl.value { - // Plain at-rule semantics: props are lowercased, no `;`. + // Plain at-rule semantics: props are lowercased, no `;` if raw.trim_end().ends_with(':') { write!(f, space()); } else { - write!(f, oxc_formatter_core::builders::hard_line_break()); + write!(f, hard_line_break()); } write_block(&ruleset.block, f); return false; @@ -74,7 +77,7 @@ pub fn write_less_variable_declaration<'a>( crate::print::scss::write_top_level_value(&decl.value, value_ctx, f); return true; } - // postcss-less drops (block) comments between the name and the colon. + // postcss-less drops (block) comments between the name and the colon let _ = f.context().comments().take_before(colon_end); write!(f, [":", space()]); crate::print::scss::write_top_level_value(&decl.value, value_ctx, f); @@ -88,23 +91,20 @@ fn write_mixin_name<'a>(name: &LessMixinName<'a>, f: &mut CssFormatter<'_, 'a>) } /// Raw source text with Prettier's string-level normalizations applied -/// (`adjustNumbers(adjustStrings(...))`) — the selector-side print path for -/// everything postcss-selector-parser receives: spacing and newlines stay -/// verbatim and nothing ever breaks on line width. +/// (`adjustNumbers(adjustStrings(...))`). +/// The selector-side print path for everything postcss-selector-parser receives: +/// spacing and newlines stay verbatim and nothing ever breaks on line width. fn write_adjusted_verbatim<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) { - let single_quote = f.options().single_quote.value(); - match value::adjust_numbers_and_strings(raw, single_quote) { - std::borrow::Cow::Borrowed(s) => write!(f, text(s)), - std::borrow::Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + let adjusted = value::adjust_numbers_and_strings(raw, f.options()); + write!(f, text(arena_cow_str(&adjusted, f))); } /// Prelude printed verbatim from `start` to the block, then the block. -/// A trailing `//` comment pushes `{` to the next line (selector-unknown's -/// `lastLineHasInlineComment`). +/// A trailing `//` comment pushes `{` to the next line +/// (selector-unknown's `lastLineHasInlineComment`). fn write_verbatim_prelude_rule<'a>( start: u32, - block: &raffia::ast::SimpleBlock<'a>, + block: &SimpleBlock<'a>, f: &mut CssFormatter<'_, 'a>, ) { let source = f.context().source_text(); @@ -112,19 +112,22 @@ fn write_verbatim_prelude_rule<'a>( let raw = source.slice_range(start, block_start).trim_end(); let _ = f.context().comments().take_before(block_start); write_adjusted_verbatim(raw, f); - if crate::comments::last_line_has_inline_comment(raw) { - write!(f, oxc_formatter_core::builders::hard_line_break()); + if last_line_has_inline_comment(raw) { + write!(f, hard_line_break()); } else { write!(f, space()); } write_block(block, f); } -/// `.mixin(@params...) when (guard) { ... }` — Prettier hands the whole -/// prelude to postcss-selector-parser (`css-rule` selector) and prints it -/// raw apart from number/string adjustments, so parameter spacing, a space -/// before `(`, trailing `;` separators and multi-line layouts all survive. -pub fn write_less_mixin_definition<'a>( +/// `.mixin(@params...) when (guard) { ... }`: +/// Prettier hands the whole prelude to postcss-selector-parser (`css-rule` selector) +/// and prints it raw apart from number/string adjustments, +/// so parameter spacing, a space before `(`, trailing `;` separators and multi-line layouts all survive. +/// +/// NOTE: `raffia` gives us a structured `LessMixinDefinition` (name + params + guard), +/// so we COULD print this structurally and break long parameter lists on width. +pub(super) fn write_less_mixin_definition<'a>( def: &LessMixinDefinition<'a>, f: &mut CssFormatter<'_, 'a>, ) { @@ -133,8 +136,11 @@ pub fn write_less_mixin_definition<'a>( /// `selector when (guard) { ... }` — a `css-rule` in Prettier: raw selector /// text (guard included), block, and NO trailing `;`. -pub fn write_less_conditional_qualified_rule<'a>( - rule: &raffia::ast::LessConditionalQualifiedRule<'a>, +/// +/// NOTE: `raffia` structures the selector and the `when` guard, +/// but we keep the raw source for Prettier alignment. +pub(super) fn write_less_conditional_qualified_rule<'a>( + rule: &LessConditionalQualifiedRule<'a>, f: &mut CssFormatter<'_, 'a>, ) { write_verbatim_prelude_rule(to_span(&rule.span).start, &rule.block, f); @@ -143,7 +149,14 @@ pub fn write_less_conditional_qualified_rule<'a>( /// Statement-position `.mixin(args);` — a `mixin` at-rule in Prettier, whose /// params are re-parsed as a SELECTOR (parser-postcss.js) and printed raw: /// argument spacing is preserved and a long call never breaks on width. -pub fn write_less_mixin_call_statement<'a>(call: &LessMixinCall<'a>, f: &mut CssFormatter<'_, 'a>) { +/// +/// NOTE: `raffia` gives a structured `LessMixinCall` with callee + args, +/// so a structured printer (argument list, width-breaking) is possible. +/// We follow Prettier's verbatim contract so `.mixin( @a , @b )` etc, survive intact. +pub(super) fn write_less_mixin_call_statement<'a>( + call: &LessMixinCall<'a>, + f: &mut CssFormatter<'_, 'a>, +) { let source = f.context().source_text(); let span = to_span(&call.span); let end = call.important.as_ref().map_or(span.end, |imp| to_span(&imp.span).start); @@ -157,7 +170,7 @@ pub fn write_less_mixin_call_statement<'a>(call: &LessMixinCall<'a>, f: &mut Css /// `.mixin(args) !important` in VALUE / namespace-callee position only /// (statement position goes through `write_less_mixin_call_statement`). -pub fn write_less_mixin_call<'a>(call: &LessMixinCall<'a>, f: &mut CssFormatter<'_, 'a>) { +fn write_less_mixin_call<'a>(call: &LessMixinCall<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); for child in &call.callee.children { if let Some(combinator) = &child.combinator { @@ -195,7 +208,7 @@ pub fn write_less_mixin_call<'a>(call: &LessMixinCall<'a>, f: &mut CssFormatter< } } -pub fn write_less_condition<'a>(condition: &LessCondition<'a>, f: &mut CssFormatter<'_, 'a>) { +fn write_less_condition<'a>(condition: &LessCondition<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); match condition { LessCondition::Binary(binary) => { @@ -220,16 +233,16 @@ pub fn write_less_condition<'a>(condition: &LessCondition<'a>, f: &mut CssFormat } /// `.mixin(args)[@lookup][...]` / `@var[lookup]` -pub fn write_less_namespace_value<'a>( - namespace: &raffia::ast::LessNamespaceValue<'a>, +fn write_less_namespace_value<'a>( + namespace: &LessNamespaceValue<'a>, f: &mut CssFormatter<'_, 'a>, ) { let source = f.context().source_text(); match &namespace.callee { - raffia::ast::LessNamespaceValueCallee::LessMixinCall(call) => { + LessNamespaceValueCallee::LessMixinCall(call) => { write_less_mixin_call(call, f); } - raffia::ast::LessNamespaceValueCallee::LessVariable(variable) => { + LessNamespaceValueCallee::LessVariable(variable) => { let span = to_span(variable.span()); write!(f, text(source.text_for(&span))); } @@ -244,16 +257,16 @@ pub fn write_less_namespace_value<'a>( } } -/// `{ ... }` detached ruleset as a value. Property names inside keep their -/// case (Prettier checks the enclosing `variable` at-rule). -pub fn write_less_detached_ruleset<'a>( - ruleset: &raffia::ast::LessDetachedRuleset<'a>, +/// `{ ... }` detached ruleset as a value. +/// Property names inside keep their case (Prettier checks the enclosing `variable` at-rule). +fn write_less_detached_ruleset<'a>( + ruleset: &LessDetachedRuleset<'a>, f: &mut CssFormatter<'_, 'a>, ) { - // Comments before `{` stay on the same line. + // Comments before `{` stay on the same line let block_start = to_span(&ruleset.block.span).start; for &comment in f.context().comments().take_before(block_start) { - crate::comments::write_single_comment(comment, f); + write_single_comment(comment, f); write!(f, space()); } let was = f.context().in_less_detached().replace(true); @@ -262,7 +275,7 @@ pub fn write_less_detached_ruleset<'a>( } /// `value` of a ComponentValue that is Less-specific; returns false if not handled. -pub fn write_less_component_value<'a>( +pub(super) fn write_less_component_value<'a>( value: &ComponentValue<'a>, f: &mut CssFormatter<'_, 'a>, ) -> bool { diff --git a/crates/oxc_formatter_css/src/print/mod.rs b/crates/oxc_formatter_css/src/print/mod.rs index 31255474ce4d5..c07b163b519e5 100644 --- a/crates/oxc_formatter_css/src/print/mod.rs +++ b/crates/oxc_formatter_css/src/print/mod.rs @@ -1,15 +1,14 @@ +use cow_utils::CowUtils; +use raffia::ast::Stylesheet; + use oxc_formatter_core::{ - Buffer, Format, Formatter, - builders::{FormatWith, empty_line, hard_line_break}, + Buffer, Format, Formatter, arena_cow_str, + builders::{FormatWith, text, token}, write, }; -use raffia::ast::Stylesheet; use crate::{ - comments::{ - Gap, classify_gap, flush_leading_comments, write_leading_comments, - write_trailing_same_line_comment, - }, + comments::{write_gap, write_leading_comments}, context::CssFormatContext, }; @@ -29,7 +28,7 @@ pub type CssFormatter<'buf, 'a> = Formatter<'buf, 'a, CssFormatContext<'a>>; impl<'a> Format<'a, CssFormatContext<'a>> for &'static str { #[inline] fn fmt(&self, f: &mut CssFormatter<'_, 'a>) { - write!(f, oxc_formatter_core::builders::token(self)); + write!(f, token(self)); } } @@ -43,12 +42,44 @@ where FormatWith::new(formatter) } +/// Collapses any whitespace run in `raw` to a single space. +pub fn normalize_whitespace(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut iter = raw.split_whitespace(); + if let Some(first) = iter.next() { + out.push_str(first); + for word in iter { + out.push(' '); + out.push_str(word); + } + } + out +} + +/// Mirrors Prettier's `maybeToLowerCase`. +/// Lowercase unless the identifier contains variable/interpolation markers. +pub fn write_maybe_lowercase<'a>(value: &'a str, f: &mut CssFormatter<'_, 'a>) { + if value.contains('$') + || value.contains('@') + || value.contains('#') + || value.starts_with('%') + || value.starts_with("--") + || value.starts_with(":--") + || (value.contains('(') && value.contains(')')) + { + write!(f, text(value)); + return; + } + let lower = value.cow_to_ascii_lowercase(); + write!(f, text(arena_cow_str(&lower, f))); +} + /// Emits the whole stylesheet: top-level statements separated by hard lines /// (blank lines preserved, max one), then any trailing comments. /// /// Mirrors Prettier's `css-root` + `printSequence`. pub fn write_stylesheet<'a>(stylesheet: &Stylesheet<'a>, f: &mut CssFormatter<'_, 'a>) { - write_statement_sequence(&stylesheet.statements, f); + statement::write_statement_sequence_bounded(&stylesheet.statements, u32::MAX, f); // Comments after the last statement (or in an otherwise empty file). let remaining = f.context().comments().take_remaining(); @@ -56,90 +87,9 @@ pub fn write_stylesheet<'a>(stylesheet: &Stylesheet<'a>, f: &mut CssFormatter<'_ if !stylesheet.statements.is_empty() { let source = f.context().source_text(); let prev_end = stylesheet.statements.last().map_or(0, |s| statement::stmt_end(s, f)); - match classify_gap(source.bytes_range(prev_end, remaining[0].span.start)) { - Gap::None => write!(f, oxc_formatter_core::builders::space()), - Gap::Line => write!(f, hard_line_break()), - Gap::Blank => write!(f, empty_line()), - } + write_gap(source.bytes_range(prev_end, remaining[0].span.start), f); } let last_end = remaining.last().unwrap().span.end; write_leading_comments(remaining, last_end, f); } } - -/// Emits `statements` separated by hard lines, preserving at most one blank line -/// between consecutive statements, flushing comments at their source positions. -/// -/// Mirrors Prettier's `printSequence`. -pub fn write_statement_sequence<'a>( - statements: &[raffia::ast::Statement<'a>], - f: &mut CssFormatter<'_, 'a>, -) { - write_statement_sequence_bounded(statements, u32::MAX, f); -} - -/// Like [`write_statement_sequence`], but trailing same-line comments are -/// only claimed when they end before `upper` (a block's closing `}`), -/// so inline rules don't steal comments that belong to the parent. -pub fn write_statement_sequence_bounded<'a>( - statements: &[raffia::ast::Statement<'a>], - upper: u32, - f: &mut CssFormatter<'_, 'a>, -) { - let source = f.context().source_text(); - for (i, stmt) in statements.iter().enumerate() { - let start = statement::stmt_start(stmt); - if i > 0 { - let prev_end = statement::stmt_end(&statements[i - 1], f); - // Trailing comment on the same line as the previous statement - // (but not one that sits after the NEXT statement on that line). - write_trailing_same_line_comment(prev_end, upper.min(start), f); - write!(f, hard_line_break()); - // Preserve a single blank line. The gap considered is from the end of - // the previous statement to the next printed position (comment or stmt). - let next_start = - f.context().comments().peek().map_or(start, |c| c.span.start.min(start)); - if classify_gap(source.bytes_range(prev_end, next_start)) == Gap::Blank { - write!(f, empty_line()); - } - } - // `prettier-ignore` / `oxfmt-ignore`: print the statement verbatim. - let suppressed = f - .context() - .comments() - .iter_before(start) - .last() - .is_some_and(|c| crate::comments::is_suppression_comment(source, c)); - flush_leading_comments(start, f); - if suppressed { - let end = statement::stmt_end(stmt, f); - // Prettier quirk: an ignored `;`-less at-rule left unclosed at EOF - // has no `source.end` in postcss, so `printIgnored` slices an - // empty string and the statement VANISHES. We reproduce this for - // placeholder at-rules only — there it is load-bearing (the - // placeholder count mismatch makes the css-in-js embed fall back - // to plain template printing, like Prettier) — but not for real - // code, where silently deleting an ignored statement is a bug. - let placeholder_vanishes = - matches!( - stmt, - raffia::ast::Statement::AtRule(at_rule) - if at_rule.block.is_none() - && at_rule.name.raw.starts_with("prettier-placeholder") - ) && !source.slice_range(start, end).trim_end().ends_with(';') - && source[end as usize..].trim().is_empty(); - if !placeholder_vanishes { - write!(f, oxc_formatter_core::builders::text(source.slice_range(start, end))); - } - } else { - statement::write_statement(stmt, f); - } - // Discard comments inside spans the statement printer didn't claim - // (e.g. inside selectors/values that are still printed verbatim), - // so the cursor never points before an already-printed position. - let _ = f.context().comments().take_before(statement::stmt_end(stmt, f)); - } - if let Some(last) = statements.last() { - write_trailing_same_line_comment(statement::stmt_end(last, f), upper, f); - } -} diff --git a/crates/oxc_formatter_css/src/print/scss.rs b/crates/oxc_formatter_css/src/print/scss.rs index 0d8e810661539..2f69cfa924b76 100644 --- a/crates/oxc_formatter_css/src/print/scss.rs +++ b/crates/oxc_formatter_css/src/print/scss.rs @@ -1,32 +1,37 @@ //! SCSS-specific printing: variable declarations, maps, lists, //! control directives, mixins/includes/functions, module system. +use raffia::{ + Spanned, + ast::{ + ComponentValue, InterpolableStr, SassEach, SassFor, SassForBoundaryKind, SassForward, + SassForwardVisibilityModifierKind, SassFunction, SassIfAtRule, SassInclude, SassList, + SassMap, SassMixin, SassModuleConfig, SassParameters, SassUnaryOperatorKind, SassUse, + SassUseNamespaceKind, SassVariableDeclaration, + }, +}; + use oxc_formatter_core::{ Buffer, builders::{ - group, hard_line_break, indent, soft_line_break, soft_line_break_or_space, space, text, + dedent, empty_line, group, hard_line_break, if_group_breaks, indent, soft_line_break, + soft_line_break_or_space, space, text, }, write, }; -use raffia::{ - Spanned, - ast::{ - ComponentValue, SassEach, SassFor, SassForBoundaryKind, SassIfAtRule, SassInclude, - SassList, SassMap, SassMixin, SassParameters, SassVariableDeclaration, - }, -}; +use oxc_span::Span; use crate::{ + comments, format::to_span, print::{ - CssFormatter, format_with, - statement::write_block, + CssFormatter, format_with, statement, value::{self, ValueContext}, }, }; /// `$var: value !flags;` -pub fn write_sass_variable_declaration<'a>( +pub(super) fn write_sass_variable_declaration<'a>( decl: &SassVariableDeclaration<'a>, f: &mut CssFormatter<'_, 'a>, ) { @@ -38,7 +43,7 @@ pub fn write_sass_variable_declaration<'a>( write!(f, "$"); let name_span = to_span(decl.name.name.span()); write!(f, text(source.text_for(&name_span))); - // Comments between the name and the colon are kept verbatim. + // Comments between the name and the colon are kept verbatim let colon_end = to_span(&decl.colon_span).end; let between = source.slice_range(name_span.end, colon_end); if between.trim() == ":" { @@ -50,14 +55,14 @@ pub fn write_sass_variable_declaration<'a>( write!(f, space()); let ctx = ValueContext { decl_prop: Some("$"), map_break: true, ..ValueContext::default() }; - // Comments between the colon and the value: inline ones get their own - // line under the colon (`$x:\n // c\n value`). + // Comments between the colon and the value: + // inline ones get their own line under the colon (`$x:\n // c\n value`). let value_start = to_span(decl.value.span()).start; if f.context().comments().peek().is_some_and(|c| c.inline && c.span.end <= value_start) { let lead = format_with(move |f: &mut CssFormatter<'_, 'a>| { for &comment in f.context().comments().take_before(value_start) { write!(f, hard_line_break()); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); } }); write!(f, indent(&lead)); @@ -74,7 +79,7 @@ pub fn write_sass_variable_declaration<'a>( } // Comments between the value/flags and the `;`. let decl_end = to_span(decl.span()).end; - let end = crate::print::statement::end_with_semicolon(decl_end, f); + let end = statement::end_with_semicolon(decl_end, f); let bound = if end > decl_end { end - 1 } else { decl_end }; if let Some(comment_end) = value::flush_trailing_value_comments(bound, f) && end > decl_end @@ -84,10 +89,10 @@ pub fn write_sass_variable_declaration<'a>( } } -/// A single `ComponentValue` in declaration-value position: comma-separated -/// `SassList`s get Prettier's top-level list layout (one entry per line when -/// any entry has multiple parts). -pub fn write_top_level_value<'a>( +/// A single `ComponentValue` in declaration-value position: +/// comma-separated `SassList`s get Prettier's top-level list layout +/// (one entry per line when any entry has multiple parts). +pub(super) fn write_top_level_value<'a>( value: &ComponentValue<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, @@ -133,14 +138,17 @@ pub fn write_top_level_value<'a>( /// `(key: value, ...)`: SCSS maps in map-item positions always break, /// one item per line, with a trailing comma per the `trailingComma` option. -pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_map<'a>( + map: &SassMap<'a>, + ctx: ValueContext<'a>, + f: &mut CssFormatter<'_, 'a>, +) { if map.items.is_empty() { // A map with no items may still hold comments (`(\n // c\n)`). // Keep them inside the parens, one line each, instead of leaking // them past `)` as a trailing declaration comment (Prettier #18535). let r_paren = to_span(map.span()).end.saturating_sub(1); - let tail: Vec = - f.context().comments().take_before(r_paren).to_vec(); + let tail: Vec = f.context().comments().take_before(r_paren).to_vec(); if tail.is_empty() { write!(f, ["(", ")"]); return; @@ -150,18 +158,18 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF if i == 0 || comment.inline || tail[i - 1].inline { write!(f, hard_line_break()); } else { - // Block comments are fill items: they join when they fit. + // Block comments are fill items: they join when they fit write!(f, " "); } - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); } }); write!(f, ["(", indent(&body), hard_line_break(), ")"]); return; } - // Maps break only in "map item" positions (`$var:` values, map item - // values, function arguments — Prettier's `isSCSSMapItemNode`). In key - // position or elsewhere (e.g. `@each ... in (k: v)`) they stay inline. + // Maps break only in "map item" positions + // (`$var:` values, map item values, function arguments, Prettier's `isSCSSMapItemNode`). + // In key position or elsewhere (e.g. `@each ... in (k: v)`) they stay inline. if ctx.map_key || !ctx.map_break { let source = f.context().source_text(); let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { @@ -173,15 +181,15 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF // (Prettier's isNextLineEmpty → hardline). let prev_end = to_span(map.items[i - 1].span()).end; let start = to_span(item.span()).start; - if crate::comments::classify_gap(source.bytes_range(prev_end, start)) - == crate::comments::Gap::Blank + if comments::classify_gap(source.bytes_range(prev_end, start)) + == comments::Gap::Blank { - write!(f, oxc_formatter_core::builders::empty_line()); + write!(f, empty_line()); } else { write!(f, soft_line_break_or_space()); } } - // `key: value` may break after the colon when too long. + // `key: value` may break after the colon when too long let pair = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut filler = f.fill(); let key = format_with(move |f: &mut CssFormatter<'_, 'a>| { @@ -198,9 +206,9 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF write!(f, group(&indent(&pair))); } // Outside map-item positions (e.g. `@each ... in (k: v)`), - // isSCSSMapItemNode is false → no trailing comma. + // `isSCSSMapItemNode` is false → no trailing comma. if ctx.map_key && f.options().allow_trailing_comma() { - write!(f, oxc_formatter_core::builders::if_group_breaks(&text(","))); + write!(f, if_group_breaks(&text(","))); } }); write!( @@ -224,7 +232,7 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF if i > 0 { write!(f, ","); write!(f, hard_line_break()); - // Preserve one blank line between items. + // Preserve one blank line between items let prev_end = to_span(map.items[i - 1].span()).end; let item_start = to_span(item.span()).start; let next_start = f @@ -232,38 +240,36 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF .comments() .peek() .map_or(item_start, |c| c.span.start.min(item_start)); - if crate::comments::classify_gap(source.bytes_range(prev_end, next_start)) - == crate::comments::Gap::Blank + if comments::classify_gap(source.bytes_range(prev_end, next_start)) + == comments::Gap::Blank { - write!(f, oxc_formatter_core::builders::empty_line()); + write!(f, empty_line()); } } let key_ctx = ValueContext { map_key: true, paren_break: false, map_break: false, ..ctx }; let val_ctx = ValueContext { map_key: false, paren_break: true, map_break: true, ..ctx }; - // Nested maps / paren lists hug the colon (`key: (`); the pair - // never breaks after the colon (Prettier dedents these). + // Nested maps / paren lists hug the colon (`key: (`); + // the pair never breaks after the colon (Prettier dedents these). let value_is_block = matches!( item.value, ComponentValue::SassMap(_) | ComponentValue::SassParenthesizedExpression(_) ); - // Comments between items: block comments join the item when the - // pair fits on one line (Prettier's fill); `//` comments and - // pairs that don't fit keep their own line. + // Comments between items: + // block comments join the item when the pair fits on one line (Prettier's fill); + // `//` comments and pairs that don't fit keep their own line. let item_start = to_span(item.span()).start; let item_width = to_span(item.span()).end - item_start; let mut had_leading_comment = false; - let mut leading_comment_inline = false; for &comment in f.context().comments().take_before(item_start) { had_leading_comment = true; - leading_comment_inline = comment.inline; let comment_width = comment.span.end - comment.span.start; let fits = !value_is_block && u32::from(f.options().indent_width.value()) + comment_width + 2 + item_width <= u32::from(f.options().line_width.value()); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline || !fits { write!(f, hard_line_break()); } else { @@ -277,7 +283,6 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF if i + 1 == map.items.len() { // Suppressed only when the source ALSO had a trailing comma // (the comment lands inside the last comma_group in postcss). - let _ = leading_comment_inline; last_item_block_with_comment = value_is_block && had_leading_comment && map.comma_spans.len() >= map.items.len(); @@ -290,15 +295,11 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF } else if value_is_block { value::write_component_value(&item.key, key_ctx, f); write!(f, [":", space()]); - // A paren/map KEY — or a leading comment — keeps the pair's - // indent on the value (Prettier's dedent applies only when - // the doc is a plain `group(indent(fill))`). - let key_is_block = matches!( - item.key, - ComponentValue::SassMap(_) | ComponentValue::SassParenthesizedExpression(_) - ) || had_leading_comment - || f.context().block_depth().get() > 0; - if key_is_block { + // A paren/map KEY (or a leading comment, or nesting) keeps the pair's indent on the value + // (Prettier's dedent applies only when the doc is a plain `group(indent(fill))`). + let needs_indent = + key_is_block || had_leading_comment || f.context().block_depth().get() > 0; + if needs_indent { let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { write_top_level_value(&item.value, val_ctx, f); }); @@ -307,12 +308,11 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF write_top_level_value(&item.value, val_ctx, f); } } else { - // `key: value` breaks after the colon when too long. + // `key: value` breaks after the colon when too long let pair = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut filler = f.fill(); let key = format_with(move |f: &mut CssFormatter<'_, 'a>| { - // Paren/map keys cancel the pair indent (Prettier's - // `isKey` → dedent). + // Paren/map keys cancel the pair indent (Prettier's `isKey` → dedent) if matches!( item.key, ComponentValue::SassMap(_) @@ -321,7 +321,7 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF let inner = format_with(move |f: &mut CssFormatter<'_, 'a>| { value::write_component_value(&item.key, key_ctx, f); }); - write!(f, oxc_formatter_core::builders::dedent(&inner)); + write!(f, dedent(&inner)); } else { value::write_component_value(&item.key, key_ctx, f); } @@ -337,16 +337,16 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF write!(f, group(&indent(&pair))); } } - // Own-line comments after the last item make it "non-last" in - // Prettier's sequence, so it always gets a comma. + // Own-line comments after the last item make it "non-last" in Prettier's sequence, + // so it always gets a comma. let last_item_end = map.items.last().map_or(0, |it| to_span(it.span()).end); let has_ownline_tail = f .context() .comments() .iter_before(r_paren) .any(|c| c.span.start >= last_item_end && value::comment_is_own_line(c, source)); - // Prettier drops the trailing comma after a comment-preceded block - // value (the pair doc isn't the plain dedent shape). + // Prettier drops the trailing comma after a comment-preceded block value + // (the pair doc isn't the plain dedent shape). let printed_comma = (trailing && !last_item_block_with_comment) || has_ownline_tail; if printed_comma { write!(f, ","); @@ -365,24 +365,23 @@ pub fn write_sass_map<'a>(map: &SassMap<'a>, ctx: ValueContext<'a>, f: &mut CssF value::flush_same_line_comments(to_span(last.span()).end, f); } } - // Own-line comments before `)`: same-line runs stay glued. - let tail: Vec = - f.context().comments().take_before(r_paren).to_vec(); + // Own-line comments before `)`: same-line runs stay glued + let tail: Vec = f.context().comments().take_before(r_paren).to_vec(); for (i, &comment) in tail.iter().enumerate() { if i == 0 || comment.inline || tail[i - 1].inline { write!(f, hard_line_break()); } else { - // Block comments are fill items: they join when they fit. + // Block comments are fill items: they join when they fit write!(f, " "); } - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); } }); write!(f, ["(", indent(&body), hard_line_break(), ")"]); } /// Space- or comma-separated SCSS list in a nested position. -pub fn write_sass_list<'a>( +pub(super) fn write_sass_list<'a>( list: &SassList<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, @@ -410,13 +409,13 @@ pub fn write_sass_list<'a>( /// `@each $key, $value in $expr`: printed as one flat comma list /// (`$k, $v in (a), (b), (c)`), filling and indenting like a value. -pub fn write_sass_each<'a>(each: &SassEach<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_each<'a>(each: &SassEach<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let in_span = to_span(&each.in_span); let in_tight = in_span.end == to_span(each.expr.span()).start; - // Comma-list expr: the first element joins the `... in` entry, the rest - // are separate fill entries (mirrors postcss's flat comma groups). + // Comma-list expr: the first element joins the `... in` entry, + // the rest are separate fill entries (mirrors postcss's flat comma groups). let expr_elements: &[ComponentValue<'a>] = match &each.expr { ComponentValue::SassList(list) if list.comma_spans.is_some() => &list.elements, expr => std::slice::from_ref(expr), @@ -430,8 +429,8 @@ pub fn write_sass_each<'a>(each: &SassEach<'a>, f: &mut CssFormatter<'_, 'a>) { let is_last = i == last_binding; let content = format_with(move |f: &mut CssFormatter<'_, 'a>| { if is_last { - // `$binding in expr` — breakable before `in`, with the - // continuation indented one level deeper. + // `$binding in expr`: breakable before `in`, + // with the continuation indented one level deeper. let tail = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut inner = f.fill(); let binding = format_with(move |f: &mut CssFormatter<'_, 'a>| { @@ -453,7 +452,7 @@ pub fn write_sass_each<'a>(each: &SassEach<'a>, f: &mut CssFormatter<'_, 'a>) { }); inner.entry(&soft_line_break_or_space(), &binding); if span.end == in_span.start { - // `in` fused to the binding in the source. + // `in` fused to the binding in the source inner.entry(&format_with(|_| {}), &in_expr); } else { inner.entry(&soft_line_break_or_space(), &in_expr); @@ -484,7 +483,7 @@ pub fn write_sass_each<'a>(each: &SassEach<'a>, f: &mut CssFormatter<'_, 'a>) { } /// `@for $i from to|through ` -pub fn write_sass_for<'a>(sass_for: &SassFor<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_for<'a>(sass_for: &SassFor<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let binding_span = to_span(sass_for.binding.span()); write!(f, text(source.text_for(&binding_span))); @@ -498,7 +497,7 @@ pub fn write_sass_for<'a>(sass_for: &SassFor<'a>, f: &mut CssFormatter<'_, 'a>) } /// `@mixin name($params...)` -pub fn write_sass_mixin<'a>(mixin: &SassMixin<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_mixin<'a>(mixin: &SassMixin<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let name_span = to_span(mixin.name.span()); write!(f, text(source.text_for(&name_span))); @@ -508,10 +507,7 @@ pub fn write_sass_mixin<'a>(mixin: &SassMixin<'a>, f: &mut CssFormatter<'_, 'a>) } /// `@function name($params...)` -pub fn write_sass_function<'a>( - function: &raffia::ast::SassFunction<'a>, - f: &mut CssFormatter<'_, 'a>, -) { +pub(super) fn write_sass_function<'a>(function: &SassFunction<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let name_span = to_span(function.name.span()); write!(f, text(source.text_for(&name_span))); @@ -557,7 +553,7 @@ fn write_sass_parameters<'a>(parameters: &SassParameters<'a>, f: &mut CssFormatt } /// `@include name(args...) [using (params)]` -pub fn write_sass_include<'a>(include: &SassInclude<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_include<'a>(include: &SassInclude<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let name_span = to_span(include.name.span()); write!(f, text(source.text_for(&name_span))); @@ -569,8 +565,8 @@ pub fn write_sass_include<'a>(include: &SassInclude<'a>, f: &mut CssFormatter<'_ for (i, arg) in args.iter().enumerate() { if i > 0 { write!(f, ","); - // Preserve a blank line, but only after a multi-part - // argument (Prettier checks `value-comma_group`s only). + // Preserve a blank line, but only after a multi-part argument + // (Prettier checks `value-comma_group`s only). let prev = &args[i - 1]; let prev_is_group = matches!( prev, @@ -579,10 +575,10 @@ pub fn write_sass_include<'a>(include: &SassInclude<'a>, f: &mut CssFormatter<'_ let prev_end = to_span(prev.span()).end; let start = to_span(arg.span()).start; if prev_is_group - && crate::comments::classify_gap(source.bytes_range(prev_end, start)) - == crate::comments::Gap::Blank + && comments::classify_gap(source.bytes_range(prev_end, start)) + == comments::Gap::Blank { - write!(f, oxc_formatter_core::builders::empty_line()); + write!(f, empty_line()); } else { write!(f, soft_line_break_or_space()); } @@ -609,12 +605,12 @@ pub fn write_sass_include<'a>(include: &SassInclude<'a>, f: &mut CssFormatter<'_ } /// `@if cond { } @else if cond { } @else { }` -pub fn write_sass_if_at_rule<'a>(if_rule: &SassIfAtRule<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_if_at_rule<'a>(if_rule: &SassIfAtRule<'a>, f: &mut CssFormatter<'_, 'a>) { write!(f, ["@if", space()]); write_control_condition(&if_rule.if_clause.condition, f); - write_block(&if_rule.if_clause.block, f); + statement::write_block(&if_rule.if_clause.block, f); for clause in &if_rule.else_if_clauses { - // Comments between `}` and `@else` break the join. + // Comments between `}` and `@else` break the join let cond_start = to_span(clause.condition.span()).start; let mut broke_join = false; while let Some(comment) = f.context().comments().peek() { @@ -623,7 +619,7 @@ pub fn write_sass_if_at_rule<'a>(if_rule: &SassIfAtRule<'a>, f: &mut CssFormatte } f.context().comments().take_before(comment.span.end); write!(f, hard_line_break()); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); broke_join = true; } if broke_join { @@ -632,18 +628,18 @@ pub fn write_sass_if_at_rule<'a>(if_rule: &SassIfAtRule<'a>, f: &mut CssFormatte } else { write!(f, [space(), "@else", space()]); } - // `if` is a value word in postcss, so the condition may break after it. + // `if` is a value word in postcss, so the condition may break after it if matches!(clause.condition, ComponentValue::SassParenthesizedExpression(_)) { write!(f, ["if", space()]); write_control_condition(&clause.condition, f); } else { write_condition_chain(Some("if"), &clause.condition, f); } - write_block(&clause.block, f); + statement::write_block(&clause.block, f); } if let Some(else_block) = &if_rule.else_clause { write!(f, [space(), "@else", space()]); - write_block(else_block, f); + statement::write_block(else_block, f); } } @@ -671,7 +667,7 @@ fn write_control_condition<'a>(condition: &ComponentValue<'a>, f: &mut CssFormat enum CondPart<'b, 'a> { Value(&'b ComponentValue<'a>), /// Operator/keyword raw text (`and`, `or`, `not`, `==`, `*`, ...). - Op(oxc_span::Span), + Op(Span), } fn flatten_condition<'b, 'a>(cond: &'b ComponentValue<'a>, out: &mut Vec>) { @@ -682,7 +678,7 @@ fn flatten_condition<'b, 'a>(cond: &'b ComponentValue<'a>, out: &mut Vec + if matches!(unary.op.kind, SassUnaryOperatorKind::Not) => { out.push(CondPart::Op(to_span(unary.op.span()))); flatten_condition(&unary.expr, out); @@ -691,9 +687,9 @@ fn flatten_condition<'b, 'a>(cond: &'b ComponentValue<'a>, out: &mut Vec( prefix: Option<&'static str>, condition: &ComponentValue<'a>, @@ -706,7 +702,7 @@ fn write_condition_chain<'a>( let inner = format_with(move |f: &mut CssFormatter<'_, 'a>| { if let Some(word) = prefix { write!(f, text(word)); - // Separator to the first part: space when it's an operator. + // Separator to the first part: space when it's an operator if let Some(CondPart::Op(_)) = parts_ref.first() { write!(f, space()); } else { @@ -747,9 +743,9 @@ fn write_condition_chain<'a>( } /// `@use "path" as ns with (...)` / `@forward "path" as p-* show a, b with (...)` -pub fn write_sass_use<'a>(sass_use: &raffia::ast::SassUse<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_sass_use<'a>(sass_use: &SassUse<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); - if let raffia::ast::InterpolableStr::Literal(str) = &sass_use.path { + if let InterpolableStr::Literal(str) = &sass_use.path { value::write_str(str, f); } else { let span = to_span(sass_use.path.span()); @@ -758,11 +754,11 @@ pub fn write_sass_use<'a>(sass_use: &raffia::ast::SassUse<'a>, f: &mut CssFormat if let Some(namespace) = &sass_use.namespace { write!(f, [space(), "as", space()]); match &namespace.kind { - raffia::ast::SassUseNamespaceKind::Named(ident) => { + SassUseNamespaceKind::Named(ident) => { let span = to_span(ident.span()); write!(f, text(source.text_for(&span))); } - raffia::ast::SassUseNamespaceKind::Unnamed(_) => write!(f, "*"), + SassUseNamespaceKind::Unnamed(_) => write!(f, "*"), } } if let Some(config) = &sass_use.config { @@ -770,12 +766,9 @@ pub fn write_sass_use<'a>(sass_use: &raffia::ast::SassUse<'a>, f: &mut CssFormat } } -pub fn write_sass_forward<'a>( - forward: &raffia::ast::SassForward<'a>, - f: &mut CssFormatter<'_, 'a>, -) { +pub(super) fn write_sass_forward<'a>(forward: &SassForward<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); - if let raffia::ast::InterpolableStr::Literal(str) = &forward.path { + if let InterpolableStr::Literal(str) = &forward.path { value::write_str(str, f); } else { let span = to_span(forward.path.span()); @@ -787,10 +780,10 @@ pub fn write_sass_forward<'a>( } if let Some(visibility) = &forward.visibility { match visibility.modifier.kind { - raffia::ast::SassForwardVisibilityModifierKind::Show => { + SassForwardVisibilityModifierKind::Show => { write!(f, [space(), "show", space()]); } - raffia::ast::SassForwardVisibilityModifierKind::Hide => { + SassForwardVisibilityModifierKind::Hide => { write!(f, [space(), "hide", space()]); } } @@ -809,10 +802,7 @@ pub fn write_sass_forward<'a>( /// `with ($var: value, ...)`: configurations always break, one item per line, /// without a trailing comma. -fn write_sass_module_config<'a>( - config: &raffia::ast::SassModuleConfig<'a>, - f: &mut CssFormatter<'_, 'a>, -) { +fn write_sass_module_config<'a>(config: &SassModuleConfig<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); write!(f, [space(), "with", space(), "("]); let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { diff --git a/crates/oxc_formatter_css/src/print/selector.rs b/crates/oxc_formatter_css/src/print/selector.rs index f21257974995e..9fd0621810ecf 100644 --- a/crates/oxc_formatter_css/src/print/selector.rs +++ b/crates/oxc_formatter_css/src/print/selector.rs @@ -1,33 +1,50 @@ -//! Selector printing. Ports Prettier's `selector-*` cases (postcss-selector-parser) -//! onto raffia's structured selector AST. +//! Selector printing. +//! Ports Prettier's `selector-*` cases (postcss-selector-parser) onto raffia's structured selector AST. use std::borrow::Cow; use cow_utils::CowUtils; -use oxc_formatter_core::{ - Buffer, - builders::{group, hard_line_break, indent, soft_line_break, soft_line_break_or_space, text}, - write, -}; use raffia::{ Spanned, ast::{ AttributeSelector, AttributeSelectorMatcherKind, AttributeSelectorValue, Combinator, CombinatorKind, ComplexSelector, ComplexSelectorChild, CompoundSelector, InterpolableIdent, - NsPrefixKind, Nth, NthIndex, PseudoClassSelector, PseudoClassSelectorArgKind, - PseudoElementSelector, PseudoElementSelectorArgKind, SelectorList, SimpleSelector, - TypeSelector, WqName, + InterpolableStr, KeyframeSelector, NsPrefixKind, Nth, NthIndex, PseudoClassSelector, + PseudoClassSelectorArgKind, PseudoElementSelector, PseudoElementSelectorArgKind, + SelectorList, SimpleSelector, TypeSelector, WqName, }, }; +use oxc_formatter_core::{ + Buffer, arena_cow_str, + builders::{group, hard_line_break, indent, soft_line_break, soft_line_break_or_space, text}, + write, +}; + use crate::{ + TEMPLATE_PLACEHOLDER_PREFIX, comments, format::to_span, - print::{CssFormatter, format_with, statement::write_maybe_lowercase}, + print::{CssFormatter, format_with, normalize_whitespace, value, write_maybe_lowercase}, }; +/// Detects an ICSS rule (`:import { ... }` / `:export { ... }`) +/// by walking the first selector's leading pseudo-class instead of scanning raw source, +/// so leading whitespace or `:Import` casing don't fool the check. +pub(super) fn is_icss_selector(list: &SelectorList<'_>) -> bool { + let Some(first) = list.selectors.first() else { return false }; + let Some(ComplexSelectorChild::CompoundSelector(compound)) = first.children.first() else { + return false; + }; + let Some(SimpleSelector::PseudoClass(pseudo)) = compound.children.first() else { + return false; + }; + let InterpolableIdent::Literal(ident) = &pseudo.name else { return false }; + matches!(ident.name.as_ref(), "import" | "export") +} + /// How a selector list separates its selectors. #[derive(Clone, Copy, PartialEq, Eq)] -pub enum SelectorListStyle { +pub(super) enum SelectorListStyle { /// `,` + hardline (rule selectors at any nesting level). Hard, /// `,` + line (inside `@extend`, `@custom-selector`, `@nest`, pseudo args). @@ -35,25 +52,25 @@ pub enum SelectorListStyle { } /// Mirrors Prettier's `selector-root`. -pub fn write_selector_list<'a>( +pub(super) fn write_selector_list<'a>( list: &SelectorList<'a>, style: SelectorListStyle, f: &mut CssFormatter<'_, 'a>, ) { - // css-in-js `${}` placeholders (raffia fork's - // `tolerate_at_keyword_placeholders`): postcss-selector-parser degrades - // on at-words — everything from the selector containing the first - // placeholder onwards becomes one garbage token soup whose commas no - // longer split selectors. Prettier then prints it near-verbatim: + // css-in-js `${}` placeholders (`raffia` fork has `tolerate_at_keyword_placeholders` option): + // `postcss-selector-parser` degrades on at-words, + // everything from the selector containing the first placeholder onwards + // becomes one garbage token soup whose commas no longer split selectors. + // Prettier then prints it near-verbatim: // whitespace runs collapse to single spaces and the line never breaks. // Selectors BEFORE the first placeholder still split normally. - // Only the embedded entry point can contain placeholders; the gate also - // keeps a literal marker inside e.g. an attribute value (`[x="@prettier"]`) - // in a standalone file from triggering garbage mode. + // Only the embedded entry point can contain placeholders; + // the gate also keeps a literal marker inside + // (e.g. an attribute value (`[x="@prettier"]`)) in a standalone file from triggering garbage mode. let placeholder_idx = if f.context().template_placeholders() { let source = f.context().source_text(); list.selectors.iter().position(|complex| { - source.text_for(&to_span(complex.span())).contains(crate::TEMPLATE_PLACEHOLDER_PREFIX) + source.text_for(&to_span(complex.span())).contains(TEMPLATE_PLACEHOLDER_PREFIX) }) } else { None @@ -63,20 +80,19 @@ pub fn write_selector_list<'a>( for (i, complex) in list.selectors.iter().enumerate() { if i > 0 { write!(f, ","); - // A comment trailing the comma stays on the same line. + // A comment trailing the comma stays on the same line let next_start = to_span(complex.span()).start; if let Some(comment) = f.context().comments().peek() && comment.span.end <= next_start { let source = f.context().source_text(); let prev_end = to_span(list.selectors[i - 1].span()).end; - if crate::comments::classify_gap( - source.bytes_range(prev_end, comment.span.start), - ) == crate::comments::Gap::None + if comments::classify_gap(source.bytes_range(prev_end, comment.span.start)) + == comments::Gap::None { f.context().comments().take_before(comment.span.end); write!(f, " "); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); } } match style { @@ -84,10 +100,10 @@ pub fn write_selector_list<'a>( SelectorListStyle::Line => write!(f, soft_line_break_or_space()), } } - // Comments on their own line between selectors. + // Comments on their own line between selectors let start = to_span(complex.span()).start; for &comment in f.context().comments().take_before(start) { - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); write!(f, hard_line_break()); } if placeholder_idx == Some(i) { @@ -101,7 +117,7 @@ pub fn write_selector_list<'a>( } collapsed.push_str(word); } - // Comments inside the chunk are part of the verbatim text. + // Comments inside the chunk are part of the verbatim text let _ = f.context().comments().take_before(end); write!(f, text(collapsed.into_str())); return; @@ -112,9 +128,12 @@ pub fn write_selector_list<'a>( write!(f, group(&body)); } -/// Mirrors Prettier's `selector-selector`: group, indented when long -/// (more than 2 children). -pub fn write_complex_selector<'a>(complex: &ComplexSelector<'a>, f: &mut CssFormatter<'_, 'a>) { +/// Mirrors Prettier's `selector-selector`. +/// group, indented when long (more than 2 children). +pub(super) fn write_complex_selector<'a>( + complex: &ComplexSelector<'a>, + f: &mut CssFormatter<'_, 'a>, +) { let should_indent = complex.children.len() > 2; let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { for (i, child) in complex.children.iter().enumerate() { @@ -128,6 +147,7 @@ pub fn write_complex_selector<'a>(complex: &ComplexSelector<'a>, f: &mut CssForm } } }); + if should_indent { write!(f, group(&indent(&body))); } else { @@ -135,8 +155,8 @@ pub fn write_complex_selector<'a>(complex: &ComplexSelector<'a>, f: &mut CssForm } } -/// Mirrors Prettier's `selector-combinator`: -/// breakable line BEFORE the combinator, space after. +/// Mirrors Prettier's `selector-combinator`. +/// Breakable line BEFORE the combinator, space after. fn write_combinator( combinator: &Combinator, is_first: bool, @@ -165,7 +185,7 @@ fn write_combinator( } } -pub fn write_compound_selector<'a>(compound: &CompoundSelector<'a>, f: &mut CssFormatter<'_, 'a>) { +fn write_compound_selector<'a>(compound: &CompoundSelector<'a>, f: &mut CssFormatter<'_, 'a>) { for child in &compound.children { write_simple_selector(child, f); } @@ -200,27 +220,24 @@ fn write_simple_selector<'a>(selector: &SimpleSelector<'a>, f: &mut CssFormatter } fn write_interpolable_ident<'a>(ident: &InterpolableIdent<'a>, f: &mut CssFormatter<'_, 'a>) { - // Sass interpolation reprints structurally (same as value position) so the - // output is internally consistent: inner spaces collapse (`#{ $b }` → - // `#{$b}`) and quoted string literals re-quote (`#{'x'}` → `#{"x"}`). Plain - // idents and Less `@{}` stay verbatim. - // DELIBERATE DIVERGENCE: Prettier keeps SELECTOR interpolation verbatim (it - // only normalizes VALUE-position interpolation), so `#{ $b }` stays - // `#{ $b }` there. We normalize both positions for consistency — see AGENTS.md. + // Sass interpolation reprints structurally (same as value position) so the output is internally consistent: + // inner spaces collapse (`#{ $b }` → `#{$b}`) and quoted string literals re-quote (`#{'x'}` → `#{"x"}`). + // Plain idents and Less `@{}` stay verbatim. + // + // NOTE: Prettier keeps SELECTOR interpolation verbatim (it only normalizes VALUE-position interpolation), + // so `#{ $b }` stays `#{ $b }` there. + // We normalize both positions for consistency. if let InterpolableIdent::SassInterpolated(interp) = ident { - crate::print::value::write_sass_interpolated_ident( - interp, - crate::print::value::ValueContext::default(), - f, - ); + value::write_sass_interpolated_ident(interp, value::ValueContext::default(), f); return; } + let source = f.context().source_text(); let span = to_span(ident.span()); let raw = source.text_for(&span); - // Multi-line interpolations collapse to single spaces. + // Multi-line interpolations collapse to single spaces if raw.contains('\n') || raw.contains('\r') { - let joined = raw.split_whitespace().collect::>().join(" "); + let joined = normalize_whitespace(raw); write!(f, text(f.allocator().alloc_str(&joined))); } else { write!(f, text(raw)); @@ -240,9 +257,9 @@ fn write_wq_name<'a>(name: &WqName<'a>, f: &mut CssFormatter<'_, 'a>) { write_interpolable_ident(&name.name, f); } -/// Mirrors Prettier's `selector-tag`: lowercase `from`/`to` inside -/// `@keyframes`; HTML tag names are printed as-is (adjustNumbers is a no-op -/// for plain identifiers). +/// Mirrors Prettier's `selector-tag`. +/// Lowercase `from`/`to` inside `@keyframes`; +/// HTML tag names are printed as-is (`adjustNumbers` is a no-op for plain identifiers). fn write_type_selector<'a>(type_selector: &TypeSelector<'a>, f: &mut CssFormatter<'_, 'a>) { match type_selector { TypeSelector::TagName(tag) => write_wq_name(&tag.name, f), @@ -280,24 +297,25 @@ fn write_attribute_selector<'a>(attribute: &AttributeSelector<'a>, f: &mut CssFo match value { // Unquoted values get quoted (Prettier's `quoteAttributeValue`). AttributeSelectorValue::Ident(ident) => { - let quote = if f.options().single_quote.value() { "'" } else { "\"" }; + let quote = f.options().single_quote.as_str(); let span = to_span(ident.span()); write!(f, [text(quote), text(source.text_for(&span)), text(quote)]); } - AttributeSelectorValue::Str(raffia::ast::InterpolableStr::Literal(str)) => { - crate::print::value::write_str(str, f); + AttributeSelectorValue::Str(InterpolableStr::Literal(str)) => { + value::write_str(str, f); } - // Interpolated string (`[x="#{$v}"]`): re-quote the outer quotes per - // `singleQuote`, keep the `#{}` content verbatim. + // Interpolated string (`[x="#{$v}"]`): + // re-quote the outer quotes per `singleQuote`, keep the `#{}` content verbatim. AttributeSelectorValue::Str(istr) => { let span = to_span(istr.span()); - crate::print::value::write_requoted_verbatim(source.text_for(&span), f); + value::write_requoted_verbatim(source.text_for(&span), f); } - // `[class^=~'...']`: postcss sees a plain string after the `~`, so - // it re-quotes per `singleQuote` like the value-position handler. + // `[class^=~'...']`: + // postcss sees a plain string after the `~`, + // so it re-quotes per `singleQuote` like the value-position handler. AttributeSelectorValue::LessEscapedStr(escaped) => { write!(f, "~"); - crate::print::value::write_str(&escaped.str, f); + value::write_str(&escaped.str, f); } AttributeSelectorValue::Percentage(_) => { let span = to_span(value.span()); @@ -349,7 +367,7 @@ fn write_pseudo_class_arg<'a>(kind: &PseudoClassSelectorArgKind<'a>, f: &mut Css write!(f, soft_line_break_or_space()); } if let Some(combinator) = &selector.combinator { - // `is_last: false` already appends the space after `>`. + // `is_last: false` already appends the space after `>` write_combinator(combinator, true, false, f); } write_complex_selector(&selector.complex_selector, f); @@ -371,6 +389,13 @@ fn write_pseudo_class_arg<'a>(kind: &PseudoClassSelectorArgKind<'a>, f: &mut Css PseudoClassSelectorArgKind::Nth(nth) => write_nth(nth, f), // Number, LanguageRangeList, TokenSeq, LessExtendList: // print the source verbatim (normalized below where needed). + // + // NOTE: `raffia` provides structured AST for these (notably `LessExtendList`), + // so a structured printer would be feasible. + // They are kept verbatim because `postcss-selector-parser` tokenizes them as opaque strings + // and Prettier emits them raw (matching that keeps `:lang(...)`, `:extend(...)` etc.) + // byte-identical to Prettier output. + // Real-world usage is rare enough that the consistency cost is negligible. _ => { let span = to_span(kind.span()); write!(f, text(source.text_for(&span))); @@ -378,11 +403,12 @@ fn write_pseudo_class_arg<'a>(kind: &PseudoClassSelectorArgKind<'a>, f: &mut Css } } -/// `:nth-child()` argument. postcss-selector-parser tokenizes `An+B` so that -/// a `+` becomes a combinator (printed with one space on both sides) while a -/// `-` stays glued inside the word; digit-led words land in -/// `selector-unknown` and get `maybeToLowerCase`d, letter-led ones are tags -/// and keep their case. `odd`/`even`/integers print verbatim. +/// `:nth-child()` argument. +/// `postcss-selector-parser` tokenizes `An+B` so that a `+` becomes a combinator (printed with one space on both sides) +/// while a `-` stays glued inside the word; +/// digit-led words land in `selector-unknown` and get `maybeToLowerCase`d, +/// letter-led ones are tags and keep their case. +/// `odd`/`even`/integers print verbatim. fn write_nth<'a>(nth: &Nth<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let index_span = to_span(nth.index.span()); @@ -390,10 +416,7 @@ fn write_nth<'a>(nth: &Nth<'a>, f: &mut CssFormatter<'_, 'a>) { match &nth.index { NthIndex::AnPlusB(_) => { let normalized = normalize_an_plus_b(raw); - match normalized { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + write!(f, text(arena_cow_str(&normalized, f))); } NthIndex::Odd(_) | NthIndex::Even(_) | NthIndex::Integer(_) => { write!(f, text(raw)); @@ -403,7 +426,7 @@ fn write_nth<'a>(nth: &Nth<'a>, f: &mut CssFormatter<'_, 'a>) { write!(f, " "); let matcher_span = to_span(matcher.span()); if let Some(selector) = &matcher.selector { - // The `of` keyword as written, then the selector list. + // The `of` keyword as written, then the selector list let keyword_end = to_span(selector.span()).start; let keyword = source.slice_range(matcher_span.start, keyword_end).trim_ascii_end(); write!(f, [text(keyword), " "]); @@ -414,9 +437,9 @@ fn write_nth<'a>(nth: &Nth<'a>, f: &mut CssFormatter<'_, 'a>) { } } -/// Normalize an `An+B` expression: exactly one space around each `+` -/// (none before a leading `+`), digit-led segments lowercased, all other -/// whitespace kept as-is (a glued `-` stays glued). +/// Normalize an `An+B` expression: +/// exactly one space around each `+` (none before a leading `+`), +/// digit-led segments lowercased, all other whitespace kept as-is (a glued `-` stays glued). fn normalize_an_plus_b(raw: &str) -> Cow<'_, str> { let bytes = raw.as_bytes(); if !bytes.iter().any(|&b| b == b'+' || b.is_ascii_uppercase()) { @@ -496,37 +519,31 @@ fn write_pseudo_element<'a>(pseudo: &PseudoElementSelector<'a>, f: &mut CssForma } /// Lowercase `from` / `to` keyframe selectors; keep percentages as numbers. -pub fn write_keyframe_selector<'a>( - selector: &raffia::ast::KeyframeSelector<'a>, +pub(super) fn write_keyframe_selector<'a>( + selector: &KeyframeSelector<'a>, f: &mut CssFormatter<'_, 'a>, ) { let source = f.context().source_text(); match selector { - raffia::ast::KeyframeSelector::Ident(ident) => match ident { + KeyframeSelector::Ident(ident) => match ident { // Only `from`/`to` lowercase (Prettier's `isKeyframeAtRuleKeywords`); // raffia flags anything else as a recoverable error anyway. - raffia::ast::InterpolableIdent::Literal(lit) => { - match lit.raw.cow_to_ascii_lowercase() { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + InterpolableIdent::Literal(lit) => { + let lower = lit.raw.cow_to_ascii_lowercase(); + write!(f, text(arena_cow_str(&lower, f))); } - // Interpolations reprint structurally: idents/variables keep - // their case, dimensions/strings get value normalization. - raffia::ast::InterpolableIdent::SassInterpolated(interp) => { - crate::print::value::write_sass_interpolated_ident( - interp, - crate::print::value::ValueContext::default(), - f, - ); + // Interpolations reprint structurally: + // idents/variables keep their case, dimensions/strings get value normalization. + InterpolableIdent::SassInterpolated(interp) => { + value::write_sass_interpolated_ident(interp, value::ValueContext::default(), f); } - raffia::ast::InterpolableIdent::LessInterpolated(_) => { + InterpolableIdent::LessInterpolated(_) => { let span = to_span(ident.span()); write!(f, text(source.text_for(&span))); } }, - raffia::ast::KeyframeSelector::Percentage(percentage) => { - crate::print::value::write_number(&percentage.value, f); + KeyframeSelector::Percentage(percentage) => { + value::write_number(&percentage.value, f); write!(f, "%"); } } diff --git a/crates/oxc_formatter_css/src/print/statement.rs b/crates/oxc_formatter_css/src/print/statement.rs index 2783ec4fc2943..8bc3a3b64d4bb 100644 --- a/crates/oxc_formatter_css/src/print/statement.rs +++ b/crates/oxc_formatter_css/src/print/statement.rs @@ -1,43 +1,46 @@ -use std::borrow::Cow; - use cow_utils::CowUtils; +use raffia::{ + ParserBuilder, Spanned, + ast::{ComponentValue, Declaration, QualifiedRule, SimpleBlock, Statement}, +}; + use oxc_formatter_core::{ - Buffer, - builders::{block_indent, hard_line_break, indent, space, text}, + Buffer, arena_cow_str, + builders::{block_indent, dedent, empty_line, hard_line_break, indent, space, text}, write, }; -use raffia::{ - Spanned, - ast::{Declaration, QualifiedRule, SimpleBlock, Statement}, -}; use crate::{ - comments::{flush_trailing_inside_comments, write_leading_comments}, + comments::{ + Gap, classify_gap, flush_leading_comments, flush_trailing_inside_comments, + is_suppression_comment, last_line_has_inline_comment, write_leading_comments, + write_trailing_same_line_comment, + }, format::to_span, print::{ CssFormatter, at_rule, format_with, less, scss, selector, value::{self, ValueContext}, + write_maybe_lowercase, }, }; /// Start offset of a statement. -pub fn stmt_start(stmt: &Statement<'_>) -> u32 { +pub(super) fn stmt_start(stmt: &Statement<'_>) -> u32 { to_span(stmt.span()).start } /// End offset of a statement, extended over a trailing semicolon -/// (raffia's spans exclude it; postcss's `locEnd` includes it, and blank-line -/// detection counts from after the `;`). -pub fn stmt_end(stmt: &Statement<'_>, f: &CssFormatter<'_, '_>) -> u32 { +/// (raffia's spans exclude it; postcss's `locEnd` includes it, +/// and blank-line detection counts from after the `;`). +pub(super) fn stmt_end(stmt: &Statement<'_>, f: &CssFormatter<'_, '_>) -> u32 { end_with_semicolon(to_span(stmt.span()).end, f) } /// Extends `end` over any whitespace, comments and a final `;` in the source. -/// End of an at-rule's params region: the block start, or (without a block) -/// the span end — extended over a trailing `;` and shrunk back so the `;` -/// itself stays outside the region. -pub fn params_region_end<'a>( - block: Option<&raffia::ast::SimpleBlock<'a>>, +/// End of an at-rule's params region: the block start, or (without a block) the span end, +/// extended over a trailing `;` and shrunk back so the `;` itself stays outside the region. +pub(super) fn params_region_end<'a>( + block: Option<&SimpleBlock<'a>>, span_end: u32, f: &CssFormatter<'_, 'a>, ) -> u32 { @@ -50,7 +53,7 @@ pub fn params_region_end<'a>( ) } -pub fn end_with_semicolon(end: u32, f: &CssFormatter<'_, '_>) -> u32 { +pub(super) fn end_with_semicolon(end: u32, f: &CssFormatter<'_, '_>) -> u32 { let source = f.context().source_text(); let bytes = source.as_bytes(); let mut i = end as usize; @@ -58,7 +61,7 @@ pub fn end_with_semicolon(end: u32, f: &CssFormatter<'_, '_>) -> u32 { while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } - // Skip a block comment between the statement and its `;`. + // Skip a block comment between the statement and its `;` if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'*' { match source[i + 2..].find("*/") { Some(close) => { @@ -73,17 +76,69 @@ pub fn end_with_semicolon(end: u32, f: &CssFormatter<'_, '_>) -> u32 { if i < bytes.len() && bytes[i] == b';' { u32::try_from(i + 1).unwrap_or(end) } else { end } } +/// Emits `statements` separated by hard lines, preserving at most one blank line +/// between consecutive statements, flushing comments at their source positions. +/// Trailing same-line comments are only claimed when they end before `upper` +/// (a block's closing `}`), so inline rules don't steal comments that belong +/// to the parent. Pass `u32::MAX` at the stylesheet root. +/// +/// Mirrors Prettier's `printSequence`. +pub(super) fn write_statement_sequence_bounded<'a>( + statements: &[Statement<'a>], + upper: u32, + f: &mut CssFormatter<'_, 'a>, +) { + let source = f.context().source_text(); + for (i, stmt) in statements.iter().enumerate() { + let start = stmt_start(stmt); + if i > 0 { + let prev_end = stmt_end(&statements[i - 1], f); + // Trailing comment on the same line as the previous statement + // (but not one that sits after the NEXT statement on that line). + write_trailing_same_line_comment(prev_end, upper.min(start), f); + write!(f, hard_line_break()); + // Preserve a single blank line. The gap considered is from the end of + // the previous statement to the next printed position (comment or stmt). + let next_start = + f.context().comments().peek().map_or(start, |c| c.span.start.min(start)); + if classify_gap(source.bytes_range(prev_end, next_start)) == Gap::Blank { + write!(f, empty_line()); + } + } + + let is_suppressed = f + .context() + .comments() + .iter_before(start) + .last() + .is_some_and(|c| is_suppression_comment(source, c)); + flush_leading_comments(start, f); + let end = stmt_end(stmt, f); + if is_suppressed { + // Divergence: Prettier deletes `;`-less ignored at-rules at EOF. + // See `embedded/scss/placeholder-ignore.scss`. + write!(f, text(source.slice_range(start, end))); + } else { + write_statement(stmt, f); + } + // Discard comments inside spans the statement printer didn't claim + // (e.g. inside selectors/values that are still printed verbatim), + // so the cursor never points before an already-printed position. + let _ = f.context().comments().take_before(end); + } + if let Some(last) = statements.last() { + write_trailing_same_line_comment(stmt_end(last, f), upper, f); + } +} + /// Dispatch a single statement. -pub fn write_statement<'a>(stmt: &Statement<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_statement<'a>(stmt: &Statement<'a>, f: &mut CssFormatter<'_, 'a>) { match stmt { Statement::QualifiedRule(rule) => write_qualified_rule(rule, f), Statement::Declaration(decl) => { write_declaration(decl, f); // Nested declaration blocks (`background: { ... }`) get no `;`. - if !matches!( - decl.value.last(), - Some(raffia::ast::ComponentValue::SassNestingDeclaration(_)) - ) { + if !matches!(decl.value.last(), Some(ComponentValue::SassNestingDeclaration(_))) { write!(f, ";"); } } @@ -107,9 +162,8 @@ pub fn write_statement<'a>(stmt: &Statement<'a>, f: &mut CssFormatter<'_, 'a>) { } Statement::SassIfAtRule(if_rule) => scss::write_sass_if_at_rule(if_rule, f), Statement::UnknownSassAtRule(unknown) => { - // Same string-params contract as the Unknown prelude in - // `write_at_rule`: Prettier prints unknown at-rule params - // verbatim. + // Same string-params contract as the Unknown prelude in `write_at_rule`: + // Prettier prints unknown at-rule params verbatim. let source = f.context().source_text(); write!(f, "@"); let name_span = to_span(unknown.name.span()); @@ -140,6 +194,9 @@ pub fn write_statement<'a>(stmt: &Statement<'a>, f: &mut CssFormatter<'_, 'a>) { write_block(&keyframe_block.block, f); } // Not yet ported: emit the original source verbatim. + // - LessExtendRule + // - LessFunctionCall + // - LessVariableCall _ => { let span = to_span(stmt.span()); let source = f.context().source_text(); @@ -154,17 +211,16 @@ fn write_qualified_rule<'a>(rule: &QualifiedRule<'a>, f: &mut CssFormatter<'_, ' let source = f.context().source_text(); let sel_span = to_span(rule.selector.span()); let block_start = to_span(rule.block.span()).start; - // Comments inside the selector (both `//` and `/* */`) make Prettier - // print the raw selector verbatim (`selector-unknown`) — reordering them - // would change which compound they annotate. A trailing `//` comment - // pushes `{` to the next line. + // Comments inside the selector (both `//` and `/* */`) make Prettier print the raw selector verbatim (`selector-unknown`). + // Reordering them would change which compound they annotate. + // A trailing `//` comment pushes `{` to the next line. let has_inline_comment = f.context().comments().iter_before(block_start).any(|c| c.span.start >= sel_span.start); if has_inline_comment { let raw = source.slice_range(sel_span.start, block_start).trim_end(); let _ = f.context().comments().take_before(block_start); write!(f, text(raw)); - if crate::comments::last_line_has_inline_comment(raw) { + if last_line_has_inline_comment(raw) { write!(f, hard_line_break()); } else { write!(f, space()); @@ -174,40 +230,15 @@ fn write_qualified_rule<'a>(rule: &QualifiedRule<'a>, f: &mut CssFormatter<'_, ' } selector::write_selector_list(&rule.selector, selector::SelectorListStyle::Hard, f); write!(f, space()); - let raw_sel = source.text_for(&sel_span); - let is_icss = raw_sel.starts_with(":import") || raw_sel.starts_with(":export"); + let is_icss = selector::is_icss_selector(&rule.selector); let was = f.context().in_icss_rule().replace(is_icss); write_block(&rule.block, f); f.context().in_icss_rule().set(was); } -/// Mirrors Prettier's `maybeToLowerCase`: lowercase unless the identifier -/// contains variable/interpolation markers. -pub fn write_maybe_lowercase<'a>(value: &'a str, f: &mut CssFormatter<'_, 'a>) { - if value.contains('$') - || value.contains('@') - || value.contains('#') - || value.starts_with('%') - || value.starts_with("--") - || value.starts_with(":--") - || (value.contains('(') && value.contains(')')) - { - write!(f, text(value)); - } else { - match value.cow_to_ascii_lowercase() { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } - } -} - -/// A declaration without trailing semicolon (also used for `@supports (...)` features). -pub fn write_declaration_inline<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { - write_declaration(decl, f); -} - /// Mirrors Prettier's `css-decl`. -fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { +/// Used without a trailing semicolon for `@supports (...)` features (the caller skips the `;`). +pub(super) fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let name_span = to_span(decl.name.span()); let prop = source.text_for(&name_span); @@ -218,16 +249,16 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { } else { write_maybe_lowercase(prop, f); } - // Prettier prints the WHOLE `raws.between` (prop → value, `:` and any - // comments included) trimmed but otherwise verbatim, with a space before - // a leading `//` comment and a space before the value. + // Prettier prints the WHOLE `raws.between` + // (prop → value, `:` and any comments included) trimmed but otherwise verbatim, + // with a space before a leading `//` comment and a space before the value. let colon_end = to_span(&decl.colon_span).end; let between_upper = if decl.value.is_empty() { colon_end } else { to_span(decl.value[0].span()).start }; let between = source.slice_range(name_span.end, between_upper); let trimmed_between = between.trim_ascii(); - // `lastLineHasInlineComment`: the value drops to the next line, one - // indent under the prop (`indent([hardline, dedent(value)])`). + // `lastLineHasInlineComment`: the value drops to the next line, + // one indent under the prop (`indent([hardline, dedent(value)])`). let between_breaks = trimmed_between != ":" && trimmed_between.rsplit(['\n', '\r']).next().unwrap_or(trimmed_between).contains("//"); if trimmed_between == ":" { @@ -278,10 +309,7 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { } else { write!(f, space()); let prop_lower = prop.cow_to_ascii_lowercase(); - let prop_lower: &'a str = match prop_lower { - Cow::Borrowed(s) => s, - Cow::Owned(s) => f.allocator().alloc_str(&s), - }; + let prop_lower: &'a str = arena_cow_str(&prop_lower, f); // `filter: progid:...` values are printed verbatim. let value_start = to_span(decl.value[0].span()).start; @@ -292,8 +320,8 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { } else if (value_text.contains("\\(") || value_text.contains("\\)")) && value_text.contains('\n') { - // Escaped parens break postcss's value parser: the whole value - // is a `value-unknown`, printed verbatim. + // Escaped parens break postcss's value parser: + // the whole value is a `value-unknown`, printed verbatim. write!(f, text(value_text)); let _ = f.context().comments().take_before(value_end); } else if (value_text.starts_with('"') || value_text.starts_with('\'')) @@ -305,9 +333,9 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { to_span(w[0].span()).end == to_span(w[1].span()).start }) { - // A string containing SCSS interpolation that raffia tokenized - // apart (`"#{".5"}"`): print the pieces glued — numbers get - // normalized, strings keep their quotes. + // A string containing SCSS interpolation that `raffia` tokenized apart (`"#{".5"}"`): + // print the pieces glued. + // Numbers get normalized, strings keep their quotes. // Interpolation among the pieces → `value-unknown`: verbatim, // except bare quoted numbers, which postcss saw unquoted. value::write_requoted_verbatim(value_text, f); @@ -316,38 +344,34 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { if value_text.trim_end().ends_with('}') && value_text.bytes().filter(|&b| b == b'{').count() == 1 { - // `--prop: { decls };` custom-property rule blocks (postcss - // re-parses these as a rule): print the token stream as a block. + // `--prop: { decls };` custom-property rule blocks + // (postcss re-parses these as a rule): print the token stream as a block. value::flush_value_comments(value_start, f); write_custom_property_block(value_text, f); } else { - // Unparsable rule-ish value (e.g. missing `;` swallowed the - // following declarations): keep the source verbatim. + // Unparsable rule-ish value (e.g. missing `;` swallowed the following declarations): + // keep the source verbatim. write!(f, text(value_text)); } // The raw text includes any comments; drop them from the cursor. let _ = f.context().comments().take_before(value_end); } else { - // Custom property values come back from raffia as a raw token - // stream (per spec, `` is any token soup), but - // Prettier (postcss) value-parses them like any other declaration, + // Custom property values come back from `raffia` as a raw token stream + // (per spec, `` is any token soup), + // but Prettier (postcss) value-parses them like any other declaration, // so `var(...)` etc. get the normal group/break layout. let reparsed = if prop.starts_with("--") - && decl - .value - .iter() - .all(|v| matches!(v, raffia::ast::ComponentValue::TokenWithSpan(_))) + && decl.value.iter().all(|v| matches!(v, ComponentValue::TokenWithSpan(_))) { reparse_custom_property_value(decl, f) } else { None }; - let values: &[raffia::ast::ComponentValue<'a>] = - reparsed.as_ref().map_or(&decl.value, |d| &d.value); - // A custom property's `!important` lands in the REPARSED - // declaration (the original token-soup decl has `important: - // None`); remember its source offset so the tail printing below - // doesn't drop it. The padded copy keeps original offsets. + let values: &[ComponentValue<'a>] = reparsed.as_ref().map_or(&decl.value, |d| &d.value); + // A custom property's `!important` lands in the REPARSED declaration + // (the original token-soup decl has `important: None`); + // remember its source offset so the tail printing below doesn't drop it. + // The padded copy keeps original offsets. reparsed_important_start = reparsed .as_ref() .and_then(|d| d.important.as_ref()) @@ -357,30 +381,27 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { decl_prop: Some(prop_lower), // Prettier applies `removeLines` to `composes` values. no_break: prop_lower == "composes", - // Prettier's printer counts a multi-line between's FULL - // width, so the first trailing comment always wraps. + // Prettier's printer counts a multi-line between's FULL width, + // so the first trailing comment always wraps. tail_break: trimmed_between.contains('\n'), ..ValueContext::default() }; - // The `;` position bounds wrapped trailing comments — only when - // such comments exist (keeps simple declarations on the - // single-component fast path). + // The `;` position bounds wrapped trailing comments. + // Only when such comments exist (keeps simple declarations on the single-component fast path). let decl_end_pre = to_span(decl.span()).end; let semi = end_with_semicolon(decl_end_pre, f); let bound = if semi > decl_end_pre { semi - 1 } else { decl_end_pre }; - // A single interpolated component is exempt — its fill-chunk fit - // ignores the line tail (see `is_single_sass_interpolation`). + // A single interpolated component is exempt, + // its fill-chunk fit ignores the line tail (see `is_single_sass_interpolation`). let has_tail = decl.important.is_none() && reparsed_important_start.is_none() && !value::is_single_sass_interpolation(values) && f.context().comments().iter_before(bound).any(|c| c.span.start >= value_end); let ctx = ValueContext { tail_bound: has_tail.then_some(bound), ..ctx }; - // `prop: { decls }` — a trailing nested block hugs the - // declaration; leading values are space-joined flat. - if let Some(raffia::ast::ComponentValue::SassNestingDeclaration(nesting)) = - values.last() - { + // `prop: { decls }`: + // A trailing nested block hugs the declaration; leading values are space-joined flat. + if let Some(ComponentValue::SassNestingDeclaration(nesting)) = values.last() { for v in &values[..values.len() - 1] { value::write_component_value(v, ctx, f); write!(f, space()); @@ -389,14 +410,14 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { return; } if between_breaks { - // `indent([hardline, dedent(value)])` — the value's own indent - // is cancelled so it sits exactly one level under the prop. + // `indent([hardline, dedent(value)])`, + // the value's own indent is cancelled so it sits exactly one level under the prop. let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { write!(f, hard_line_break()); let inner = format_with(move |f: &mut CssFormatter<'_, 'a>| { value::write_declaration_value(values, ctx, f); }); - write!(f, oxc_formatter_core::builders::dedent(&inner)); + write!(f, dedent(&inner)); }); write!(f, indent(&body)); } else { @@ -411,30 +432,28 @@ fn write_declaration<'a>(decl: &Declaration<'a>, f: &mut CssFormatter<'_, 'a>) { value::flush_trailing_value_comments(start, f); write!(f, [space(), "!important"]); } - // Comments between the value and the `;`. Note: the `;` position is the - // flush bound, so a comment after `;` stays for the trailing-comment pass. + // Comments between the value and the `;`. + // NOTE: the `;` position is the flush bound, so a comment after `;` stays for the trailing-comment pass. let decl_end = to_span(decl.span()).end; let end = end_with_semicolon(decl_end, f); let bound = if end > decl_end { end - 1 } else { decl_end }; if let Some(comment_end) = value::flush_trailing_value_comments(bound, f) { - // Preserve a source gap between the last comment and the `;`. + // Preserve a source gap between the last comment and the `;` if end > decl_end && comment_end < end - 1 { write!(f, space()); } } } -/// Re-parse a custom property's raw token-stream value as a normal -/// declaration so the value gets the standard group/break layout. +/// Re-parse a custom property's raw token-stream value as a normal declaration, +/// so the value gets the standard group/break layout. /// -/// The declaration is rebuilt at the same source offsets — the prefix is -/// blanked out and the `--name` prop replaced by a same-length plain ident — -/// so every span in the re-parsed value stays valid against the original -/// source (Prettier pulls the same offset-preserving trick for -/// custom-property rule blocks in `parser-postcss.js`). +/// The declaration is rebuilt at the same source offsets, +/// (the prefix is blanked out and the `--name` prop replaced by a same-length plain ident) +/// so every span in the re-parsed value stays valid against the original source. +/// (Prettier pulls the same offset-preserving trick for custom-property rule blocks in `parser-postcss.js`) /// -/// Returns `None` (caller keeps the token stream) when the value does not -/// parse as a plain declaration value. +/// Returns `None` (caller keeps the token stream) when the value does not parse as a plain declaration value. fn reparse_custom_property_value<'a>( decl: &Declaration<'a>, f: &CssFormatter<'_, 'a>, @@ -448,8 +467,8 @@ fn reparse_custom_property_value<'a>( if c == '\n' || c == '\r' { padded.push(c); } else { - // One space per BYTE (not per char): spans are byte offsets, so a - // multi-byte char (e.g. `º` in a comment) must keep its width. + // One space per BYTE (not per char): spans are byte offsets, + // so a multi-byte char (e.g. `º` in a comment) must keep its width. for _ in 0..c.len_utf8() { padded.push(' '); } @@ -463,7 +482,7 @@ fn reparse_custom_property_value<'a>( let allocator = f.allocator(); let padded: &'a str = allocator.alloc_str(&padded); let syntax = f.options().variant.to_raffia(); - let mut parser = raffia::ParserBuilder::new(padded).syntax(syntax).build(); + let mut parser = ParserBuilder::new(padded).syntax(syntax).build(); let reparsed = parser.parse::().ok()?; if !parser.recoverable_errors().is_empty() { return None; @@ -471,8 +490,8 @@ fn reparse_custom_property_value<'a>( Some(reparsed) } -/// Prints a `--prop: { a: b; c: d }` rule-block value by re-flowing the raw -/// text: one declaration per line, normalized `prop: value;` spacing. +/// Prints a `--prop: { a: b; c: d }` rule-block value by re-flowing the raw text: +/// one declaration per line, normalized `prop: value;` spacing. fn write_custom_property_block<'a>(value_text: &'a str, f: &mut CssFormatter<'_, 'a>) { let inner = value_text.trim_end(); let inner = &inner[1..inner.len() - 1]; @@ -513,14 +532,14 @@ fn write_custom_property_block<'a>(value_text: &'a str, f: &mut CssFormatter<'_, if i > 0 { write!(f, hard_line_break()); } - // Re-flow line by line (keeps interior comments in place). + // Re-flow line by line (keeps interior comments in place) for (j, line) in decl.lines().map(str::trim).filter(|l| !l.is_empty()).enumerate() { if j > 0 { write!(f, hard_line_break()); } // Normalize `prop : value` spacing on declaration lines: - // split at the first colon OUTSIDE comments, keep the prop - // side verbatim, re-space the value side tokens. + // split at the first colon OUTSIDE comments, keep the prop side verbatim, + // re-space the value side tokens. if let Some(colon) = find_colon_outside_comments(line) { let (p, v) = line.split_at(colon); let v = &v[1..]; @@ -535,7 +554,7 @@ fn write_custom_property_block<'a>(value_text: &'a str, f: &mut CssFormatter<'_, write!(f, text(line)); } } - // A trailing comment-only segment gets no semicolon. + // A trailing comment-only segment gets no semicolon if !(i + 1 == decls.len() && decl.starts_with("/*") && decl.ends_with("*/")) { write!(f, ";"); } @@ -603,7 +622,7 @@ fn respace_value_tokens(v: &str) -> String { /// Mirrors Prettier's block printing: `{` + indented statements + `}`. /// An empty block prints as `{\n}`. -pub fn write_block<'a>(block: &SimpleBlock<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_block<'a>(block: &SimpleBlock<'a>, f: &mut CssFormatter<'_, 'a>) { let depth = f.context().block_depth(); depth.set(depth.get() + 1); write_block_inner(block, f); @@ -618,7 +637,7 @@ fn write_block_inner<'a>(block: &SimpleBlock<'a>, f: &mut CssFormatter<'_, 'a>) write!(f, "{"); if block.statements.is_empty() { - // Dangling comments inside an otherwise empty block. + // Dangling comments inside an otherwise empty block let comments = f.context().comments().take_before(r_curly); if comments.is_empty() { write!(f, hard_line_break()); @@ -631,7 +650,7 @@ fn write_block_inner<'a>(block: &SimpleBlock<'a>, f: &mut CssFormatter<'_, 'a>) } } else { let body = format_with(|f: &mut CssFormatter<'_, 'a>| { - crate::print::write_statement_sequence_bounded(&block.statements, r_curly, f); + write_statement_sequence_bounded(&block.statements, r_curly, f); let last_end = block.statements.last().map_or(block_span.start, |s| stmt_end(s, f)); flush_trailing_inside_comments(last_end, r_curly, f); }); diff --git a/crates/oxc_formatter_css/src/print/value.rs b/crates/oxc_formatter_css/src/print/value.rs index f73ba89243611..89aa9f51187e0 100644 --- a/crates/oxc_formatter_css/src/print/value.rs +++ b/crates/oxc_formatter_css/src/print/value.rs @@ -1,147 +1,84 @@ //! Component value printing. //! -//! Ports Prettier's `print/comma-separated-value-group.js`, -//! `print/parenthesized-value-group.js` and `print/misc.js` onto raffia's -//! flat `ComponentValue` streams (which mirror postcss-values-parser tokens: +//! Ports Prettier's +//! - `print/comma-separated-value-group.js` +//! - `print/parenthesized-value-group.js` +//! - `print/misc.js` +//! +//! onto raffia's flat `ComponentValue` streams +//! (which mirror `postcss-values-parser` tokens, //! commas and solidi appear as `Delimiter` components). use std::borrow::Cow; use cow_utils::CowUtils; +use raffia::{ + Spanned, + ast::{ + Calc, CalcOperatorKind, ComponentValue, Delimiter, DelimiterKind, Dimension, Function, + FunctionName, InterpolableIdent, InterpolableStr, LessBinaryOperation, + LessOperationOperatorKind, LessParenthesizedOperation, Number, SassBinaryExpression, + SassBinaryOperator, SassBinaryOperatorKind, SassInterpolatedIdent, + SassInterpolatedIdentElement, SassUnaryOperatorKind, Str, Url, UrlValue, + }, + token::Token, +}; + use oxc_formatter_core::{ - Buffer, + Buffer, SourceText, arena_cow_str, builders::{ - group, hard_line_break, indent, soft_line_break, soft_line_break_or_space, space, text, - token, + empty_line, expand_parent, group, hard_line_break, if_group_breaks, indent, + soft_line_break, soft_line_break_or_space, space, text, token, }, + spec::{format_trimmed_number, normalize_string}, write, }; -use raffia::{ - Spanned, - ast::{ComponentValue, Delimiter, DelimiterKind, Dimension, Function, Number, Str, Url}, -}; use crate::{ + CssFormatOptions, comments, format::to_span, - print::{CssFormatter, format_with}, + print::{CssFormatter, format_with, less, scss, statement}, }; -/// Prettier's `css-units-list`: lowercase -> canonical casing. -fn canonical_unit(lowercased: &str) -> Option<&'static str> { - Some(match lowercased { - "em" => "em", - "rem" => "rem", - "ex" => "ex", - "rex" => "rex", - "cap" => "cap", - "rcap" => "rcap", - "ch" => "ch", - "rch" => "rch", - "ic" => "ic", - "ric" => "ric", - "lh" => "lh", - "rlh" => "rlh", - "vw" => "vw", - "svw" => "svw", - "lvw" => "lvw", - "dvw" => "dvw", - "vh" => "vh", - "svh" => "svh", - "lvh" => "lvh", - "dvh" => "dvh", - "vi" => "vi", - "svi" => "svi", - "lvi" => "lvi", - "dvi" => "dvi", - "vb" => "vb", - "svb" => "svb", - "lvb" => "lvb", - "dvb" => "dvb", - "vmin" => "vmin", - "svmin" => "svmin", - "lvmin" => "lvmin", - "dvmin" => "dvmin", - "vmax" => "vmax", - "svmax" => "svmax", - "lvmax" => "lvmax", - "dvmax" => "dvmax", - "cm" => "cm", - "mm" => "mm", - "q" => "Q", - "in" => "in", - "pt" => "pt", - "pc" => "pc", - "px" => "px", - "deg" => "deg", - "grad" => "grad", - "rad" => "rad", - "turn" => "turn", - "s" => "s", - "ms" => "ms", - "hz" => "Hz", - "khz" => "kHz", - "dpi" => "dpi", - "dpcm" => "dpcm", - "dppx" => "dppx", - "x" => "x", - "cqw" => "cqw", - "cqh" => "cqh", - "cqi" => "cqi", - "cqb" => "cqb", - "cqmin" => "cqmin", - "cqmax" => "cqmax", - "fr" => "fr", - _ => return None, - }) -} - /// Prettier's `printNumber` + `printCssNumber` (trailing `.0` removal included). -pub fn print_css_number(raw: &str) -> Cow<'_, str> { - if raw.len() == 1 { - return Cow::Borrowed(raw); - } - let lowered = raw.cow_to_ascii_lowercase(); - - // Split off scientific notation exponent. - let (mantissa, exponent) = match lowered.find('e') { - Some(idx) => (&lowered[..idx], Some(&lowered[idx + 1..])), - None => (&lowered[..], None), - }; - - // Normalize exponent: remove `+` and leading zeroes; drop `e0`. - let exponent = exponent.map(|exp| { - let (sign, digits) = match exp.as_bytes().first() { - Some(b'+') => ("", &exp[1..]), - Some(b'-') => ("-", &exp[1..]), - _ => ("", exp), - }; - let digits = digits.trim_start_matches('0'); - if digits.is_empty() { String::new() } else { format!("e{sign}{digits}") } - }); +/// Normalize a CSS number for printing. +/// +/// Thin wrapper over [`format_trimmed_number`] with `keep_one_trailing_decimal_zero = false`, +/// the CSS policy (`x.00000` → `x`, vs JS's `x.0`). +pub(super) fn print_css_number(raw: &str) -> Cow<'_, str> { + format_trimmed_number(raw, false) +} - // Normalize mantissa (the sign, including `+`, is kept). - let (sign, digits) = match mantissa.as_bytes().first() { - Some(b'+') => ("+", &mantissa[1..]), - Some(b'-') => ("-", &mantissa[1..]), - _ => ("", mantissa), +/// Prettier's `css-units-list`: lowercase → canonical casing. +/// +/// Three units need special casing (`q→Q`, `hz→Hz`, `khz→kHz`); +/// everything else is identity-mapped, +/// so the bulk lives in a `phf` set whose `get_key` hands back the matching `&'static str`. +fn canonical_unit(lowercased: &str) -> Option<&'static str> { + static IDENTITY_UNITS: phf::Set<&'static str> = phf::phf_set! { + "cap", "ch", "cm", "cqb", "cqh", "cqi", "cqmax", "cqmin", "cqw", + "deg", "dpcm", "dpi", "dppx", "dvb", "dvh", "dvi", "dvmax", "dvmin", "dvw", + "em", "ex", "fr", "grad", "ic", "in", "lh", "lvb", "lvh", "lvi", "lvmax", "lvmin", "lvw", + "mm", "ms", "pc", "pt", "px", "rad", "rcap", "rch", "rem", "rex", "ric", "rlh", + "s", "svb", "svh", "svi", "svmax", "svmin", "svw", + "turn", "vb", "vh", "vi", "vmax", "vmin", "vw", "x", }; - let mut digits = digits.to_string(); - if digits.contains('.') { - // Remove extraneous trailing decimal zeroes and the trailing dot. - let trimmed = digits.trim_end_matches('0'); - let trimmed = trimmed.strip_suffix('.').unwrap_or(trimmed); - digits = trimmed.to_string(); - } - // Make sure numbers always start with a digit. - if digits.starts_with('.') { - digits.insert(0, '0'); - } else if digits.is_empty() { - digits.push('0'); + match lowercased { + "q" => Some("Q"), + "hz" => Some("Hz"), + "khz" => Some("kHz"), + _ => IDENTITY_UNITS.get_key(lowercased).copied(), } +} - let exponent = exponent.unwrap_or_default(); - let result = format!("{sign}{digits}{exponent}"); - if result == raw { Cow::Borrowed(raw) } else { Cow::Owned(result) } +pub(super) fn write_number<'a>(number: &Number<'a>, f: &mut CssFormatter<'_, 'a>) { + let printed = print_css_number(number.raw); + write!(f, text(arena_cow_str(&printed, f))); +} + +fn write_dimension<'a>(dimension: &Dimension<'a>, f: &mut CssFormatter<'_, 'a>) { + write_number(&dimension.value, f); + print_unit(dimension.unit.raw, f); } /// Prettier's `printUnit`. @@ -154,87 +91,41 @@ fn print_unit<'a>(raw_unit: &'a str, f: &mut CssFormatter<'_, 'a>) { } } -pub fn write_number<'a>(number: &Number<'a>, f: &mut CssFormatter<'_, 'a>) { - match print_css_number(number.raw) { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } -} - -pub fn write_dimension<'a>(dimension: &Dimension<'a>, f: &mut CssFormatter<'_, 'a>) { - write_number(&dimension.value, f); - print_unit(dimension.unit.raw, f); -} - -/// Prettier's `printString`: re-quote according to `singleQuote` unless the -/// content contains quotes that would need extra escaping. -pub fn print_string(raw: &str, single_quote: bool) -> Cow<'_, str> { +/// Prettier's `printString`. +/// Re-quote per [`CssFormatOptions::preferred_quote`] +/// unless the content contains quotes that would need extra escaping. +/// +/// The actual escape rewrite delegates to [`normalize_string`]. +fn print_string<'a>(raw: &'a str, options: &CssFormatOptions) -> Cow<'a, str> { let content = &raw[1..raw.len() - 1]; + let enclosing = options.preferred_quote(content); - let (preferred, alternate) = if single_quote { ('\'', '"') } else { ('"', '\'') }; - let mut preferred_count = 0usize; - let mut alternate_count = 0usize; - for b in content.bytes() { - if b == preferred as u8 { - preferred_count += 1; - } else if b == alternate as u8 { - alternate_count += 1; - } - } - let enclosing = if preferred_count > alternate_count { alternate } else { preferred }; - - if raw.as_bytes()[0] == enclosing as u8 { + if raw.as_bytes()[0] == enclosing { return Cow::Borrowed(raw); } - // makeString: flip escapes as needed. - let other = if enclosing == '"' { '\'' } else { '"' }; + let normalized = normalize_string(content, enclosing, true); let mut out = String::with_capacity(raw.len()); - out.push(enclosing); - let mut chars = content.chars(); - while let Some(c) = chars.next() { - match c { - '\\' => match chars.next() { - Some(next @ ('"' | '\'' | '\\')) => { - if next == other { - out.push(other); - } else { - out.push('\\'); - out.push(next); - } - } - Some(next) => { - out.push('\\'); - out.push(next); - } - None => out.push('\\'), - }, - c if c == enclosing => { - out.push('\\'); - out.push(c); - } - c => out.push(c), - } - } - out.push(enclosing); + out.push(enclosing as char); + out.push_str(&normalized); + out.push(enclosing as char); Cow::Owned(out) } -pub fn write_str<'a>(str: &Str<'a>, f: &mut CssFormatter<'_, 'a>) { - let single_quote = f.options().single_quote.value(); - match print_string(str.raw, single_quote) { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } +pub(super) fn write_str<'a>(str: &Str<'a>, f: &mut CssFormatter<'_, 'a>) { + let printed = print_string(str.raw, f.options()); + write!(f, text(arena_cow_str(&printed, f))); } /// Prettier's `adjustNumbers(adjustStrings(...))` over a raw source slice: -/// strings re-quote via `printString`, standalone numbers (not glued to a -/// word part) get `printCssNumber` + lowercased/canonical unit; everything -/// else — whitespace and newlines included — stays verbatim. Used where -/// Prettier prints raw selector text: Less mixin definitions/calls and -/// `when` guards. -pub fn adjust_numbers_and_strings(raw: &str, single_quote: bool) -> Cow<'_, str> { +/// strings re-quote via `printString`, +/// standalone numbers (not glued to a word part) get `printCssNumber` + lowercased/canonical unit; +/// Everything else (whitespace and newlines included) stays verbatim. +/// Used where Prettier prints raw selector text: Less mixin definitions/calls and `when` guards. +pub(super) fn adjust_numbers_and_strings<'a>( + raw: &'a str, + options: &CssFormatOptions, +) -> Cow<'a, str> { let bytes = raw.as_bytes(); let mut out = String::with_capacity(raw.len()); let mut changed = false; @@ -242,8 +133,8 @@ pub fn adjust_numbers_and_strings(raw: &str, single_quote: bool) -> Cow<'_, str> let is_word_start = |b: u8| b == b'_' || b.is_ascii_alphabetic() || b >= 0x80; let is_word_char = |b: u8| b == b'_' || b == b'-' || b.is_ascii_alphanumeric() || b >= 0x80; - // `(?:\d*\.\d+|\d+\.?)(?:e[+-]?\d+)?` — returns the end index, or `start` - // if no number begins here. + // `(?:\d*\.\d+|\d+\.?)(?:e[+-]?\d+)?`: + // returns the end index, or `start` if no number begins here. let scan_number = |start: usize| -> usize { let mut j = start; while j < bytes.len() && bytes[j].is_ascii_digit() { @@ -298,7 +189,7 @@ pub fn adjust_numbers_and_strings(raw: &str, single_quote: bool) -> Cow<'_, str> j += if bytes[j] == b'\\' { 2 } else { 1 }; } if j < bytes.len() { - let printed = print_string(&raw[i..=j], single_quote); + let printed = print_string(&raw[i..=j], options); changed |= matches!(printed, Cow::Owned(_)); out.push_str(&printed); i = j + 1; @@ -332,7 +223,7 @@ pub fn adjust_numbers_and_strings(raw: &str, single_quote: bool) -> Cow<'_, str> i = j; continue; } - // Standalone number (+ optional unit). + // Standalone number (+ optional unit) if b.is_ascii_digit() || b == b'.' { let number_end = scan_number(i); if number_end > i { @@ -357,7 +248,7 @@ pub fn adjust_numbers_and_strings(raw: &str, single_quote: bool) -> Cow<'_, str> continue; } } - // Plain byte; push the whole UTF-8 char. + // Plain byte; push the whole UTF-8 char let ch_len = raw[i..].chars().next().map_or(1, char::len_utf8); out.push_str(&raw[i..i + ch_len]); i += ch_len; @@ -368,21 +259,21 @@ pub fn adjust_numbers_and_strings(raw: &str, single_quote: bool) -> Cow<'_, str> /// Re-quotes the OUTER quotes of a raw quoted string per `singleQuote`, /// keeping the content (e.g. `#{...}` interpolation) verbatim. -pub fn write_requoted_verbatim<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_requoted_verbatim<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) { if raw.len() < 2 || !(raw.starts_with('\'') || raw.starts_with('"')) { write!(f, text(raw)); return; } + let content = &raw[1..raw.len() - 1]; - // Interpolated strings whose content has quote characters keep their - // original quotes (the inner quotes confuse the preference count) — - // but bare quoted numbers inside `#{}` are normalized (postcss sees - // them as unquoted numbers). + // Interpolated strings whose content has quote characters keep their original quotes + // (the inner quotes confuse the preference count), + // but bare quoted numbers inside `#{}` are normalized (postcss sees them as unquoted numbers). if content.contains("#{") && (content.contains('"') || content.contains('\'')) { - // The outer quote re-appearing inside the content splits the string - // in postcss; every piece gets requoted to the preferred quote. + // The outer quote re-appearing inside the content splits the string in postcss; + // every piece gets requoted to the preferred quote. let outer = raw.as_bytes()[0] as char; - let preferred = if f.options().single_quote.value() { '\'' } else { '"' }; + let preferred = f.options().single_quote.as_char(); if outer != preferred && content.contains(outer) && !content.contains(preferred) { let replaced = raw.cow_replace(outer, preferred.encode_utf8(&mut [0; 4])); let normalized = normalize_quoted_numbers(&replaced); @@ -397,22 +288,13 @@ pub fn write_requoted_verbatim<'a>(raw: &'a str, f: &mut CssFormatter<'_, 'a>) { } return; } - let single_quote = f.options().single_quote.value(); - let (preferred, alternate) = if single_quote { ('\'', '"') } else { ('"', '\'') }; - let mut pc = 0usize; - let mut ac = 0usize; - for b in content.bytes() { - if b == preferred as u8 { - pc += 1; - } else if b == alternate as u8 { - ac += 1; - } - } - let enclosing = if pc > ac { alternate } else { preferred }; - if raw.as_bytes()[0] == enclosing as u8 { + + let enclosing = f.options().preferred_quote(content); + if raw.as_bytes()[0] == enclosing { write!(f, text(raw)); } else { - let out = format!("{enclosing}{content}{enclosing}"); + let q = enclosing as char; + let out = format!("{q}{content}{q}"); write!(f, text(f.allocator().alloc_str(&out))); } } @@ -422,14 +304,14 @@ fn normalize_quoted_numbers(raw: &str) -> String { let bytes = raw.as_bytes(); let mut out = String::with_capacity(raw.len()); let mut i = 0; - // Skip the outer opening quote. + // Skip the outer opening quote out.push(bytes[0] as char); i += 1; let end = raw.len() - 1; while i < end { let b = bytes[i]; if (b == b'"' || b == b'\'') && i + 1 < end { - // Find the matching close within the content. + // Find the matching close within the content if let Some(close_rel) = raw[i + 1..end].find(b as char) { let inner = &raw[i + 1..i + 1 + close_rel]; if !inner.is_empty() && inner.bytes().all(|c| c.is_ascii_digit() || c == b'.') { @@ -500,16 +382,15 @@ fn is_possible_font_size(value: &ComponentValue<'_>) -> bool { } fn function_name_text<'a>(func: &Function<'a>) -> &'a str { - use raffia::ast::FunctionName; match &func.name { - FunctionName::Ident(raffia::ast::InterpolableIdent::Literal(ident)) => ident.raw, + FunctionName::Ident(InterpolableIdent::Literal(ident)) => ident.raw, _ => "", } } /// Layout context for a value being printed. #[derive(Clone, Copy, Default)] -pub struct ValueContext<'a> { +pub(super) struct ValueContext<'a> { /// Lowercased property name of the enclosing declaration, if any. pub decl_prop: Option<&'a str>, /// The value never breaks: `composes` (Prettier's `removeLines`) and @@ -518,28 +399,24 @@ pub struct ValueContext<'a> { /// SCSS map printed in key position: stays inline (Prettier's `isKey`). pub map_key: bool, /// A parenthesized comma list here breaks one item per line - /// (only as a direct map-item value — `isSCSSMapItemNode` needs key-value - /// pairs in the group or its grandparent). + /// (only as a direct map-item value, + /// `isSCSSMapItemNode` needs key-value pairs in the group or its grandparent). pub paren_break: bool, /// SCSS maps here always break (`$var:` values, function args, map items). pub map_break: bool, - /// Comments before this bound are flushed as wrapped fill items at the - /// end of the outermost comma group (declaration tail). + /// Comments before this bound are flushed as wrapped fill items + /// at the end of the outermost comma group (declaration tail). pub tail_bound: Option, /// Inside `url(...)`: colons stay tight (`url(fbglyph:cross-outline)`). pub in_url: bool, /// Inside function/include arguments (comment-slot rules differ). pub in_args: bool, - /// This component directly follows a `//` comment: a function call here - /// gets Prettier's quirky double indent (args +2 levels, `)` +1). - pub after_inline_comment: bool, - /// A multi-line `raws.between` was printed before the value: Prettier's - /// printer counts its full width, so the first trailing comment always - /// wraps. + /// A multi-line `raws.between` was printed before the value: + /// Prettier's printer counts its full width, so the first trailing comment always wraps. pub tail_break: bool, - /// Less variable declaration value (`@var: ...`): Prettier's - /// `shouldPrecededBySoftline` matches `css-decl` ONLY, so the value fill - /// starts on the colon line and never breaks right after the colon. + /// Less variable declaration value (`@var: ...`): + /// Prettier's `shouldPrecededBySoftline` matches `css-decl` ONLY, + /// so the value fill starts on the colon line and never breaks right after the colon. pub no_leading_softline: bool, } @@ -564,7 +441,7 @@ fn split_comma_groups<'b, 'a>(values: &'b [ComponentValue<'a>]) -> Vec<&'b [Comp } } groups.push(&values[start..]); - // A trailing comma produces an empty last group; Prettier drops it. + // A trailing comma produces an empty last group; Prettier drops it if groups.len() > 1 && groups.last().is_some_and(|g| g.is_empty()) { groups.pop(); } @@ -573,31 +450,31 @@ fn split_comma_groups<'b, 'a>(values: &'b [ComponentValue<'a>]) -> Vec<&'b [Comp /// Mirrors Prettier's top-level declaration value printing /// (`value-root` -> `value-paren_group` without parens). -pub fn write_declaration_value<'a>( +pub(super) fn write_declaration_value<'a>( values: &[ComponentValue<'a>], ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, ) { - // A lone SCSS list/map IS the value (`$var: 1px 2px, 3px;`). + // A lone SCSS list/map IS the value (`$var: 1px 2px, 3px;`) if values.len() == 1 && matches!(values[0], ComponentValue::SassList(_) | ComponentValue::SassMap(_)) { - crate::print::scss::write_top_level_value(&values[0], ctx, f); + scss::write_top_level_value(&values[0], ctx, f); return; } let groups = split_comma_groups(values); if groups.len() == 1 { - // Flattened to a single comma group. + // Flattened to a single comma group write_comma_group(groups[0], ctx, f); return; } - // Multiple comma groups: `shouldBreakList` forces one per line when any - // group has multiple elements (a real `value-comma_group` survives - // flattening — comments count as members) and the property is not a - // custom property. + // Multiple comma groups: + // `shouldBreakList` forces one per line when any group has multiple elements + // (a real `value-comma_group` survives flattening, comments count as members) + // and the property is not a custom property. let value_start = to_span(values[0].span()).start; let value_end = to_span(values[values.len() - 1].span()).end; let has_comments = @@ -609,36 +486,35 @@ pub fn write_declaration_value<'a>( write_value_groups(&groups, ctx, force_hard_line, true, f); } -/// Does this comma group count as a `value-comma_group` for Prettier's -/// `shouldBreakList`? Multi-element groups do; so does a single ident with a -/// leading `-` in non-initial position, which postcss-values splits into an -/// operator + word (`Arial, -apple-system` breaks the list while -/// `-apple-system, Arial` does not). -pub fn comma_group_is_multi(group: &[ComponentValue<'_>], is_first: bool) -> bool { +/// Does this comma group count as a `value-comma_group` for Prettier's `shouldBreakList`? +/// Multi-element groups do; so does a single ident with a leading `-` in non-initial position, +/// which postcss-values splits into an operator + word +/// (`Arial, -apple-system` breaks the list while `-apple-system, Arial` does not). +pub(super) fn comma_group_is_multi(group: &[ComponentValue<'_>], is_first: bool) -> bool { if group.len() > 1 { return true; } - // Less `~'...'` lexes as TWO postcss-values nodes (`~` word + string), so - // it is a real `value-comma_group` in ANY position. + // Less `~'...'` lexes as TWO postcss-values nodes (`~` word + string), + // so it is a real `value-comma_group` in ANY position. if matches!(group.first(), Some(ComponentValue::LessEscapedStr(_))) { return true; } !is_first && matches!(group.first(), - Some(ComponentValue::InterpolableIdent(raffia::ast::InterpolableIdent::Literal(id))) + Some(ComponentValue::InterpolableIdent(InterpolableIdent::Literal(id))) if id.raw.starts_with('-') && !id.raw.starts_with("--")) } -/// Top-level comma-group list layout, shared between flat component streams -/// and SCSS comma lists. -pub fn write_value_groups<'a>( +/// Top-level comma-group list layout, +/// shared between flat component streams and SCSS comma lists. +pub(super) fn write_value_groups<'a>( groups: &[&[ComponentValue<'a>]], ctx: ValueContext<'a>, force_hard_line: bool, _top_level: bool, f: &mut CssFormatter<'_, 'a>, ) { - // A single comma group never forces (it isn't a list). + // A single comma group never forces (it isn't a list) let force_hard_line = force_hard_line && groups.len() > 1; if force_hard_line { let body = format_with(|f: &mut CssFormatter<'_, 'a>| { @@ -648,28 +524,25 @@ pub fn write_value_groups<'a>( write!(f, ","); write!(f, hard_line_break()); } - // The declaration tail belongs to the LAST group only. + // The declaration tail belongs to the LAST group only let gctx = if i + 1 < groups.len() { ValueContext { tail_bound: None, ..ctx } } else { ctx }; // Comments between groups (e.g. after the previous comma) - // fill together with the group they precede; `//` comments - // (and leading own-line comments) keep their own line. - let lead: Vec = group_values - .first() - .map(|first| { - f.context().comments().take_before(to_span(first.span()).start).to_vec() - }) - .unwrap_or_default(); + // fill together with the group they precede; + // `//` comments (and leading own-line comments) keep their own line. + let lead: &'a [comments::CssComment] = group_values.first().map_or(&[], |first| { + f.context().comments().take_before(to_span(first.span()).start) + }); if lead.is_empty() { write_comma_group(group_values, gctx, f); } else if i == 0 { let source = f.context().source_text(); - for &comment in &lead { + for &comment in lead { let own_line = comment_is_own_line(comment, source); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline || own_line { write!(f, hard_line_break()); } else { @@ -679,8 +552,8 @@ pub fn write_value_groups<'a>( write_comma_group(group_values, gctx, f); } else { // Prettier-fill the comments with the group they lead. - // Simulated with static widths: the group's fit must NOT - // include its declaration tail, which our entry would. + // Simulated with static widths: + // the group's fit must NOT include its declaration tail, which our entry would. let width = u32::from(f.options().line_width.value()); let group_w = group_values.first().zip(group_values.last()).map_or(0, |(first, last)| { @@ -688,7 +561,7 @@ pub fn write_value_groups<'a>( }) + u32::from(i + 1 < groups.len()); let mut x = 4u32; // hardline indent under the value for (k, &comment) in lead.iter().enumerate() { - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); x += comment.span.end - comment.span.start; let next_w = lead.get(k + 1).map_or(group_w, |c| c.span.end - c.span.start); if comment.inline { @@ -733,10 +606,7 @@ pub fn write_value_groups<'a>( } /// `true` when only whitespace including a newline precedes the comment. -pub fn comment_is_own_line( - comment: crate::comments::CssComment, - source: oxc_formatter_core::SourceText<'_>, -) -> bool { +pub(super) fn comment_is_own_line(comment: comments::CssComment, source: SourceText<'_>) -> bool { let bytes = source.as_bytes(); let mut i = comment.span.start as usize; while i > 0 { @@ -753,12 +623,12 @@ pub fn comment_is_own_line( /// Emits pending comments that precede `upper_bound` inline /// (`//` comments force a break after themselves and expand the parent). /// Returns true when the last emitted comment was a `//` comment. -pub fn flush_value_comments(upper_bound: u32, f: &mut CssFormatter<'_, '_>) -> bool { +pub(super) fn flush_value_comments(upper_bound: u32, f: &mut CssFormatter<'_, '_>) -> bool { let mut last_inline = false; for &comment in f.context().comments().take_before(upper_bound) { - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, [oxc_formatter_core::builders::expand_parent(), hard_line_break()]); + write!(f, [expand_parent(), hard_line_break()]); } else { write!(f, " "); } @@ -769,11 +639,7 @@ pub fn flush_value_comments(upper_bound: u32, f: &mut CssFormatter<'_, '_>) -> b /// Emits pending comments that sit on the same line as the just-printed /// content ending at `prev_end` (` // c` / ` /* c */`), up to `upper_bound`. -pub fn flush_same_line_comments_before( - prev_end: u32, - upper_bound: u32, - f: &mut CssFormatter<'_, '_>, -) { +fn flush_same_line_comments_before(prev_end: u32, upper_bound: u32, f: &mut CssFormatter<'_, '_>) { loop { let Some(comment) = f.context().comments().peek() else { return }; let source = f.context().source_text(); @@ -785,9 +651,9 @@ pub fn flush_same_line_comments_before( } f.context().comments().take_before(comment.span.end); write!(f, " "); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, [oxc_formatter_core::builders::expand_parent()]); + write!(f, [expand_parent()]); return; } } @@ -795,23 +661,23 @@ pub fn flush_same_line_comments_before( /// Unbounded variant (used at container tails where everything pending /// belongs to the container). -pub fn flush_same_line_comments(prev_end: u32, f: &mut CssFormatter<'_, '_>) { +pub(super) fn flush_same_line_comments(prev_end: u32, f: &mut CssFormatter<'_, '_>) { flush_same_line_comments_before(prev_end, u32::MAX, f); } /// Emits pending comments before `upper_bound` as ` /* c */` suffixes /// (used after the last value component, before `;` / `!important`). /// Returns the end offset of the last emitted comment. -pub fn flush_trailing_value_comments( +pub(super) fn flush_trailing_value_comments( upper_bound: u32, f: &mut CssFormatter<'_, '_>, ) -> Option { let mut last_end = None; for &comment in f.context().comments().take_before(upper_bound) { write!(f, " "); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, [oxc_formatter_core::builders::expand_parent(), hard_line_break()]); + write!(f, [expand_parent(), hard_line_break()]); } last_end = Some(comment.span.end); } @@ -819,22 +685,22 @@ pub fn flush_trailing_value_comments( } /// A value that is exactly one sass interpolation (`--p: #{fn(...)};`). -/// Prettier's value parser splits `#{` into multiple fill chunks, and a fill -/// chunk's fit ignores the rest of the line (`;`, trailing comments) — unlike -/// a bare func (`calc`), whose group fit counts them. `write_comma_group` -/// routes this shape through a fill entry for the chunk-isolated fit, and -/// `write_declaration` exempts it from tail-comment counting. -pub fn is_single_sass_interpolation(values: &[ComponentValue<'_>]) -> bool { +/// Prettier's value parser splits `#{` into multiple fill chunks, +/// and a fill chunk's fit ignores the rest of the line (`;`, trailing comments). +/// Unlike a bare func (`calc`), whose group fit counts them. +/// `write_comma_group` routes this shape through a fill entry for the chunk-isolated fit, +/// and `write_declaration` exempts it from tail-comment counting. +pub(super) fn is_single_sass_interpolation(values: &[ComponentValue<'_>]) -> bool { values.len() == 1 && matches!( values[0], - ComponentValue::InterpolableIdent(raffia::ast::InterpolableIdent::SassInterpolated(_)) + ComponentValue::InterpolableIdent(InterpolableIdent::SassInterpolated(_)) ) } -/// Mirrors Prettier's `printCommaSeparatedValueGroup`: -/// joins components with `line`, except for pairs that must stay tight. -pub fn write_comma_group<'a>( +/// Mirrors Prettier's `printCommaSeparatedValueGroup`. +/// Joins components with `line`, except for pairs that must stay tight. +pub(super) fn write_comma_group<'a>( values: &[ComponentValue<'a>], ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, @@ -842,14 +708,12 @@ pub fn write_comma_group<'a>( if values.is_empty() { return; } - // Prettier's `flattenGroups`: a single-element comma group collapses to - // the element itself (no extra group/indent level). + // Prettier's `flattenGroups`: + // a single-element comma group collapses to the element itself (no extra group/indent level). if values.len() == 1 && ctx.tail_bound.is_none() { - let after_inline = flush_value_comments(to_span(values[0].span()).start, f); - let ctx = - if after_inline { ValueContext { after_inline_comment: true, ..ctx } } else { ctx }; - // EXCEPT a sass interpolation: route through a fill entry to get the - // chunk-isolated fit (see `is_single_sass_interpolation`). + flush_value_comments(to_span(values[0].span()).start, f); + // EXCEPT a sass interpolation: + // route through a fill entry to get the chunk-isolated fit (see `is_single_sass_interpolation`). if is_single_sass_interpolation(values) { let value = &values[0]; let content = format_with(move |f: &mut CssFormatter<'_, 'a>| { @@ -863,14 +727,14 @@ pub fn write_comma_group<'a>( } return; } - // A single value WITH a tail bound routes through the fill below so its - // trailing comments wrap. + // A single value WITH a tail bound routes through the fill below + // so its trailing comments wrap. let source = f.context().source_text(); let upper_bound = to_span(values[values.len() - 1].span()).end; // Grid values that break across source lines start on their own line - // (Prettier's `didBreak` + `parts.unshift(hardline)`) — but breaks caused - // by inline comments in the gap don't count (the comment-hardline branch - // runs before the grid check in Prettier). + // (Prettier's `didBreak` + `parts.unshift(hardline)`), + // but breaks caused by inline comments in the gap don't count. + // (the comment-hardline branch runs before the grid check in Prettier) let grid_did_break = ctx.is_grid() && (1..values.len()).any(|i| { separator_between(values, i, ctx, source) == Separator::Hard && { @@ -886,13 +750,13 @@ pub fn write_comma_group<'a>( if grid_did_break { write!(f, hard_line_break()); } - // Snapshot of pending comments inside the value (for separator - // decisions; consumption happens inside the entries). - let pending: Vec = + // Snapshot of pending comments inside the value + // (for separator decisions; consumption happens inside the entries). + let pending: Vec = f.context().comments().iter_before(upper_bound).collect(); let tail_bound = ctx.tail_bound; let ctx = ValueContext { tail_bound: None, ..ctx }; - let tail_comments: Vec = tail_bound + let tail_comments: Vec = tail_bound .map(|bound| { f.context() .comments() @@ -908,7 +772,7 @@ pub fn write_comma_group<'a>( // (ignored by the fill builder for the first entry). let mut sep = if i == 0 { Separator::Line } else { separator_between(values, i, ctx, source) }; - // An inline comment trailing the PREVIOUS run forces a break. + // An inline comment trailing the PREVIOUS run forces a break if i > 0 { let prev_end = to_span(values[i - 1].span()).end; let start = to_span(values[i].span()).start; @@ -921,7 +785,7 @@ pub fn write_comma_group<'a>( sep = Separator::Hard; } } - // Merge runs of tight / non-breaking-space components into one fill entry. + // Merge runs of tight / non-breaking-space components into one fill entry let mut run_end = i + 1; while run_end < values.len() && matches!( @@ -937,19 +801,14 @@ pub fn write_comma_group<'a>( let run_end_pos = to_span(values[run_end - 1].span()).end; let is_last_run = run_end == values.len(); let content = format_with(move |f: &mut CssFormatter<'_, 'a>| { - let after_inline = flush_value_comments(start, f); + flush_value_comments(start, f); for (j, v) in run.iter().enumerate() { if j > 0 && separator_between(values, run_start + j, ctx, source) == Separator::Space { write!(f, " "); } - let vctx = if j == 0 && after_inline { - ValueContext { after_inline_comment: true, ..ctx } - } else { - ctx - }; - write_component_value(v, vctx, f); + write_component_value(v, ctx, f); } // Same-line `//` comments stay attached to this entry // (a separate entry would break with the expanded group). @@ -965,8 +824,8 @@ pub fn write_comma_group<'a>( } f.context().comments().take_before(comment.span.end); write!(f, " "); - crate::comments::write_single_comment(comment, f); - write!(f, oxc_formatter_core::builders::expand_parent()); + comments::write_single_comment(comment, f); + write!(f, expand_parent()); } } }); @@ -997,9 +856,9 @@ pub fn write_comma_group<'a>( let entry = format_with(move |f: &mut CssFormatter<'_, 'a>| { if f.context().comments().peek().is_some_and(|c| c.span == comment.span) { f.context().comments().take_before(comment.span.end); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, oxc_formatter_core::builders::expand_parent()); + write!(f, expand_parent()); } } }); @@ -1008,13 +867,13 @@ pub fn write_comma_group<'a>( } i = run_end; } - // Declaration-tail comments wrap as fill items. + // Declaration-tail comments wrap as fill items for (k, &comment) in tail_comments.iter().enumerate() { let entry = format_with(move |f: &mut CssFormatter<'_, 'a>| { f.context().comments().take_before(comment.span.end); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, oxc_formatter_core::builders::expand_parent()); + write!(f, expand_parent()); } }); if k == 0 && ctx.tail_break { @@ -1047,30 +906,33 @@ fn math_op_token(value: &ComponentValue<'_>) -> bool { if let ComponentValue::TokenWithSpan(token) = value { matches!( &token.token, - raffia::token::Token::Plus(_) - | raffia::token::Token::Minus(_) - | raffia::token::Token::Asterisk(_) - | raffia::token::Token::Percent(_) - | raffia::token::Token::Solidus(_) + Token::Plus(_) + | Token::Minus(_) + | Token::Asterisk(_) + | Token::Percent(_) + | Token::Solidus(_) ) } else { false } } -fn raw_token<'b, 'a>(value: &'b ComponentValue<'a>) -> Option<&'b raffia::token::Token<'a>> { +fn raw_token<'b, 'a>(value: &'b ComponentValue<'a>) -> Option<&'b Token<'a>> { if let ComponentValue::TokenWithSpan(token) = value { Some(&token.token) } else { None } } /// Decides the separator BEFORE `values[i]` (i >= 1). /// -/// In Prettier's loop terms: `iNode` = `values[i - 1]`, `iNextNode` = -/// `values[i]`, `iPrevNode` = `values[i - 2]`, `iNextNextNode` = `values[i + 1]`. +/// In Prettier's loop terms: +/// - `iNode` = `values[i - 1]` +/// - `iNextNode` = `values[i]` +/// - `iPrevNode` = `values[i - 2]` +/// - `iNextNextNode` = `values[i + 1]` fn separator_between( values: &[ComponentValue<'_>], i: usize, ctx: ValueContext<'_>, - source: oxc_formatter_core::SourceText<'_>, + source: SourceText<'_>, ) -> Separator { let prev = &values[i - 1]; let curr = &values[i]; @@ -1081,9 +943,9 @@ fn separator_between( // `hasEmptyRawBefore(iNode)` let prev_gap_empty = i >= 2 && to_span(values[i - 2].span()).end == prev_span.start; - // Grid: preserve source line structure — Prettier emits a hardline where - // the source breaks and a PLAIN SPACE otherwise (never re-wraps a - // single-line grid value, however long). + // Grid: preserve source line structure: + // Prettier emits a hardline where the source breaks and a PLAIN SPACE otherwise + // (never re-wraps a single-line grid value, however long). if ctx.is_grid() { let gap = source.bytes_range(prev_span.end, curr_span.start); if gap.contains(&b'\n') || gap.contains(&b'\r') { @@ -1092,13 +954,13 @@ fn separator_between( return Separator::Space; } - // Solidus (`/`) spacing rules. + // Solidus (`/`) spacing rules if is_solidus(curr) || is_solidus(prev) { - // Path-like streams (`-fb-url(/a/b.png)`): glued tokens stay glued. + // Path-like streams (`-fb-url(/a/b.png)`): glued tokens stay glued if is_solidus(&values[0]) && gap_empty { return Separator::Tight; } - // Fully glued `/` (`center/80%`, `12px/1.5`): one postcss word. + // Fully glued `/` (`center/80%`, `12px/1.5`): one postcss word if is_solidus(curr) && gap_empty && values.get(i + 1).is_none_or(|n| to_span(curr.span()).end == to_span(n.span()).start) @@ -1108,8 +970,8 @@ fn separator_between( if is_solidus(prev) && gap_empty && prev_gap_empty { return Separator::Tight; } - // `font: 12px/1.5` and custom properties: keep tight when the source - // is tight around a font-size-capable node. + // `font: 12px/1.5` and custom properties: + // keep tight when the source is tight around a font-size-capable node. if ctx.is_font_or_custom() { if is_solidus(curr) && gap_empty && is_possible_font_size(prev) { return Separator::Tight; @@ -1119,8 +981,7 @@ fn separator_between( return Separator::Tight; } } - - // Leading `/` (e.g. `-fb-url(/abs/path)`). + // Leading `/` (e.g. `-fb-url(/abs/path)`) if i == 1 && is_solidus(prev) { return Separator::Tight; } @@ -1145,7 +1006,7 @@ fn separator_between( return Separator::Line; } - // Less lookups: `@var [@result]` loses the gap (`var [@lookup]` rule). + // Less lookups: `@var [@result]` loses the gap (`var [@lookup]` rule) if matches!(curr, ComponentValue::BracketBlock(_)) && matches!( prev, @@ -1158,13 +1019,12 @@ fn separator_between( return Separator::Tight; } - // Raw token punctuation: `:` hugs left and spaces right, braces hug - // their contents (custom-property JSON-ish values). + // Raw token punctuation: + // `:` hugs left and spaces right, braces hug their contents (custom-property JSON-ish values). { - use raffia::token::Token; // SCSS `if(...)` separates branches with `;` parsed as a `Delimiter` - // (not a raw `Token::Semicolon`); like every `;` it hugs the value on - // its left, dropping any source space before it (Prettier #19384). + // (not a raw `Token::Semicolon`); like every `;` it hugs the value on its left, + // dropping any source space before it (Prettier #19384). if is_semicolon(curr) { return Separator::Tight; } @@ -1185,26 +1045,26 @@ fn separator_between( return Separator::Tight; } if matches!(raw_token(prev), Some(Token::Colon(_) | Token::Comma(_))) { - // No space after `:` inside `url(...)`. + // No space after `:` inside `url(...)` if ctx.in_url && matches!(raw_token(prev), Some(Token::Colon(_))) { return Separator::Tight; } return Separator::Space; } - // `*` is never glued (Prettier excludes multiplication from the - // tight rules) — except Tailwind's `ident-*` pattern. + // `*` is never glued (Prettier excludes multiplication from the tight rules), + // except Tailwind's `ident-*` pattern. if matches!(raw_token(curr), Some(Token::Asterisk(_))) || matches!(raw_token(prev), Some(Token::Asterisk(_))) { if gap_empty - && matches!(prev, ComponentValue::InterpolableIdent(raffia::ast::InterpolableIdent::Literal(id)) if id.raw.ends_with('-')) + && matches!(prev, ComponentValue::InterpolableIdent(InterpolableIdent::Literal(id)) if id.raw.ends_with('-')) { return Separator::Tight; } return Separator::Line; } - // Token-level division, mirroring the structural rules: tight only - // when glued in the source and no word/function neighbors. + // Token-level division, mirroring the structural rules: + // tight only when glued in the source and no word/function neighbors. let is_solidus_tok = |v: &ComponentValue<'_>| matches!(raw_token(v), Some(Token::Solidus(_))); // A leading `/` makes the stream path-like (`-fb-url(/a/b.png)`): @@ -1212,8 +1072,8 @@ fn separator_between( if is_solidus_tok(&values[0]) && gap_empty { return Separator::Tight; } - // Token-level font shorthand rule (`--font: var(--size)/2` in - // custom properties, where the value is a raw token stream). + // Token-level font shorthand rule + // (`--font: var(--size)/2` in custom properties, where the value is a raw token stream). if ctx.is_font_or_custom() { let font_size_ish = |v: Option<&ComponentValue<'_>>| { v.is_some_and(|v| { @@ -1255,11 +1115,10 @@ fn separator_between( } } - // Prettier: an at-word placeholder glued to a paren group gets a - // `softline` (`${fn}(30px)` may break BEFORE the parens, keeping the - // paren group intact on the next line). + // Prettier: an at-word placeholder glued to a paren group gets a `softline` + // (`${fn}(30px)` may break BEFORE the parens, keeping the paren group intact on the next line). if gap_empty - && matches!(raw_token(prev), Some(raffia::token::Token::AtKeyword(_))) + && matches!(raw_token(prev), Some(Token::AtKeyword(_))) && matches!( curr, ComponentValue::SassParenthesizedExpression(_) | ComponentValue::SassMap(_) @@ -1268,8 +1127,8 @@ fn separator_between( return Separator::SoftBreak; } - // Raw token fallbacks (postcss-values would have produced operator/word - // tokens): keep gap-free neighbors tight (`10em+12em`, `-fb-url(/a/b)`). + // Raw token fallbacks (postcss-values would have produced operator/word tokens): + // keep gap-free neighbors tight (`10em+12em`, `-fb-url(/a/b)`). if gap_empty && (matches!(prev, ComponentValue::TokenWithSpan(_)) || matches!(curr, ComponentValue::TokenWithSpan(_)) @@ -1279,15 +1138,14 @@ fn separator_between( return Separator::Tight; } - // postcss-values lexes `1#{$var}` as ONE word: a neighbor glued to an - // interpolated ident stays glued. + // postcss-values lexes `1#{$var}` as ONE word: + // a neighbor glued to an interpolated ident stays glued. { let interpolated = |v: &ComponentValue<'_>| { matches!( v, ComponentValue::InterpolableIdent( - raffia::ast::InterpolableIdent::SassInterpolated(_) - | raffia::ast::InterpolableIdent::LessInterpolated(_), + InterpolableIdent::SassInterpolated(_) | InterpolableIdent::LessInterpolated(_), ) ) }; @@ -1296,9 +1154,9 @@ fn separator_between( } } - // Math operators with surrounding spaces: `a + b` keeps the space before - // the operator non-breaking and breaks after it (Prettier's - // `isNextMathOperator` merge). + // Math operators with surrounding spaces: + // `a + b` keeps the space before the operator non-breaking and breaks after it + // (Prettier's `isNextMathOperator` merge). if math_op_token(curr) { return Separator::Space; } @@ -1307,7 +1165,7 @@ fn separator_between( } /// Dispatch a single component value. -pub fn write_component_value<'a>( +pub(super) fn write_component_value<'a>( value: &ComponentValue<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, @@ -1332,34 +1190,34 @@ pub fn write_component_value<'a>( } ComponentValue::HexColor(hex) => { write!(f, "#"); - match hex.raw.cow_to_ascii_lowercase() { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + let lower = hex.raw.cow_to_ascii_lowercase(); + write!(f, text(arena_cow_str(&lower, f))); } - ComponentValue::InterpolableIdent(raffia::ast::InterpolableIdent::Literal(ident)) => { + ComponentValue::InterpolableIdent(InterpolableIdent::Literal(ident)) => { if is_wide_keyword(ident.raw) { - match ident.raw.cow_to_ascii_lowercase() { - Cow::Borrowed(s) => write!(f, text(s)), - Cow::Owned(s) => write!(f, text(f.allocator().alloc_str(&s))), - } + let lower = ident.raw.cow_to_ascii_lowercase(); + write!(f, text(arena_cow_str(&lower, f))); } else { write!(f, text(ident.raw)); } } - ComponentValue::InterpolableIdent(raffia::ast::InterpolableIdent::SassInterpolated( - interp, - )) => { - // Prettier's value parser splits `#{fn(...)}` into multiple fill - // chunks wrapped in the value's `group(indent(fill))`, so a - // breaking call inside the interpolation carries ONE extra indent - // level (`args` +2, `)}` +1 relative to the property). + ComponentValue::InterpolableIdent(InterpolableIdent::SassInterpolated(interp)) => { + // Prettier's value parser splits `#{fn(...)}` into + // multiple fill chunks wrapped in the value's `group(indent(fill))`. + // So a breaking call inside the interpolation carries ONE extra indent level. + // ``` + // --prop: #{fn( + // $args, + // $args2, + // )}; + // ``` + // `args` +2 and `)}` +1, both relative to the property. let body = format_with(move |f: &mut CssFormatter<'_, 'a>| { write_sass_interpolated_ident(interp, ctx, f); }); write!(f, indent(&body)); } - ComponentValue::InterpolableStr(raffia::ast::InterpolableStr::Literal(str)) => { + ComponentValue::InterpolableStr(InterpolableStr::Literal(str)) => { write_str(str, f); } ComponentValue::InterpolableStr(istr) => { @@ -1368,10 +1226,10 @@ pub fn write_component_value<'a>( } ComponentValue::Function(func) => write_function(func, ctx, f), ComponentValue::Calc(calc) => write_calc(calc, ctx, f), - ComponentValue::SassMap(map) => crate::print::scss::write_sass_map(map, ctx, f), - ComponentValue::SassList(list) => crate::print::scss::write_sass_list(list, ctx, f), + ComponentValue::SassMap(map) => scss::write_sass_map(map, ctx, f), + ComponentValue::SassList(list) => scss::write_sass_list(list, ctx, f), ComponentValue::SassParenthesizedExpression(paren) => { - // `$var: ((a, b), (c, d))` / map item values: one item per line. + // `$var: ((a, b), (c, d))` / map item values: one item per line if ctx.paren_break { // Map-item values always break (`isSCSSMapItemNode`), // with a trailing comma per option. @@ -1415,17 +1273,17 @@ pub fn write_component_value<'a>( write_component_value(el, inner_ctx, f); } if trailing && (ctx.map_key || ctx.paren_break) { - write!(f, oxc_formatter_core::builders::if_group_breaks(&text(","))); + write!(f, if_group_breaks(&text(","))); } } else { write_component_value(&paren.expr, inner_ctx, f); } - // Inline comments before `)` stay inside, forcing the break. + // Inline comments before `)` stay inside, forcing the break for &comment in f.context().comments().take_before(r_paren) { write!(f, " "); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, oxc_formatter_core::builders::expand_parent()); + write!(f, expand_parent()); } } }); @@ -1440,16 +1298,16 @@ pub fn write_component_value<'a>( ); } ComponentValue::SassUnaryExpression(unary) => { - let keyword_not = matches!(unary.op.kind, raffia::ast::SassUnaryOperatorKind::Not); + let keyword_not = matches!(unary.op.kind, SassUnaryOperatorKind::Not); match unary.op.kind { - raffia::ast::SassUnaryOperatorKind::Plus => write!(f, "+"), - raffia::ast::SassUnaryOperatorKind::Minus => write!(f, "-"), - raffia::ast::SassUnaryOperatorKind::Not => write!(f, ["not", " "]), + SassUnaryOperatorKind::Plus => write!(f, "+"), + SassUnaryOperatorKind::Minus => write!(f, "-"), + SassUnaryOperatorKind::Not => write!(f, ["not", " "]), } - // Prettier glues a unary `+`/`-` to its operand even across a - // source gap (`- ( $x / 2 )` → `-($x / 2)`, `- 2deg` → `-2deg`) — - // EXCEPT before a function call, where the gap is preserved - // (`- pow(2, 2)` stays spaced). `not` already wrote its space. + // Prettier glues a unary `+`/`-` to its operand even across a source gap + // (`- ( $x / 2 )` → `-($x / 2)`, `- 2deg` → `-2deg`). + // EXCEPT before a function call, where the gap is preserved (`- pow(2, 2)` stays spaced). + // `not` already wrote its space. if !keyword_not && is_func_like(&unary.expr) && to_span(unary.op.span()).end != to_span(unary.expr.span()).start @@ -1460,7 +1318,7 @@ pub fn write_component_value<'a>( } ComponentValue::SassBinaryExpression(binary) => write_sass_binary(binary, ctx, f), ComponentValue::SassKeywordArgument(kw) => { - // Block values (maps/parens) hug the colon without pair indent. + // Block values (maps/parens) hug the colon without pair indent if matches!( &*kw.value, ComponentValue::SassMap(_) | ComponentValue::SassParenthesizedExpression(_) @@ -1470,7 +1328,7 @@ pub fn write_component_value<'a>( write_component_value(&kw.value, ctx, f); return; } - // `$name: value` may break after the colon when too long. + // `$name: value` may break after the colon when too long let pair = format_with(move |f: &mut CssFormatter<'_, 'a>| { let mut filler = f.fill(); let name_span = to_span(kw.name.span()); @@ -1487,14 +1345,14 @@ pub fn write_component_value<'a>( write!(f, group(&indent(&pair))); } ComponentValue::SassNestingDeclaration(nesting) => { - crate::print::statement::write_block(&nesting.block, f); + statement::write_block(&nesting.block, f); } ComponentValue::SassArbitraryArgument(arg) => { write_component_value(&arg.value, ctx, f); write!(f, "..."); } ComponentValue::Url(url) => write_url(url, f), - // Less lookups / nth bracket blocks: `[...]` hugs its contents. + // Less lookups / nth bracket blocks: `[...]` hugs its contents ComponentValue::BracketBlock(bracket) => { write!(f, "["); for (i, v) in bracket.value.iter().enumerate() { @@ -1509,24 +1367,23 @@ pub fn write_component_value<'a>( let span = to_span(range.span()); write!(f, text(source.text_for(&span))); } - // `~'...'`: postcss sees a plain string token after the `~`, so it - // re-quotes per `singleQuote` like any other string. + // `~'...'`: postcss sees a plain string token after the `~`, + // so it re-quotes per `singleQuote` like any other string. ComponentValue::LessEscapedStr(escaped) => { write!(f, "~"); write_str(&escaped.str, f); } - // Less arithmetic: a flat operator fill (break after the operator), - // mirroring Prettier. Safe to restructure because raffia only ASTs a - // whitespace-followed `+`/`-` as a binary operator (see - // `write_less_binary_operation`). + // Less arithmetic: a flat operator fill (break after the operator), mirroring Prettier. + // Safe to restructure because `raffia` only ASTs a whitespace-followed + // `+`/`-` as a binary operator (see `write_less_binary_operation`). ComponentValue::LessBinaryOperation(op) => write_less_binary_operation(op, ctx, f), ComponentValue::LessParenthesizedOperation(paren) => { write_less_parenthesized_operation(paren, ctx, f); } - // Everything else (Sass/Less constructs, interpolations, token - // fallbacks): print the source verbatim until ported structurally. + // Everything else (Sass/Less constructs, interpolations, token fallbacks): + // print the source verbatim until ported structurally. _ => { - if crate::print::less::write_less_component_value(value, f) { + if less::write_less_component_value(value, f) { return; } let span = to_span(value.span()); @@ -1539,11 +1396,11 @@ pub fn write_component_value<'a>( /// `(operand, following-op)` pairs, mirroring postcss's flat token stream. fn flatten_sass_binary<'b, 'a>( expr: &'b ComponentValue<'a>, - out: &mut Vec<(&'b ComponentValue<'a>, Option<&'b raffia::ast::SassBinaryOperator>)>, + out: &mut Vec<(&'b ComponentValue<'a>, Option<&'b SassBinaryOperator>)>, ) { if let ComponentValue::SassBinaryExpression(binary) = expr { flatten_sass_binary(&binary.left, out); - // Attach this operator to the last operand collected. + // Attach this operator to the last operand collected if let Some(last) = out.last_mut() { last.1 = Some(&binary.op); } @@ -1553,10 +1410,10 @@ fn flatten_sass_binary<'b, 'a>( } } -/// SCSS binary expression: a flat fill of `operand op` entries; spaces follow -/// the source around each operator, breaking after operators. +/// SCSS binary expression: a flat fill of `operand op` entries; +/// spaces follow the source around each operator, breaking after operators. fn write_sass_binary<'a>( - binary: &raffia::ast::SassBinaryExpression<'a>, + binary: &SassBinaryExpression<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, ) { @@ -1573,25 +1430,24 @@ fn write_sass_binary<'a>( let mut i = 0; while i < parts.len() { // Merge a run while operators are tight to the next operand. - // `*` never merges: Prettier excludes multiplication from the - // tight rules (`$m*100` → `$m * 100`). Word-like operands force - // spaces on BOTH sides even when glued (`$a+"str"` → `$a + "str"`), + // `*` never merges: Prettier excludes multiplication from the tight rules (`$m*100` → `$m * 100`). + // Word-like operands force spaces on BOTH sides even when glued (`$a+"str"` → `$a + "str"`), // so they end the run too. let mut run_end = i; let mut division_force_space = false; while let Some(op) = parts[run_end].1 { - if matches!(op.kind, raffia::ast::SassBinaryOperatorKind::Multiply) { + if matches!(op.kind, SassBinaryOperatorKind::Multiply) { break; } let op_span = to_span(op.span()); let next = &parts[run_end + 1].0; let operand = &parts[run_end].0; - // Division mirrors postcss-values lexing: a word-led chunk - // absorbs a directly attached `/` (`$w/2`, `$w/ 2` stay - // verbatim); otherwise word/func NEIGHBORS force spaces on - // both sides (`2/$w` -> `2 / $w`, `$w /2` -> `$w / 2`) while - // plain numbers keep the source gap (`10px/8px`, `1/2`). - if matches!(op.kind, raffia::ast::SassBinaryOperatorKind::Division) { + // Division mirrors postcss-values lexing: + // a word-led chunk absorbs a directly attached `/` (`$w/2`, `$w/ 2` stay verbatim); + // otherwise word/func NEIGHBORS force spaces on both sides + // (`2/$w` -> `2 / $w`, `$w /2` -> `$w / 2`) + // while plain numbers keep the source gap (`10px/8px`, `1/2`). + if matches!(op.kind, SassBinaryOperatorKind::Division) { let wf = |v: &ComponentValue<'_>| is_word_like(v) || is_func_like(v); let left_absorbs = to_span(operand.span()).end == op_span.start && wf(operand); if !left_absorbs { @@ -1612,12 +1468,10 @@ fn write_sass_binary<'a>( || is_word_like(next) || is_func_like(next)) { - // An asymmetric `+`/`-` (whitespace BEFORE, glued AFTER) is a - // signed operand in postcss-values lexing — Prettier keeps it - // glued (`$a -$b` stays `$a -$b`, NOT `$a - $b`). It matters - // for the ambiguous Sass `margin: -$a -$b` list/subtraction - // case (dart-sass deprecates the binary reading). Merge it - // into this run so no fill space lands after the operator. + // An asymmetric `+`/`-` (whitespace BEFORE, glued AFTER) is a signed operand in postcss-values lexing, + // Prettier keeps it glued (`$a -$b` stays `$a -$b`, NOT `$a - $b`). + // It matters for the ambiguous Sass `margin: -$a -$b` list/subtraction case (dart-sass deprecates the binary reading). + // Merge it into this run so no fill space lands after the operator. if to_span(operand.span()).end != op_span.start && op_span.end == next_start { run_end += 1; continue; @@ -1639,8 +1493,8 @@ fn write_sass_binary<'a>( let operand_end = to_span(operand.span()).end; let next_start = run.get(j + 1).map(|(next, _)| to_span(next.span()).start); // `op(paren)` fuses like a postcss function token, - // which always gets a space before it; word-like - // operands force spaces (`$a + $b` even when glued). + // which always gets a space before it; + // word-like operands force spaces (`$a + $b` even when glued). let fuses_paren = next_start == Some(op_span.end); let op_text = source.text_for(&op_span); let wordish = matches!(op_text, "+" | "-") @@ -1649,14 +1503,14 @@ fn write_sass_binary<'a>( || run.get(j + 1).is_some_and(|(next, _)| { is_word_like(next) || is_func_like(next) })); - // Division: space before `/` unless a word-led - // operand absorbs it (see the run-merge rule above). + // Division: space before `/` unless a word-led operand absorbs it + // (see the run-merge rule above). let division_spaces = op_text == "/" && division_force_space && j + 1 == run.len(); if operand_end != op_span.start || wordish || division_spaces - // `*` always spaces (it also ends its run above). + // `*` always spaces (it also ends its run above) || op_text == "*" || (fuses_paren && run.get(j + 1).is_some_and(|(next, _)| { @@ -1669,9 +1523,8 @@ fn write_sass_binary<'a>( } } }); - // Media feature values (`ValueContext::no_break`) are flat text and - // never break, however long — Prettier's `media-value`. Other - // contexts get a break opportunity between runs. + // Media feature values (`ValueContext::no_break`) are flat text and never break + // (Prettier's `media-value`). if ctx.no_break { filler.entry(&text(" "), &content); } else { @@ -1684,33 +1537,27 @@ fn write_sass_binary<'a>( write!(f, group(&indent(&body))); } -/// Calc expression (`a + b` inside `calc(...)`): spaces around `+`/`-` follow -/// the source (invalid syntax otherwise), with a break opportunity after the -/// operator only. -fn write_calc<'a>( - calc: &raffia::ast::Calc<'a>, - ctx: ValueContext<'a>, - f: &mut CssFormatter<'_, 'a>, -) { - use raffia::ast::CalcOperatorKind; - - // Prettier prints calc contents as a FLAT run of word/operator fill - // chunks (postcss-values has no expression tree): every operator glues - // to its LEFT operand with the break opportunity after it, and - // continuation lines share ONE uniform indent — nested unparenthesized - // operations do NOT nest groups/indents. A parenthesized sub-expression - // stays a single chunk. Source-glued runs (`1px+2px`) stay glued. +/// Calc expression (`a + b` inside `calc(...)`): +/// spaces around `+`/`-` follow the source (invalid syntax otherwise), +/// with a break opportunity after the operator only. +fn write_calc<'a>(calc: &Calc<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>) { + // Prettier prints calc contents as a FLAT run of word/operator fill chunks + // (postcss-values has no expression tree): + // every operator glues to its LEFT operand with the break opportunity after it, + // and continuation lines share ONE uniform indent, + // nested unparenthesized operations do NOT nest groups/indents. + // A parenthesized sub-expression stays a single chunk. + // Source-glued runs (`1px+2px`) stay glued. enum Piece<'b, 'a> { /// The bool: print the operand's own source parens around it - /// (computed once at flatten time — `calc_operand_has_own_parens` - /// scans the source). + /// (computed once at flatten time, `calc_operand_has_own_parens` scans the source). Operand(&'b ComponentValue<'a>, bool), Op(&'static str), Space, } fn flatten<'b, 'a>( - calc: &'b raffia::ast::Calc<'a>, + calc: &'b Calc<'a>, f: &CssFormatter<'_, 'a>, chunks: &mut Vec>>, current: &mut Vec>, @@ -1801,33 +1648,30 @@ fn write_calc<'a>( write!(f, group(&indent(&body))); } -/// Less arithmetic (`@a - 2 * @b - (@c / 2)`): a flat operator fill matching -/// Prettier (the `printNumber`/`adjustNumbers` math layout, umbrella -/// prettier/prettier#1811). Every operator glues to its LEFT operand with the -/// break opportunity AFTER it; continuation lines share one uniform indent. -/// Nested unparenthesized operations flatten into the SAME fill; a -/// parenthesized sub-expression is its own nested group (it can break inside -/// its parens). Operator spacing follows the source: `+`/`-` always have -/// whitespace here (raffia only treats a whitespace-followed `+`/`-` as a -/// binary operator — a signed value like `-@b` in a `margin` shorthand stays a -/// separate `LessNegativeValue`, never an operand here), while `*`/`/` may be -/// glued (`@base*2`). +/// Less arithmetic (`@a - 2 * @b - (@c / 2)`): +/// a flat operator fill matching Prettier (the `printNumber`/`adjustNumbers` math layout, umbrella prettier/prettier#1811). +/// Every operator glues to its LEFT operand with the break opportunity AFTER it; +/// continuation lines share one uniform indent. +/// Nested unparenthesized operations flatten into the SAME fill; +/// a parenthesized sub-expression is its own nested group (it can break inside its parens). +/// Operator spacing follows the source: `+`/`-` always have whitespace here +/// (`raffia` only treats a whitespace-followed `+`/`-` as a binary operator, +/// a signed value like `-@b` in a `margin` shorthand stays a separate `LessNegativeValue`, never an operand here), +/// while `*`/`/` may be glued (`@base*2`). fn write_less_binary_operation<'a>( - op: &raffia::ast::LessBinaryOperation<'a>, + op: &LessBinaryOperation<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, ) { - use raffia::ast::LessOperationOperatorKind; - enum Piece<'b, 'a> { Operand(&'b ComponentValue<'a>), - Paren(&'b raffia::ast::LessParenthesizedOperation<'a>), + Paren(&'b LessParenthesizedOperation<'a>), Op(&'static str), Space, } fn flatten<'b, 'a>( - op: &'b raffia::ast::LessBinaryOperation<'a>, + op: &'b LessBinaryOperation<'a>, chunks: &mut Vec>>, current: &mut Vec>, ) { @@ -1846,7 +1690,7 @@ fn write_less_binary_operation<'a>( current.push(Piece::Space); } current.push(Piece::Op(op_str)); - // Break opportunity only when the source has a gap after the operator. + // Break opportunity only when the source has a gap after the operator if op_span.end != right_start { chunks.push(std::mem::take(current)); } @@ -1907,12 +1751,12 @@ fn write_less_binary_operation<'a>( write!(f, group(&indent(&body))); } -/// A Less parenthesized operation (`(@a - @b)`): its own group, so when it -/// breaks the `(` / `)` land on their own lines with the inner operation -/// indented one level (Prettier's `printParenthesizedValueGroup`). When it -/// fits, it stays inline (`(@a - @b)`). +/// A Less parenthesized operation (`(@a - @b)`): +/// its own group, so when it breaks the `(` / `)` land on their own lines +/// with the inner operation indented one level (Prettier's `printParenthesizedValueGroup`). +/// When it fits, it stays inline (`(@a - @b)`). fn write_less_parenthesized_operation<'a>( - paren: &raffia::ast::LessParenthesizedOperation<'a>, + paren: &LessParenthesizedOperation<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, ) { @@ -1937,11 +1781,11 @@ fn write_less_parenthesized_operation<'a>( /// Whether a calc operand has ONE pair of source parens of its own to restore. /// -/// raffia folds `(a - b) * c` into nested `Calc` nodes whose spans EXCLUDE -/// the parens, so they must be recovered from the source (postcss keeps them -/// as a `value-paren_group` and Prettier preserves them). Only an operand -/// position can do this safely: at the top of `calc(...)` the function's own -/// parens are indistinguishable from a redundant pair. +/// `raffia` folds `(a - b) * c` into nested `Calc` nodes whose spans EXCLUDE the parens, +/// so they must be recovered from the source +/// (postcss keeps them as a `value-paren_group` and Prettier preserves them). +/// Only an operand position can do this safely: +/// at the top of `calc(...)` the function's own parens are indistinguishable from a redundant pair. fn calc_operand_has_own_parens(operand: &ComponentValue<'_>, f: &CssFormatter<'_, '_>) -> bool { let span = to_span(operand.span()); let source_len = u32::try_from(f.context().source_text().len()).unwrap_or(u32::MAX); @@ -1950,10 +1794,10 @@ fn calc_operand_has_own_parens(operand: &ComponentValue<'_>, f: &CssFormatter<'_ /// Layers of source parens that belong to a function-argument group itself. /// -/// raffia drops bare parens around argument values — `min(((@a)), @b)` -/// parses the first argument as just `@a`, and a `Calc` argument's span -/// excludes its outermost source parens (`max(((a - b) / 2), 0)`). postcss -/// keeps every pair as a `value-paren_group`, so Prettier prints them all. +/// `raffia` drops bare parens around argument values: +/// `min(((@a)), @b)` parses the first argument as just `@a`, +/// and a `Calc` argument's span excludes its outermost source parens (`max(((a - b) / 2), 0)`). +/// postcss keeps every pair as a `value-paren_group`, so Prettier prints them all. /// The scan is bounded by the function's own parens (`region`). fn group_own_paren_layers( group: &[ComponentValue<'_>], @@ -1973,11 +1817,10 @@ fn group_own_paren_layers( /// How many pairs of source parens around `start..end` (within `region`) /// belong to that span itself. /// -/// The span may already contain unbalanced parens belonging to CHILD -/// operands, whose own pairs also sit outside their spans (`(a - 1) * b` -/// spans from `a`). Those are reprinted when the child's own chunk is -/// written; the span owns a pair only when a further `(`/`)` exists beyond -/// what the children account for. +/// The span may already contain unbalanced parens belonging to CHILD operands, +/// whose own pairs also sit outside their spans (`(a - 1) * b` spans from `a`). +/// Those are reprinted when the child's own chunk is written; +/// the span owns a pair only when a further `(`/`)` exists beyond what the children account for. fn own_paren_layers( start: u32, end: u32, @@ -1988,8 +1831,8 @@ fn own_paren_layers( let source = f.context().source_text(); let bytes = source.as_bytes(); - // The adjacency scans stop at the first non-paren byte, so they are - // cheap — run them first and bail out before the O(span) balance scan + // The adjacency scans stop at the first non-paren byte, so they are cheap. + // Run them first and bail out before the O(span) balance scan // (in plain CSS there is almost never an adjacent paren at all). let opens = i32::try_from( bytes[region_start as usize..start as usize] @@ -2003,6 +1846,7 @@ fn own_paren_layers( if opens == 0 { return 0; } + let closes = i32::try_from( bytes[end as usize..region_end as usize] .iter() @@ -2027,8 +1871,8 @@ fn own_paren_layers( _ => {} } } - // `[need_left x '('] span [need_right x ')']` balances to zero; anything - // beyond that within the region is the span's own. + // `[need_left x '('] span [need_right x ')']` balances to zero; + // anything beyond that within the region is the span's own. let need_left = -min_depth; let need_right = depth - min_depth; @@ -2037,7 +1881,7 @@ fn own_paren_layers( /// Function call: `name(` + args + `)`. /// Mirrors `value-func` + `value-paren_group` with parens. -pub fn write_function<'a>( +pub(super) fn write_function<'a>( func: &Function<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, @@ -2057,7 +1901,7 @@ pub fn write_function<'a>( // no break opportunities of their own (Prettier's `isURLFunctionNode`). if function_name_text(func).eq_ignore_ascii_case("url") { write!(f, "("); - // Single plain argument: verbatim (matches the `Url` node path). + // Single plain argument: verbatim (matches the `Url` node path) if groups.len() == 1 && groups[0].len() == 1 && matches!( @@ -2081,7 +1925,7 @@ pub fn write_function<'a>( } let args_region = (name_span.end + 1, to_span(func.span()).end.saturating_sub(1)); - // One argument group, with the source parens raffia dropped restored. + // One argument group, with the source parens raffia dropped restored let write_arg_group = move |group_values: &[ComponentValue<'a>], ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>| { @@ -2095,8 +1939,8 @@ pub fn write_function<'a>( } }; - // `no_break` (composes `removeLines` / media-value flat text): no break - // opportunities, args joined inline. + // `no_break` (composes `removeLines` / media-value flat text): + // no break opportunities, args joined inline. if ctx.no_break { write!(f, "("); for (i, group_values) in groups.iter().enumerate() { @@ -2111,18 +1955,16 @@ pub fn write_function<'a>( let groups_ref = &groups; let r_paren = to_span(func.span()).end.saturating_sub(1); - let extra_indent = ctx.after_inline_comment; - // `var(--baz,)`: an empty fallback is meaningful, so a source trailing - // comma is preserved — for `var()` ONLY (Prettier's `printTrailingComma` - // checks `isVarFunctionNode`; every other function drops it). + // `var(--baz,)`: an empty fallback is meaningful, + // so a source trailing comma is preserved for `var()` ONLY + // (Prettier's `printTrailingComma` checks `isVarFunctionNode`; every other function drops it). let has_trailing_comma = func.args.last().is_some_and(is_comma) && function_name_text(func).eq_ignore_ascii_case("var"); - // Function arguments are "map item" positions (maps break). + // Function arguments are "map item" positions (maps break) let ctx = ValueContext { map_break: true, paren_break: false, in_args: true, - after_inline_comment: false, no_leading_softline: false, ..ctx }; @@ -2132,8 +1974,8 @@ pub fn write_function<'a>( for (i, group_values) in groups_ref.iter().enumerate() { if i > 0 { write!(f, ","); - // Preserve a blank line between argument groups, but only - // after a multi-part group (Prettier checks comma_groups). + // Preserve a blank line between argument groups, + // but only after a multi-part group (Prettier checks `comma_groups`). let prev_end = groups_ref[i - 1].last().map_or(0, |v| to_span(v.span()).end); let next_start = group_values.first().map_or(prev_end, |v| to_span(v.span()).start); let next_start = f @@ -2143,10 +1985,10 @@ pub fn write_function<'a>( .map_or(next_start, |c| c.span.start.min(next_start)); if prev_end != 0 && groups_ref[i - 1].len() > 1 - && crate::comments::classify_gap(source.bytes_range(prev_end, next_start)) - == crate::comments::Gap::Blank + && comments::classify_gap(source.bytes_range(prev_end, next_start)) + == comments::Gap::Blank { - write!(f, oxc_formatter_core::builders::empty_line()); + write!(f, empty_line()); } else { write!(f, soft_line_break_or_space()); } @@ -2158,14 +2000,13 @@ pub fn write_function<'a>( } // Comments between the last argument and `)` wrap as fill items; // `//` comments stay glued to the argument (their hardline follows). - let tail: Vec = - f.context().comments().take_before(r_paren).to_vec(); + let tail: &'a [comments::CssComment] = f.context().comments().take_before(r_paren); if tail.iter().any(|c| c.inline) { - for &comment in &tail { + for &comment in tail { write!(f, " "); - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!(f, [oxc_formatter_core::builders::expand_parent(), hard_line_break()]); + write!(f, [expand_parent(), hard_line_break()]); } } } else if !tail.is_empty() { @@ -2174,14 +2015,11 @@ pub fn write_function<'a>( // Anchor entry: lets the first comment's separator attach it // to the last argument (a fill ignores the first separator). filler.entry(&soft_line_break_or_space(), &format_with(|_| {})); - for &comment in &tail { + for &comment in tail { let entry = format_with(move |f: &mut CssFormatter<'_, 'a>| { - crate::comments::write_single_comment(comment, f); + comments::write_single_comment(comment, f); if comment.inline { - write!( - f, - [oxc_formatter_core::builders::expand_parent(), hard_line_break()] - ); + write!(f, [expand_parent(), hard_line_break()]); } }); filler.entry(&soft_line_break_or_space(), &entry); @@ -2195,36 +2033,29 @@ pub fn write_function<'a>( f, group(&format_with(move |f: &mut CssFormatter<'_, 'a>| { write!(f, "("); - if extra_indent { - // Prettier's comment-in-paren-group quirk: arguments sit two - // levels deep and the `)` one level deep. - write!(f, indent(&indent(&body))); - write!(f, indent(&soft_line_break())); - } else { - write!(f, indent(&body)); - write!(f, soft_line_break()); - } + write!(f, indent(&body)); + write!(f, soft_line_break()); write!(f, ")"); })) ); } /// `url(...)`: contents are never reformatted. -/// `#{ $a + $b }` normalizes to `#{$a + $b}`: postcss-values tokenizes -/// through the interpolation, so Prettier reprints the expression (no inner -/// padding, one space around operators; idents/variables keep their case, -/// dimensions/strings get the normal value normalization). -pub fn write_sass_interpolated_ident<'a>( - interp: &raffia::ast::SassInterpolatedIdent<'a>, +/// `#{ $a + $b }` normalizes to `#{$a + $b}`: postcss-values tokenizes through the interpolation, +/// so Prettier reprints the expression. +/// (no inner padding, one space around operators; +/// idents/variables keep their case, dimensions/strings get the normal value normalization) +pub(super) fn write_sass_interpolated_ident<'a>( + interp: &SassInterpolatedIdent<'a>, ctx: ValueContext<'a>, f: &mut CssFormatter<'_, 'a>, ) { for element in &interp.elements { match element { - raffia::ast::SassInterpolatedIdentElement::Static(part) => { + SassInterpolatedIdentElement::Static(part) => { write!(f, text(part.raw)); } - raffia::ast::SassInterpolatedIdentElement::Expression(expr) => { + SassInterpolatedIdentElement::Expression(expr) => { write!(f, "#{"); write_component_value(expr, ctx, f); write!(f, "}"); @@ -2233,14 +2064,14 @@ pub fn write_sass_interpolated_ident<'a>( } } -pub fn write_url<'a>(url: &Url<'a>, f: &mut CssFormatter<'_, 'a>) { +pub(super) fn write_url<'a>(url: &Url<'a>, f: &mut CssFormatter<'_, 'a>) { let source = f.context().source_text(); let name_span = to_span(url.name.span()); - // Prettier preserves function-name casing (`URL(...)` stays uppercase). + // Prettier preserves function-name casing (`URL(...)` stays uppercase) write!(f, text(source.text_for(&name_span))); write!(f, "("); match &url.value { - Some(raffia::ast::UrlValue::Str(raffia::ast::InterpolableStr::Literal(str))) => { + Some(UrlValue::Str(InterpolableStr::Literal(str))) => { write_str(str, f); for modifier in &url.modifiers { write!(f, " "); @@ -2248,16 +2079,16 @@ pub fn write_url<'a>(url: &Url<'a>, f: &mut CssFormatter<'_, 'a>) { write!(f, text(source.text_for(&span))); } } - // Quoted url with interpolation: requote the outer quotes only. - Some(raffia::ast::UrlValue::Str(istr)) => { + // Quoted url with interpolation: requote the outer quotes only + Some(UrlValue::Str(istr)) => { let span = to_span(istr.span()); write_requoted_verbatim(source.text_for(&span), f); } - // Unquoted url contents are printed verbatim, including inner spaces. + // Unquoted url contents are printed verbatim, including inner spaces _ => { let url_span = to_span(url.span()); let inner_start = to_span(url.name.span()).end + 1; - // raffia's url span may stop before trailing padding; scan to `)`. + // raffia's url span may stop before trailing padding; scan to `)` let bytes = source.as_bytes(); let mut close = url_span.end.saturating_sub(1) as usize; while close < bytes.len() && bytes[close] != b')' { @@ -2266,12 +2097,12 @@ pub fn write_url<'a>(url: &Url<'a>, f: &mut CssFormatter<'_, 'a>) { let inner_end = u32::try_from(close).unwrap_or(url_span.end.saturating_sub(1)); if inner_start < inner_end { let inner = source.slice_range(inner_start, inner_end); - // Escaped parens keep their padding verbatim; otherwise trim. + // Escaped parens keep their padding verbatim; otherwise trim if inner.contains("\\(") || inner.contains("\\)") { write!(f, text(inner)); } else { let trimmed = inner.trim(); - // `url($a+$b)`: SCSS concatenation gets spaced. + // `url($a+$b)`: SCSS concatenation gets spaced if trimmed.contains('$') && trimmed.contains('+') && !trimmed.contains(' ') { let spaced = trimmed.cow_replace('+', " + "); write!(f, text(f.allocator().alloc_str(&spaced))); diff --git a/crates/oxc_formatter_css/tests/fixtures/embedded/scss/placeholder-ignore.scss b/crates/oxc_formatter_css/tests/fixtures/embedded/scss/placeholder-ignore.scss deleted file mode 100644 index 930777d483b95..0000000000000 --- a/crates/oxc_formatter_css/tests/fixtures/embedded/scss/placeholder-ignore.scss +++ /dev/null @@ -1,5 +0,0 @@ -color: @prettier-placeholder-0-id; -/* prettier-ignore */ -@prettier-placeholder-1-id; -/* prettier-ignore */ -@prettier-placeholder-2-id diff --git a/crates/oxc_formatter_css/tests/fixtures/embedded/scss/placeholder-ignore.scss.snap b/crates/oxc_formatter_css/tests/fixtures/embedded/scss/placeholder-ignore.scss.snap deleted file mode 100644 index d21637bebe89a..0000000000000 --- a/crates/oxc_formatter_css/tests/fixtures/embedded/scss/placeholder-ignore.scss.snap +++ /dev/null @@ -1,30 +0,0 @@ ---- -source: crates/oxc_formatter_css/tests/fixtures/mod.rs ---- -==================== Input ==================== -color: @prettier-placeholder-0-id; -/* prettier-ignore */ -@prettier-placeholder-1-id; -/* prettier-ignore */ -@prettier-placeholder-2-id - -==================== Output ==================== ------------------- -{ printWidth: 80 } ------------------- -color: @prettier-placeholder-0-id; -/* prettier-ignore */ -@prettier-placeholder-1-id; -/* prettier-ignore */ - - -------------------- -{ printWidth: 100 } -------------------- -color: @prettier-placeholder-0-id; -/* prettier-ignore */ -@prettier-placeholder-1-id; -/* prettier-ignore */ - - -===================== End ===================== diff --git a/crates/oxc_formatter_css/tests/fixtures/format/scss/inline-comment-before-call.scss b/crates/oxc_formatter_css/tests/fixtures/format/scss/inline-comment-before-call.scss new file mode 100644 index 0000000000000..bee78b2f7f9da --- /dev/null +++ b/crates/oxc_formatter_css/tests/fixtures/format/scss/inline-comment-before-call.scss @@ -0,0 +1,25 @@ +/* + * Divergence: Prettier double-indents a function call that directly follows + * a `//` inline comment in nested-args position. + * + * Prettier marks this case as a bug (prettier/prettier#4878 was filed and + * partially fixed by PR #7844 with the changelog entry "Fix extra indentation + * of lines following comments in scss"). PR #7844's fix only covers the + * SCSS-map / direct paren-group child case; the nested function-args case + * was left in their fixture as-is and never followed up. PR #18228 (3.7.0, + * 2025-11) similarly addressed only the block-comment + comma-separated-value + * variant. So the `//` + nested-function-args double indent is a forgotten + * leftover from an incomplete fix, not an intentional design. + * + * We diverge — oxc's args sit at the normal +1 / `)` +0, matching what every + * other comment-free function call does. Tracked for upstream report. + * + * Same pattern as upstream `tests/format/scss/comments/4878.scss`, shrunk + * from 12 nest levels to 3 so the divergence is visible without overflowing + * the test runner's stack. + */ +.foo { + width: someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, + // This next pow is really powerful + someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, 2)))))); +} diff --git a/crates/oxc_formatter_css/tests/fixtures/format/scss/inline-comment-before-call.scss.snap b/crates/oxc_formatter_css/tests/fixtures/format/scss/inline-comment-before-call.scss.snap new file mode 100644 index 0000000000000..d74f8a68b59bd --- /dev/null +++ b/crates/oxc_formatter_css/tests/fixtures/format/scss/inline-comment-before-call.scss.snap @@ -0,0 +1,115 @@ +--- +source: crates/oxc_formatter_css/tests/fixtures/mod.rs +--- +==================== Input ==================== +/* + * Divergence: Prettier double-indents a function call that directly follows + * a `//` inline comment in nested-args position. + * + * Prettier marks this case as a bug (prettier/prettier#4878 was filed and + * partially fixed by PR #7844 with the changelog entry "Fix extra indentation + * of lines following comments in scss"). PR #7844's fix only covers the + * SCSS-map / direct paren-group child case; the nested function-args case + * was left in their fixture as-is and never followed up. PR #18228 (3.7.0, + * 2025-11) similarly addressed only the block-comment + comma-separated-value + * variant. So the `//` + nested-function-args double indent is a forgotten + * leftover from an incomplete fix, not an intentional design. + * + * We diverge — oxc's args sit at the normal +1 / `)` +0, matching what every + * other comment-free function call does. Tracked for upstream report. + * + * Same pattern as upstream `tests/format/scss/comments/4878.scss`, shrunk + * from 12 nest levels to 3 so the divergence is visible without overflowing + * the test runner's stack. + */ +.foo { + width: someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, + // This next pow is really powerful + someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, 2)))))); +} + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +/* + * Divergence: Prettier double-indents a function call that directly follows + * a `//` inline comment in nested-args position. + * + * Prettier marks this case as a bug (prettier/prettier#4878 was filed and + * partially fixed by PR #7844 with the changelog entry "Fix extra indentation + * of lines following comments in scss"). PR #7844's fix only covers the + * SCSS-map / direct paren-group child case; the nested function-args case + * was left in their fixture as-is and never followed up. PR #18228 (3.7.0, + * 2025-11) similarly addressed only the block-comment + comma-separated-value + * variant. So the `//` + nested-function-args double indent is a forgotten + * leftover from an incomplete fix, not an intentional design. + * + * We diverge — oxc's args sit at the normal +1 / `)` +0, matching what every + * other comment-free function call does. Tracked for upstream report. + * + * Same pattern as upstream `tests/format/scss/comments/4878.scss`, shrunk + * from 12 nest levels to 3 so the divergence is visible without overflowing + * the test runner's stack. + */ +.foo { + width: someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow( + 2, + // This next pow is really powerful + someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow(2, 2) + ) + ) + ) + ) + ); +} + +------------------- +{ printWidth: 100 } +------------------- +/* + * Divergence: Prettier double-indents a function call that directly follows + * a `//` inline comment in nested-args position. + * + * Prettier marks this case as a bug (prettier/prettier#4878 was filed and + * partially fixed by PR #7844 with the changelog entry "Fix extra indentation + * of lines following comments in scss"). PR #7844's fix only covers the + * SCSS-map / direct paren-group child case; the nested function-args case + * was left in their fixture as-is and never followed up. PR #18228 (3.7.0, + * 2025-11) similarly addressed only the block-comment + comma-separated-value + * variant. So the `//` + nested-function-args double indent is a forgotten + * leftover from an incomplete fix, not an intentional design. + * + * We diverge — oxc's args sit at the normal +1 / `)` +0, matching what every + * other comment-free function call does. Tracked for upstream report. + * + * Same pattern as upstream `tests/format/scss/comments/4878.scss`, shrunk + * from 12 nest levels to 3 so the divergence is visible without overflowing + * the test runner's stack. + */ +.foo { + width: someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow( + 2, + // This next pow is really powerful + someVeryLongFunctionNameForJustAPow( + 2, + someVeryLongFunctionNameForJustAPow(2, someVeryLongFunctionNameForJustAPow(2, 2)) + ) + ) + ) + ); +} + +===================== End ===================== diff --git a/crates/oxc_formatter_css/tests/fixtures/format/scss/namespace-interpolated.scss b/crates/oxc_formatter_css/tests/fixtures/format/scss/namespace-interpolated.scss new file mode 100644 index 0000000000000..96b36dee091d6 --- /dev/null +++ b/crates/oxc_formatter_css/tests/fixtures/format/scss/namespace-interpolated.scss @@ -0,0 +1,10 @@ +// `@namespace` URI with SCSS interpolation: the outer quote re-quotes per +// `singleQuote` (Prettier `printString` / `adjustStrings`) but the content +// stays verbatim (postcss-values `value-unknown`, same path as a value-position +// `InterpolableStr`). Without this, `@namespace foo '#{$url}'` would print +// the single quote unchanged — verified against Prettier 3.8.4. + +@namespace foo '#{$url}'; +@namespace bar "#{$url}"; +@namespace baz "plain"; +@namespace qux 'plain'; diff --git a/crates/oxc_formatter_css/tests/fixtures/format/scss/namespace-interpolated.scss.snap b/crates/oxc_formatter_css/tests/fixtures/format/scss/namespace-interpolated.scss.snap new file mode 100644 index 0000000000000..6c7c43ae42ff1 --- /dev/null +++ b/crates/oxc_formatter_css/tests/fixtures/format/scss/namespace-interpolated.scss.snap @@ -0,0 +1,45 @@ +--- +source: crates/oxc_formatter_css/tests/fixtures/mod.rs +--- +==================== Input ==================== +// `@namespace` URI with SCSS interpolation: the outer quote re-quotes per +// `singleQuote` (Prettier `printString` / `adjustStrings`) but the content +// stays verbatim (postcss-values `value-unknown`, same path as a value-position +// `InterpolableStr`). Without this, `@namespace foo '#{$url}'` would print +// the single quote unchanged — verified against Prettier 3.8.4. + +@namespace foo '#{$url}'; +@namespace bar "#{$url}"; +@namespace baz "plain"; +@namespace qux 'plain'; + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +// `@namespace` URI with SCSS interpolation: the outer quote re-quotes per +// `singleQuote` (Prettier `printString` / `adjustStrings`) but the content +// stays verbatim (postcss-values `value-unknown`, same path as a value-position +// `InterpolableStr`). Without this, `@namespace foo '#{$url}'` would print +// the single quote unchanged — verified against Prettier 3.8.4. + +@namespace foo "#{$url}"; +@namespace bar "#{$url}"; +@namespace baz "plain"; +@namespace qux "plain"; + +------------------- +{ printWidth: 100 } +------------------- +// `@namespace` URI with SCSS interpolation: the outer quote re-quotes per +// `singleQuote` (Prettier `printString` / `adjustStrings`) but the content +// stays verbatim (postcss-values `value-unknown`, same path as a value-position +// `InterpolableStr`). Without this, `@namespace foo '#{$url}'` would print +// the single quote unchanged — verified against Prettier 3.8.4. + +@namespace foo "#{$url}"; +@namespace bar "#{$url}"; +@namespace baz "plain"; +@namespace qux "plain"; + +===================== End ===================== diff --git a/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss b/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss index 80d693da337f0..e449d58f55a1a 100644 --- a/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss +++ b/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss @@ -1,6 +1,8 @@ /* Verbatim-params edges: fused prelude, glued `(`, embedded comments, - trailing `//` before a block, quote preservation, interpolated names - (the `UnknownSassAtRule` statement path). */ + trailing `//` before a block, interpolated names + (the `UnknownSassAtRule` statement path). + `@error`/`@warn` re-quote strings to the preferred quote + (intentional divergence from Prettier — see `at_rule.rs`). */ @a:b; @foo (x); @foo /* gap */ bar; @@ -14,7 +16,7 @@ { color: red; } -@error 'single quotes survive'; +@error 'single quotes get normalized'; @warn 'careful with #{$x} here'; @#{$name} param1 param2; @#{$mix}in foo { diff --git a/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss.snap b/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss.snap index 667cc2440a508..3ce0ec0c3f326 100644 --- a/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss.snap +++ b/crates/oxc_formatter_css/tests/fixtures/format/scss/unknown-at-rule-edges.scss.snap @@ -3,8 +3,10 @@ source: crates/oxc_formatter_css/tests/fixtures/mod.rs --- ==================== Input ==================== /* Verbatim-params edges: fused prelude, glued `(`, embedded comments, - trailing `//` before a block, quote preservation, interpolated names - (the `UnknownSassAtRule` statement path). */ + trailing `//` before a block, interpolated names + (the `UnknownSassAtRule` statement path). + `@error`/`@warn` re-quote strings to the preferred quote + (intentional divergence from Prettier — see `at_rule.rs`). */ @a:b; @foo (x); @foo /* gap */ bar; @@ -18,7 +20,7 @@ source: crates/oxc_formatter_css/tests/fixtures/mod.rs { color: red; } -@error 'single quotes survive'; +@error 'single quotes get normalized'; @warn 'careful with #{$x} here'; @#{$name} param1 param2; @#{$mix}in foo { @@ -30,8 +32,10 @@ source: crates/oxc_formatter_css/tests/fixtures/mod.rs { printWidth: 80 } ------------------ /* Verbatim-params edges: fused prelude, glued `(`, embedded comments, - trailing `//` before a block, quote preservation, interpolated names - (the `UnknownSassAtRule` statement path). */ + trailing `//` before a block, interpolated names + (the `UnknownSassAtRule` statement path). + `@error`/`@warn` re-quote strings to the preferred quote + (intentional divergence from Prettier — see `at_rule.rs`). */ @a:b; @foo (x); @foo /* gap */ bar; @@ -45,8 +49,8 @@ source: crates/oxc_formatter_css/tests/fixtures/mod.rs { color: red; } -@error 'single quotes survive'; -@warn 'careful with #{$x} here'; +@error "single quotes get normalized"; +@warn "careful with #{$x} here"; @#{$name} param1 param2; @#{$mix}in foo { x: 1; @@ -56,8 +60,10 @@ source: crates/oxc_formatter_css/tests/fixtures/mod.rs { printWidth: 100 } ------------------- /* Verbatim-params edges: fused prelude, glued `(`, embedded comments, - trailing `//` before a block, quote preservation, interpolated names - (the `UnknownSassAtRule` statement path). */ + trailing `//` before a block, interpolated names + (the `UnknownSassAtRule` statement path). + `@error`/`@warn` re-quote strings to the preferred quote + (intentional divergence from Prettier — see `at_rule.rs`). */ @a:b; @foo (x); @foo /* gap */ bar; @@ -71,8 +77,8 @@ source: crates/oxc_formatter_css/tests/fixtures/mod.rs { color: red; } -@error 'single quotes survive'; -@warn 'careful with #{$x} here'; +@error "single quotes get normalized"; +@warn "careful with #{$x} here"; @#{$name} param1 param2; @#{$mix}in foo { x: 1; diff --git a/tasks/prettier_conformance/snapshots/prettier.scss.snap.md b/tasks/prettier_conformance/snapshots/prettier.scss.snap.md index 125d5621ce45a..8d73fd93caa13 100644 --- a/tasks/prettier_conformance/snapshots/prettier.scss.snap.md +++ b/tasks/prettier_conformance/snapshots/prettier.scss.snap.md @@ -1,9 +1,10 @@ -scss compatibility: 84/85 (98.82%), 6 files skipped +scss compatibility: 83/85 (97.65%), 6 files skipped # Failed | Spec path | Failed or Passed | Match ratio | | :-------- | :--------------: | :---------: | +| scss/comments/4878.scss | 💥 | 89.39% | | scss/map/function-argument/functional-argument.scss | 💥 | 96.00% | # Skipped (parse error, TODO: should be ignored or supported)