Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

622 changes: 176 additions & 446 deletions crates/oxc_formatter_css/AGENTS.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion crates/oxc_formatter_css/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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]
Expand Down
13 changes: 4 additions & 9 deletions crates/oxc_formatter_css/src/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand All @@ -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());
Expand Down
82 changes: 48 additions & 34 deletions crates/oxc_formatter_css/src/format.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -6,7 +8,6 @@ use oxc_formatter_core::{
write,
};
use oxc_span::Span;
use raffia::{ParserBuilder, ast::Stylesheet};

use crate::{
comments::CssComment,
Expand All @@ -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<String>) -> Vec<String>;

/// Parse `source_text` as a stylesheet and build its formatter IR.
Expand All @@ -26,25 +30,25 @@ pub type TailwindSorter<'s> = &'s dyn Fn(Vec<String>) -> Vec<String>;
/// 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,
options: CssFormatOptions,
sort_tailwind_classes: Option<TailwindSorter<'_>>,
) -> Result<Formatted<'a, CssFormatContext<'a>>, 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]);

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 });
Expand Down Expand Up @@ -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()`].
Expand All @@ -86,14 +91,22 @@ pub fn format_to_ir<'a>(
options: CssFormatOptions,
) -> Result<EmbeddedIr<'a>, 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);

Expand All @@ -107,25 +120,26 @@ 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,
options: CssFormatOptions,
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());
Expand All @@ -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::<Stylesheet>().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()
Expand Down Expand Up @@ -211,10 +225,10 @@ fn front_matter_end(source: &str) -> Option<usize> {
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<String> {
let inner = front_matter.strip_prefix("---")?.strip_suffix("---")?;
let mut out = String::with_capacity(front_matter.len());
Expand Down
10 changes: 5 additions & 5 deletions crates/oxc_formatter_css/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
64 changes: 47 additions & 17 deletions crates/oxc_formatter_css/src/options.rs
Original file line number Diff line number Diff line change
@@ -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,
}

Expand All @@ -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.
Expand All @@ -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,
}

Expand All @@ -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 (`"`).
Expand All @@ -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<bool> for SingleQuote {
Expand Down
Loading
Loading