Skip to content
Merged
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
24 changes: 12 additions & 12 deletions apps/oxfmt/src/core/external_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Result<Vec<_>, _>>()
.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 {
Expand Down Expand Up @@ -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,
}
}
Expand Down
132 changes: 93 additions & 39 deletions apps/oxfmt/src/prettier_compat/from_prettier_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,54 @@ 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.
/// JS side replaces `-Infinity` with this string before `JSON.stringify()`.
/// 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<FormatElement<'a>>`,
/// 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<Vec<FormatElement<'a>>, 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<EmbeddedDocResult<'a>, String> {
let convert = |doc_json: &Value| -> Result<(Vec<FormatElement<'a>>, 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::<Result<Vec<_>, String>>()?;
Ok(EmbeddedDocResult::MultipleDocs(irs))
}
_ => unreachable!("Unsupported embedded_doc language: {language}"),
}
}

// ---
Expand Down Expand Up @@ -333,29 +345,31 @@ 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<FormatElement<'a>>, 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<FormatElement<'a>>, 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))
{
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)
Expand All @@ -366,17 +380,57 @@ fn postprocess<'a>(ir: &mut Vec<FormatElement<'a>>, 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.
Expand Down
39 changes: 23 additions & 16 deletions crates/oxc_formatter/src/external_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,32 @@ use super::formatter::{FormatElement, group_id::UniqueGroupIdBuilder};
pub type EmbeddedFormatterCallback =
Arc<dyn Fn(&str, &str) -> Result<String, String> + 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<FormatElement<'a>>` 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<Vec<FormatElement<'a>>>),
/// CSS: The count indicates how many `@prettier-placeholder-N-id` patterns survived formatting
DocWithPlaceholders(Vec<FormatElement<'a>>, 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<Vec<Vec<FormatElement<'a>>>, String>
) -> Result<EmbeddedDocResult<'a>, String>
+ Send
+ Sync,
>;
Expand Down Expand Up @@ -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<FormatElement<'a>>` 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<Vec<FormatElement<'a>>>))` - 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<Result<Vec<Vec<FormatElement<'a>>>, String>> {
) -> Option<Result<EmbeddedDocResult<'a>, 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.
Expand Down
3 changes: 2 additions & 1 deletion crates/oxc_formatter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading