diff --git a/apps/oxfmt/src/core/external_formatter.rs b/apps/oxfmt/src/core/external_formatter.rs index b094f59b24779..a749949a1bbb8 100644 --- a/apps/oxfmt/src/core/external_formatter.rs +++ b/apps/oxfmt/src/core/external_formatter.rs @@ -301,16 +301,18 @@ impl ExternalFormatter { "Failed to get Doc for embedded code (parser '{parser_name}'): {err}" ) })?; - doc_json_strs + let doc_jsons = doc_json_strs .into_iter() - .map(|doc_json_str| { - let doc_json: serde_json::Value = - serde_json::from_str(&doc_json_str).map_err(|err| { - format!("Failed to parse Doc JSON: {err}") - })?; - from_prettier_doc::to_format_elements_for_template(&doc_json, allocator, group_id_builder) - }) - .collect() + .map(|s| serde_json::from_str(&s)) + .collect::, _>>() + .map_err(|e| format!("Failed to parse Doc JSON: {e}"))?; + + from_prettier_doc::to_format_elements_for_template( + language, + &doc_jsons, + allocator, + group_id_builder, + ) }) })) } else { @@ -363,13 +365,11 @@ impl ExternalFormatter { /// This is the single source of truth for supported embedded languages. fn language_to_prettier_parser(language: &str) -> Option<&'static str> { match language { - // TODO: "tagged-css" should use `scss` parser to support quasis - "tagged-css" | "styled-jsx" => Some("css"), + "tagged-css" | "styled-jsx" | "angular-styles" => Some("scss"), "tagged-graphql" => Some("graphql"), "tagged-html" => Some("html"), "tagged-markdown" => Some("markdown"), "angular-template" => Some("angular"), - "angular-styles" => Some("scss"), _ => None, } } diff --git a/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs b/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs index f01ff504e5bb0..851faa7bae6cc 100644 --- a/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs +++ b/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs @@ -5,8 +5,8 @@ use serde_json::Value; use oxc_allocator::{Allocator, StringBuilder}; use oxc_formatter::{ - Align, Condition, DedentMode, FormatElement, Group, GroupId, GroupMode, IndentWidth, LineMode, - PrintMode, Tag, TextWidth, UniqueGroupIdBuilder, + Align, Condition, DedentMode, EmbeddedDocResult, FormatElement, Group, GroupId, GroupMode, + IndentWidth, LineMode, PrintMode, Tag, TextWidth, UniqueGroupIdBuilder, }; /// Marker string used to represent `-Infinity` in JSON. @@ -14,33 +14,45 @@ use oxc_formatter::{ /// See `src-js/lib/apis.ts` for details. const NEGATIVE_INFINITY_MARKER: &str = "__NEGATIVE_INFINITY__"; -/// Converts a Prettier Doc JSON value into a flat `Vec>`, -/// with template-specific text escaping applied as post-processing. +/// Converts parsed Prettier Doc JSON values into an [`EmbeddedDocResult`]. +/// +/// Handles language-specific processing: +/// - GraphQL: converts each doc independently → [`EmbeddedDocResult::MultipleDocs`] +/// - CSS, HTML: merges consecutive Text nodes, counts placeholders → [`EmbeddedDocResult::DocWithPlaceholders`] pub fn to_format_elements_for_template<'a>( - doc: &Value, + language: &str, + doc_jsons: &[Value], allocator: &'a Allocator, group_id_builder: &UniqueGroupIdBuilder, -) -> Result>, String> { - let mut ctx = FmtCtx::new(allocator, group_id_builder); - let mut out = vec![]; - convert_doc(doc, &mut out, &mut ctx)?; - - postprocess(&mut out, |fe| { - if let FormatElement::Text { text, width } = fe { - // Some characters (e.g. backticks) should be escaped in template literals - let escaped = escape_template_characters(text, allocator); - if !std::ptr::eq(*text, escaped) { - *text = escaped; - // NOTE: `IndentWidth` only affects tab character width calculation. - // If a `Doc = string` node contained `\t` (e.g. inside a string literal like `"\t"`?), - // the width could be miscalculated when `options.indent_width` != 2. - // However, the default value is sufficient in practice. - *width = TextWidth::from_text(escaped, IndentWidth::default()); - } - } - }); +) -> Result, String> { + let convert = |doc_json: &Value| -> Result<(Vec>, usize), String> { + let mut ctx = FmtCtx::new(allocator, group_id_builder); + let mut out = vec![]; + convert_doc(doc_json, &mut out, &mut ctx)?; + let placeholder_count = postprocess(&mut out, allocator); + Ok((out, placeholder_count)) + }; - Ok(out) + match language { + "tagged-css" => { + let doc_json = doc_jsons + .first() + .ok_or_else(|| "Expected exactly one Doc JSON for CSS".to_string())?; + let (ir, count) = convert(doc_json)?; + Ok(EmbeddedDocResult::DocWithPlaceholders(ir, count)) + } + "tagged-graphql" => { + let irs = doc_jsons + .iter() + .map(|doc_json| { + let (ir, _) = convert(doc_json)?; + Ok(ir) + }) + .collect::, String>>()?; + Ok(EmbeddedDocResult::MultipleDocs(irs)) + } + _ => unreachable!("Unsupported embedded_doc language: {language}"), + } } // --- @@ -333,14 +345,19 @@ fn extract_group_id( // --- -/// Post-process `FormatElement`s: -/// - strip trailing hardline -/// - collapse consecutive hardlines into empty lines +/// Post-process FormatElements in a single compaction pass: +/// - strip trailing hardline (useless for embedded parts) +/// - collapse double-hardlines `[Hard, ExpandParent, Hard, ExpandParent]` → `[Empty, ExpandParent]` +/// - merge consecutive Text nodes (SCSS emits split strings like `"@"` + `"prettier-placeholder-0-id"`) +/// - escape template characters (`\`, `` ` ``, `${`) +/// - count `@prettier-placeholder-N-id` patterns /// -/// And apply a per-element callback for custom transformations. -fn postprocess<'a>(ir: &mut Vec>, mut each: impl FnMut(&mut FormatElement<'a>)) { - // Strip trailing `hardline` pattern from FormatElement output. - // Trailing line is useless for embedded parts. +/// Returns the placeholder count (0 for non-CSS languages). +fn postprocess<'a>(ir: &mut Vec>, allocator: &'a Allocator) -> usize { + const PREFIX: &str = "@prettier-placeholder-"; + const SUFFIX: &str = "-id"; + + // Strip trailing hardline if ir.len() >= 2 && matches!(ir[ir.len() - 1], FormatElement::ExpandParent) && matches!(ir[ir.len() - 2], FormatElement::Line(LineMode::Hard)) @@ -348,14 +365,11 @@ fn postprocess<'a>(ir: &mut Vec>, mut each: impl FnMut(&mut Fo ir.truncate(ir.len() - 2); } - // Collapse consecutive `[Line(Hard), ExpandParent, Line(Hard), ExpandParent]` into `[Line(Empty), ExpandParent]`. - // - // In Prettier's Doc format, a blank line is represented as `hardline, - // hardline` which expands to `[Line(Hard), ExpandParent, Line(Hard), ExpandParent]`. - // However, `oxc_formatter`'s printer needs `Line(Empty)` instead. + let mut placeholder_count = 0; let mut write = 0; let mut read = 0; while read < ir.len() { + // Collapse double-hardline → empty line if read + 3 < ir.len() && matches!(ir[read], FormatElement::Line(LineMode::Hard)) && matches!(ir[read + 1], FormatElement::ExpandParent) @@ -366,17 +380,57 @@ fn postprocess<'a>(ir: &mut Vec>, mut each: impl FnMut(&mut Fo ir[write + 1] = FormatElement::ExpandParent; write += 2; read += 4; + } else if matches!(ir[read], FormatElement::Text { .. }) { + // Merge consecutive Text nodes + escape + count placeholders + let run_start = read; + read += 1; + while read < ir.len() && matches!(ir[read], FormatElement::Text { .. }) { + read += 1; + } + + let escaped = if read - run_start == 1 { + let FormatElement::Text { text, .. } = &ir[run_start] else { unreachable!() }; + escape_template_characters(text, allocator) + } else { + let mut sb = StringBuilder::new_in(allocator); + for element in &ir[run_start..read] { + if let FormatElement::Text { text, .. } = element { + sb.push_str(text); + } + } + escape_template_characters(sb.into_str(), allocator) + }; + let width = TextWidth::from_text(escaped, IndentWidth::default()); + ir[write] = FormatElement::Text { text: escaped, width }; + write += 1; + + // Count placeholders + let mut remaining = escaped; + while let Some(start) = remaining.find(PREFIX) { + let after_prefix = &remaining[start + PREFIX.len()..]; + let digit_end = after_prefix + .bytes() + .position(|b| !b.is_ascii_digit()) + .unwrap_or(after_prefix.len()); + if digit_end > 0 + && let Some(rest) = after_prefix[digit_end..].strip_prefix(SUFFIX) + { + placeholder_count += 1; + remaining = rest; + continue; + } + remaining = &remaining[start + PREFIX.len()..]; + } } else { if write != read { ir[write] = ir[read].clone(); } - each(&mut ir[write]); write += 1; read += 1; } } - ir.truncate(write); + placeholder_count } /// Escape characters that would break template literal syntax. diff --git a/crates/oxc_formatter/src/external_formatter.rs b/crates/oxc_formatter/src/external_formatter.rs index 3d21ec6736acc..6fc77ba162688 100644 --- a/crates/oxc_formatter/src/external_formatter.rs +++ b/crates/oxc_formatter/src/external_formatter.rs @@ -9,23 +9,32 @@ use super::formatter::{FormatElement, group_id::UniqueGroupIdBuilder}; pub type EmbeddedFormatterCallback = Arc Result + Send + Sync>; -/// Callback function type for formatting embedded code via Doc in batch. +/// Result of formatting embedded code via the Doc→IR path. /// -/// Takes (allocator, group_id_builder, tag_name, texts) and returns one `Vec>` per input. +/// The variant depends on the language being formatted: +/// - GraphQL: multiple IRs (one per quasi text) +/// - CSS: single IR with placeholder survival count +/// - HTML(TODO): single IR with placeholder survival count +pub enum EmbeddedDocResult<'a> { + MultipleDocs(Vec>>), + /// CSS: The count indicates how many `@prettier-placeholder-N-id` patterns survived formatting + DocWithPlaceholders(Vec>, usize), +} + +/// Callback function type for formatting embedded code via `Doc`. +/// +/// Takes (allocator, group_id_builder, language, texts) and returns [`EmbeddedDocResult`]. /// Used for the Doc→IR path (e.g., `JS:printToDoc()` → Doc JSON → `Rust:FormatElement`). /// /// The `&Allocator` allows the callback to allocate arena strings for `FormatElement::Text`. /// The `&UniqueGroupIdBuilder` allows the callback to create `GroupId`s for group/conditional constructs. -/// -/// For GraphQL, each quasi is a separate text (`texts.len() == quasis.len()`). -/// For CSS/HTML, quasis are joined with placeholders into a single text (`texts.len() == 1`). pub type EmbeddedDocFormatterCallback = Arc< dyn for<'a> Fn( &'a Allocator, &UniqueGroupIdBuilder, &str, &[&str], - ) -> Result>>, String> + ) -> Result, String> + Send + Sync, >; @@ -90,31 +99,29 @@ impl ExternalCallbacks { self.embedded_formatter.as_ref().map(|cb| cb(tag_name, code)) } - /// Format embedded code as Doc in batch. - /// - /// Takes multiple texts and returns one `Vec>` per input text. - /// The caller is responsible for interleaving the results with JS expressions. + /// Format embedded code as Doc. /// /// # Arguments /// * `allocator` - The arena allocator for allocating strings in `FormatElement::Text` /// * `group_id_builder` - Builder for creating unique `GroupId`s - /// * `tag_name` - The template tag (e.g., "css", "gql", "html") + /// * `language` - The embedded language (e.g. "tagged-css", "tagged-graphql") /// * `texts` - The code texts to format (multiple quasis for GraphQL, single joined text for CSS/HTML) /// /// # Returns - /// * `Some(Ok(Vec>>))` - The formatted code as FormatElements for each input text + /// * `Some(Ok(EmbeddedDocResult))` - The formatted Doc result, which may contain multiple IRs or placeholder counts depending on the language (see [`EmbeddedDocResult`]) /// * `Some(Err(String))` - An error message if formatting failed - /// * `None` - No embedded formatter callback is set + /// * `None` - No embedded Doc formatter callback is set + /// pub fn format_embedded_doc<'a>( &self, allocator: &'a Allocator, group_id_builder: &UniqueGroupIdBuilder, - tag_name: &str, + language: &str, texts: &[&str], - ) -> Option>>, String>> { + ) -> Option, String>> { self.embedded_doc_formatter .as_ref() - .map(|cb| cb(allocator, group_id_builder, tag_name, texts)) + .map(|cb| cb(allocator, group_id_builder, language, texts)) } /// Sort Tailwind CSS classes. diff --git a/crates/oxc_formatter/src/lib.rs b/crates/oxc_formatter/src/lib.rs index 30b22b4898948..4ee055ea05d57 100644 --- a/crates/oxc_formatter/src/lib.rs +++ b/crates/oxc_formatter/src/lib.rs @@ -19,7 +19,8 @@ use oxc_span::SourceType; pub use crate::ast_nodes::{AstNode, AstNodes}; pub use crate::external_formatter::{ - EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, ExternalCallbacks, TailwindCallback, + EmbeddedDocFormatterCallback, EmbeddedDocResult, EmbeddedFormatterCallback, ExternalCallbacks, + TailwindCallback, }; pub use crate::formatter::format_element::tag::{ Align, Condition, DedentMode, Group, GroupMode, Tag, diff --git a/crates/oxc_formatter/src/print/template/embed/css.rs b/crates/oxc_formatter/src/print/template/embed/css.rs new file mode 100644 index 0000000000000..0eed4139bf3e6 --- /dev/null +++ b/crates/oxc_formatter/src/print/template/embed/css.rs @@ -0,0 +1,216 @@ +use oxc_allocator::{Allocator, StringBuilder}; +use oxc_ast::ast::*; + +use crate::{ + IndentWidth, + ast_nodes::AstNode, + external_formatter::EmbeddedDocResult, + format_args, + formatter::{FormatElement, Formatter, format_element::TextWidth, prelude::*}, + write, +}; + +// This prefix and suffix are used by Prettier's css formatting, +// so we need to use the same pattern. +const PLACEHOLDER_PREFIX: &str = "@prettier-placeholder-"; +const PLACEHOLDER_SUFFIX: &str = "-id"; + +/// Format a CSS-in-JS template literal via the Doc→IR path with placeholder replacement. +/// +/// Joins quasis with special `@prettier-placeholder-N-id` markers, formats as SCSS, +/// then replaces placeholder occurrences in the resulting IR with `${expr}` Docs. +pub(super) fn format_css_doc<'a>( + quasi: &AstNode<'a, TemplateLiteral<'a>>, + f: &mut Formatter<'_, 'a>, +) -> bool { + let quasis = &quasi.quasis; + let expressions: Vec<_> = quasi.expressions().iter().collect(); + + // Phase 0: No expressions + // format the single quasi text directly + if expressions.is_empty() { + // Use `.raw` (not `.cooked`), CSS/SCSS needs the original escape sequences + let raw = quasis[0].value.raw.as_str(); + + if raw.trim().is_empty() { + write!(f, ["``"]); + return true; + } + + let allocator = f.allocator(); + let group_id_builder = f.group_id_builder(); + let Some(Ok(EmbeddedDocResult::DocWithPlaceholders(ir, _))) = f + .context() + .external_callbacks() + .format_embedded_doc(allocator, group_id_builder, "tagged-css", &[raw]) + else { + return false; + }; + + write!(f, ["`", block_indent(&format_once(|f| f.write_elements(ir))), "`"]); + return true; + } + + // Phase 1: Build joined text + // quasis[0].raw + "@prettier-placeholder-0-id" + quasis[1].raw + ... + let allocator = f.context().allocator(); + let joined = { + let mut sb = StringBuilder::new_in(allocator); + for (idx, quasi_elem) in quasis.iter().enumerate() { + if idx > 0 { + sb.push_str(PLACEHOLDER_PREFIX); + let _ = std::fmt::Write::write_fmt(&mut sb, std::format_args!("{}", idx - 1)); + sb.push_str(PLACEHOLDER_SUFFIX); + } + // Use `.raw` (not `.cooked`), CSS/SCSS needs the original escape sequences + sb.push_str(quasi_elem.value.raw.as_str()); + } + sb.into_str() + }; + + // Phase 2: Format via the Doc→IR path + let allocator = f.allocator(); + let group_id_builder = f.group_id_builder(); + let Some(Ok(EmbeddedDocResult::DocWithPlaceholders(ir, placeholder_count))) = f + .context() + .external_callbacks() + .format_embedded_doc(allocator, group_id_builder, "tagged-css", &[joined]) + else { + return false; + }; + + // Verify all placeholders survived SCSS formatting. + // Some edge cases (e.g. `/* prettier-ignore */` before a placeholder without semicolon) + // cause SCSS to drop placeholders. + // In that case, fall back to regular template formatting + // (same behavior as Prettier's `replacePlaceholders()` returning `null`). + // https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/css.js#L42 + if placeholder_count != expressions.len() { + return false; + } + + // Phase 3: Replace placeholders in IR with expressions + let indent_width = f.options().indent_width; + let format_content = format_once(move |f: &mut Formatter<'_, 'a>| { + for element in ir { + match &element { + FormatElement::Text { text, .. } if text.contains(PLACEHOLDER_PREFIX) => { + let parts = split_on_placeholders(text); + for (i, part) in parts.iter().enumerate() { + if i % 2 == 0 { + if !part.is_empty() { + write_text_with_line_breaks(f, part, allocator, indent_width); + } + } else if let Some(idx) = part.parse::().ok() + && let Some(expr) = expressions.get(idx) + { + // Format `${expr}` directly to preserve soft line breaks + // so the printer can decide line breaks based on `printWidth`. + // (Regular template expressions use `RemoveSoftLinesBuffer` + // which forces single-line layout.) + write!( + f, + [group(&format_args!("${", expr, line_suffix_boundary(), "}"))] + ); + } + } + } + _ => { + f.write_element(element); + } + } + } + }); + + write!(f, ["`", block_indent(&format_content), "`"]); + true +} + +// --- + +/// Split text on `@prettier-placeholder-N-id` patterns. +/// +/// Returns alternating parts: `[literal, index_str, literal, index_str, ...]` +/// Similar to JavaScript `String.split(/(@prettier-placeholder-(\d+)-id)/)` +/// but only captures the digit group (index). +fn split_on_placeholders(text: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut remaining = text; + + loop { + let Some(start) = remaining.find(PLACEHOLDER_PREFIX) else { + result.push(remaining); + break; + }; + + // Push the literal before the placeholder + result.push(&remaining[..start]); + + // Skip past the prefix + let after_prefix = &remaining[start + PLACEHOLDER_PREFIX.len()..]; + + // Find the digits + let digit_end = + after_prefix.bytes().position(|b| !b.is_ascii_digit()).unwrap_or(after_prefix.len()); + + if digit_end == 0 { + // No digits found after prefix — not a valid placeholder, treat as literal + if let Some(last) = result.last_mut() { + let end = start + PLACEHOLDER_PREFIX.len(); + *last = &remaining[..end]; + } + remaining = &remaining[start + PLACEHOLDER_PREFIX.len()..]; + continue; + } + + let digits = &after_prefix[..digit_end]; + let after_digits = &after_prefix[digit_end..]; + + // Check for the `-id` suffix + if let Some(after_suffix) = after_digits.strip_prefix(PLACEHOLDER_SUFFIX) { + // Valid placeholder - push the digit index + result.push(digits); + remaining = after_suffix; + } else { + // Not a valid placeholder, include in the literal + let end = start + PLACEHOLDER_PREFIX.len() + digit_end; + if let Some(last) = result.last_mut() { + *last = &remaining[..end]; + } + remaining = &remaining[end..]; + } + } + + result +} + +/// Emit text with newlines converted to literal line breaks (`replaceEndOfLine()` equivalent). +/// +/// Uses `Text("\n") + ExpandParent` (= `literalline()`) +/// instead of `hard_line_break()` to avoid adding indentation. +/// +/// The SCSS formatter has already computed proper indentation in the text content, +/// so we must not add extra indent from the surrounding `block_indent`. +fn write_text_with_line_breaks<'a>( + f: &mut Formatter<'_, 'a>, + text: &str, + allocator: &'a Allocator, + indent_width: IndentWidth, +) { + let mut first = true; + // Splitting on `\n` is safe because `Doc` only contains normalized linebreaks. + for line in text.split('\n') { + if !first { + // Emit literalline: Text("\n") + ExpandParent + let newline = allocator.alloc_str("\n"); + f.write_element(FormatElement::Text { text: newline, width: TextWidth::multiline(0) }); + f.write_element(FormatElement::ExpandParent); + } + first = false; + if !line.is_empty() { + let arena_text = allocator.alloc_str(line); + let width = TextWidth::from_text(arena_text, indent_width); + f.write_element(FormatElement::Text { text: arena_text, width }); + } + } +} diff --git a/crates/oxc_formatter/src/print/template/embed/graphql.rs b/crates/oxc_formatter/src/print/template/embed/graphql.rs index da5784c6b3b5c..3cd559ecd4e0c 100644 --- a/crates/oxc_formatter/src/print/template/embed/graphql.rs +++ b/crates/oxc_formatter/src/print/template/embed/graphql.rs @@ -89,12 +89,11 @@ pub(super) fn format_graphql_doc<'a>( } else { let allocator = f.allocator(); let group_id_builder = f.group_id_builder(); - let Some(Ok(irs)) = f.context().external_callbacks().format_embedded_doc( - allocator, - group_id_builder, - "tagged-graphql", - &texts_to_format, - ) else { + let Some(Ok(crate::external_formatter::EmbeddedDocResult::MultipleDocs(irs))) = f + .context() + .external_callbacks() + .format_embedded_doc(allocator, group_id_builder, "tagged-graphql", &texts_to_format) + else { return false; }; irs diff --git a/crates/oxc_formatter/src/print/template/embed/mod.rs b/crates/oxc_formatter/src/print/template/embed/mod.rs index 474bd57af2a6e..9ad2fd1a31461 100644 --- a/crates/oxc_formatter/src/print/template/embed/mod.rs +++ b/crates/oxc_formatter/src/print/template/embed/mod.rs @@ -1,3 +1,4 @@ +mod css; mod graphql; use oxc_allocator::{Allocator, StringBuilder}; @@ -17,7 +18,7 @@ pub(super) fn try_format_embedded_template<'a>( f: &mut Formatter<'_, 'a>, ) -> bool { match get_tag_name(&tagged.tag) { - Some("css" | "styled") => try_embed_css(tagged, f), + Some("css" | "styled") => css::format_css_doc(tagged.quasi(), f), Some("gql" | "graphql") => graphql::format_graphql_doc(tagged.quasi(), f), Some("html") => try_embed_html(tagged, f), Some("md" | "markdown") => try_embed_markdown(tagged, f), @@ -60,17 +61,10 @@ pub(super) fn try_format_css_template<'a>( template_literal: &AstNode<'a, TemplateLiteral<'a>>, f: &mut Formatter<'_, 'a>, ) -> bool { - // TODO: Support expressions in the css-in-js - if !template_literal.is_no_substitution_template() { - return false; - } - if !is_in_css_jsx(template_literal) { return false; } - - let template_content = template_literal.quasis()[0].value.raw.as_str(); - format_embedded_template(f, "styled-jsx", template_content) + css::format_css_doc(template_literal, f) } /// Check if the template literal is inside a `css` prop or `