Skip to content

Commit e48ddf2

Browse files
authored
Merge d8711c1 into 50359dc
2 parents 50359dc + d8711c1 commit e48ddf2

File tree

7 files changed

+368
-101
lines changed

7 files changed

+368
-101
lines changed

apps/oxfmt/src/core/external_formatter.rs

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -301,16 +301,13 @@ impl ExternalFormatter {
301301
"Failed to get Doc for embedded code (parser '{parser_name}'): {err}"
302302
)
303303
})?;
304-
doc_json_strs
305-
.into_iter()
306-
.map(|doc_json_str| {
307-
let doc_json: serde_json::Value =
308-
serde_json::from_str(&doc_json_str).map_err(|err| {
309-
format!("Failed to parse Doc JSON: {err}")
310-
})?;
311-
from_prettier_doc::to_format_elements_for_template(&doc_json, allocator, group_id_builder)
312-
})
313-
.collect()
304+
305+
from_prettier_doc::to_embedded_doc_result(
306+
language,
307+
doc_json_strs,
308+
allocator,
309+
group_id_builder,
310+
)
314311
})
315312
}))
316313
} else {
@@ -363,13 +360,11 @@ impl ExternalFormatter {
363360
/// This is the single source of truth for supported embedded languages.
364361
fn language_to_prettier_parser(language: &str) -> Option<&'static str> {
365362
match language {
366-
// TODO: "tagged-css" should use `scss` parser to support quasis
367-
"tagged-css" | "styled-jsx" => Some("css"),
363+
"tagged-css" | "styled-jsx" | "angular-styles" => Some("scss"),
368364
"tagged-graphql" => Some("graphql"),
369365
"tagged-html" => Some("html"),
370366
"tagged-markdown" => Some("markdown"),
371367
"angular-template" => Some("angular"),
372-
"angular-styles" => Some("scss"),
373368
_ => None,
374369
}
375370
}

apps/oxfmt/src/prettier_compat/from_prettier_doc.rs

Lines changed: 102 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,41 +5,64 @@ use serde_json::Value;
55

66
use oxc_allocator::{Allocator, StringBuilder};
77
use oxc_formatter::{
8-
Align, Condition, DedentMode, FormatElement, Group, GroupId, GroupMode, IndentWidth, LineMode,
9-
PrintMode, Tag, TextWidth, UniqueGroupIdBuilder,
8+
Align, Condition, DedentMode, EmbeddedDocResult, FormatElement, Group, GroupId, GroupMode,
9+
IndentWidth, LineMode, PrintMode, Tag, TextWidth, UniqueGroupIdBuilder,
1010
};
1111

1212
/// Marker string used to represent `-Infinity` in JSON.
1313
/// JS side replaces `-Infinity` with this string before `JSON.stringify()`.
1414
/// See `src-js/lib/apis.ts` for details.
1515
const NEGATIVE_INFINITY_MARKER: &str = "__NEGATIVE_INFINITY__";
1616

17-
/// Converts a Prettier Doc JSON value into a flat `Vec<FormatElement<'a>>`,
18-
/// with template-specific text escaping applied as post-processing.
19-
pub fn to_format_elements_for_template<'a>(
20-
doc: &Value,
17+
/// Converts Prettier Doc JSON strings into an [`EmbeddedDocResult`].
18+
///
19+
/// Handles both language-specific processing:
20+
/// - CSS (`tagged-css`, `styled-jsx`, `angular-styles`):
21+
/// merges consecutive Text nodes, counts placeholders → [`EmbeddedDocResult::DocWithPlaceholders`]
22+
/// - Others (GraphQL, etc.):
23+
/// converts each doc independently → [`EmbeddedDocResult::MultipleDocs`]
24+
pub fn to_embedded_doc_result<'a>(
25+
language: &str,
26+
doc_json_strs: Vec<String>,
27+
allocator: &'a Allocator,
28+
group_id_builder: &UniqueGroupIdBuilder,
29+
) -> Result<EmbeddedDocResult<'a>, String> {
30+
match language {
31+
"tagged-css" | "styled-jsx" | "angular-styles" => {
32+
let doc_json_str = doc_json_strs
33+
.into_iter()
34+
.next()
35+
.ok_or_else(|| "Expected exactly one Doc JSON for CSS".to_string())?;
36+
let mut ir = parse_and_convert(&doc_json_str, allocator, group_id_builder)?;
37+
let count = postprocess(&mut ir, allocator);
38+
Ok(EmbeddedDocResult::DocWithPlaceholders(ir, count))
39+
}
40+
"tagged-graphql" => {
41+
let irs = doc_json_strs
42+
.into_iter()
43+
.map(|doc_json_str| {
44+
let mut ir = parse_and_convert(&doc_json_str, allocator, group_id_builder)?;
45+
let _ = postprocess(&mut ir, allocator);
46+
Ok(ir)
47+
})
48+
.collect::<Result<Vec<_>, String>>()?;
49+
Ok(EmbeddedDocResult::MultipleDocs(irs))
50+
}
51+
_ => unreachable!("Unsupported embedded_doc language: {language}"),
52+
}
53+
}
54+
55+
/// JSON parse → Doc → FormatElement IR.
56+
fn parse_and_convert<'a>(
57+
doc_json_str: &str,
2158
allocator: &'a Allocator,
2259
group_id_builder: &UniqueGroupIdBuilder,
2360
) -> Result<Vec<FormatElement<'a>>, String> {
61+
let doc_json: Value =
62+
serde_json::from_str(doc_json_str).map_err(|e| format!("Failed to parse Doc JSON: {e}"))?;
2463
let mut ctx = FmtCtx::new(allocator, group_id_builder);
2564
let mut out = vec![];
26-
convert_doc(doc, &mut out, &mut ctx)?;
27-
28-
postprocess(&mut out, |fe| {
29-
if let FormatElement::Text { text, width } = fe {
30-
// Some characters (e.g. backticks) should be escaped in template literals
31-
let escaped = escape_template_characters(text, allocator);
32-
if !std::ptr::eq(*text, escaped) {
33-
*text = escaped;
34-
// NOTE: `IndentWidth` only affects tab character width calculation.
35-
// If a `Doc = string` node contained `\t` (e.g. inside a string literal like `"\t"`?),
36-
// the width could be miscalculated when `options.indent_width` != 2.
37-
// However, the default value is sufficient in practice.
38-
*width = TextWidth::from_text(escaped, IndentWidth::default());
39-
}
40-
}
41-
});
42-
65+
convert_doc(&doc_json, &mut out, &mut ctx)?;
4366
Ok(out)
4467
}
4568

@@ -333,29 +356,31 @@ fn extract_group_id(
333356

334357
// ---
335358

336-
/// Post-process `FormatElement`s:
337-
/// - strip trailing hardline
338-
/// - collapse consecutive hardlines into empty lines
359+
/// Post-process FormatElements in a single compaction pass:
360+
/// - strip trailing hardline (useless for embedded parts)
361+
/// - collapse double-hardlines `[Hard, ExpandParent, Hard, ExpandParent]` → `[Empty, ExpandParent]`
362+
/// - merge consecutive Text nodes (SCSS emits split strings like `"@"` + `"prettier-placeholder-0-id"`)
363+
/// - escape template characters (`\`, `` ` ``, `${`)
364+
/// - count `@prettier-placeholder-N-id` patterns
339365
///
340-
/// And apply a per-element callback for custom transformations.
341-
fn postprocess<'a>(ir: &mut Vec<FormatElement<'a>>, mut each: impl FnMut(&mut FormatElement<'a>)) {
342-
// Strip trailing `hardline` pattern from FormatElement output.
343-
// Trailing line is useless for embedded parts.
366+
/// Returns the placeholder count (0 for non-CSS languages).
367+
fn postprocess<'a>(ir: &mut Vec<FormatElement<'a>>, allocator: &'a Allocator) -> usize {
368+
const PREFIX: &str = "@prettier-placeholder-";
369+
const SUFFIX: &str = "-id";
370+
371+
// Strip trailing hardline
344372
if ir.len() >= 2
345373
&& matches!(ir[ir.len() - 1], FormatElement::ExpandParent)
346374
&& matches!(ir[ir.len() - 2], FormatElement::Line(LineMode::Hard))
347375
{
348376
ir.truncate(ir.len() - 2);
349377
}
350378

351-
// Collapse consecutive `[Line(Hard), ExpandParent, Line(Hard), ExpandParent]` into `[Line(Empty), ExpandParent]`.
352-
//
353-
// In Prettier's Doc format, a blank line is represented as `hardline,
354-
// hardline` which expands to `[Line(Hard), ExpandParent, Line(Hard), ExpandParent]`.
355-
// However, `oxc_formatter`'s printer needs `Line(Empty)` instead.
379+
let mut placeholder_count = 0;
356380
let mut write = 0;
357381
let mut read = 0;
358382
while read < ir.len() {
383+
// Collapse double-hardline → empty line
359384
if read + 3 < ir.len()
360385
&& matches!(ir[read], FormatElement::Line(LineMode::Hard))
361386
&& matches!(ir[read + 1], FormatElement::ExpandParent)
@@ -366,17 +391,57 @@ fn postprocess<'a>(ir: &mut Vec<FormatElement<'a>>, mut each: impl FnMut(&mut Fo
366391
ir[write + 1] = FormatElement::ExpandParent;
367392
write += 2;
368393
read += 4;
394+
} else if matches!(ir[read], FormatElement::Text { .. }) {
395+
// Merge consecutive Text nodes + escape + count placeholders
396+
let run_start = read;
397+
read += 1;
398+
while read < ir.len() && matches!(ir[read], FormatElement::Text { .. }) {
399+
read += 1;
400+
}
401+
402+
let escaped = if read - run_start == 1 {
403+
let FormatElement::Text { text, .. } = &ir[run_start] else { unreachable!() };
404+
escape_template_characters(text, allocator)
405+
} else {
406+
let mut sb = StringBuilder::new_in(allocator);
407+
for element in &ir[run_start..read] {
408+
if let FormatElement::Text { text, .. } = element {
409+
sb.push_str(text);
410+
}
411+
}
412+
escape_template_characters(sb.into_str(), allocator)
413+
};
414+
let width = TextWidth::from_text(escaped, IndentWidth::default());
415+
ir[write] = FormatElement::Text { text: escaped, width };
416+
write += 1;
417+
418+
// Count placeholders
419+
let mut remaining = escaped;
420+
while let Some(start) = remaining.find(PREFIX) {
421+
let after_prefix = &remaining[start + PREFIX.len()..];
422+
let digit_end = after_prefix
423+
.bytes()
424+
.position(|b| !b.is_ascii_digit())
425+
.unwrap_or(after_prefix.len());
426+
if digit_end > 0
427+
&& let Some(rest) = after_prefix[digit_end..].strip_prefix(SUFFIX)
428+
{
429+
placeholder_count += 1;
430+
remaining = rest;
431+
continue;
432+
}
433+
remaining = &remaining[start + PREFIX.len()..];
434+
}
369435
} else {
370436
if write != read {
371437
ir[write] = ir[read].clone();
372438
}
373-
each(&mut ir[write]);
374439
write += 1;
375440
read += 1;
376441
}
377442
}
378-
379443
ir.truncate(write);
444+
placeholder_count
380445
}
381446

382447
/// Escape characters that would break template literal syntax.

crates/oxc_formatter/src/external_formatter.rs

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,33 @@ use super::formatter::{FormatElement, group_id::UniqueGroupIdBuilder};
99
pub type EmbeddedFormatterCallback =
1010
Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;
1111

12-
/// Callback function type for formatting embedded code via Doc in batch.
12+
/// Result of formatting embedded code via the Doc→IR path.
1313
///
14-
/// Takes (allocator, group_id_builder, tag_name, texts) and returns one `Vec<FormatElement<'a>>` per input.
14+
/// The variant depends on the language being formatted:
15+
/// - GraphQL: multiple IRs (one per quasi text)
16+
/// - CSS: single IR with placeholder survival count
17+
pub enum EmbeddedDocResult<'a> {
18+
/// Multiple IRs, one per input text (used by GraphQL).
19+
MultipleDocs(Vec<Vec<FormatElement<'a>>>),
20+
/// Single IR with placeholder count (used by CSS).
21+
/// The count indicates how many `@prettier-placeholder-N-id` patterns survived formatting.
22+
DocWithPlaceholders(Vec<FormatElement<'a>>, usize),
23+
}
24+
25+
/// Callback function type for formatting embedded code via Doc.
26+
///
27+
/// Takes (allocator, group_id_builder, language, texts) and returns [`EmbeddedDocResult`].
1528
/// Used for the Doc→IR path (e.g., `JS:printToDoc()` → Doc JSON → `Rust:FormatElement`).
1629
///
1730
/// The `&Allocator` allows the callback to allocate arena strings for `FormatElement::Text`.
1831
/// The `&UniqueGroupIdBuilder` allows the callback to create `GroupId`s for group/conditional constructs.
19-
///
20-
/// For GraphQL, each quasi is a separate text (`texts.len() == quasis.len()`).
21-
/// For CSS/HTML, quasis are joined with placeholders into a single text (`texts.len() == 1`).
2232
pub type EmbeddedDocFormatterCallback = Arc<
2333
dyn for<'a> Fn(
2434
&'a Allocator,
2535
&UniqueGroupIdBuilder,
2636
&str,
2737
&[&str],
28-
) -> Result<Vec<Vec<FormatElement<'a>>>, String>
38+
) -> Result<EmbeddedDocResult<'a>, String>
2939
+ Send
3040
+ Sync,
3141
>;
@@ -90,31 +100,19 @@ impl ExternalCallbacks {
90100
self.embedded_formatter.as_ref().map(|cb| cb(tag_name, code))
91101
}
92102

93-
/// Format embedded code as Doc in batch.
94-
///
95-
/// Takes multiple texts and returns one `Vec<FormatElement<'a>>` per input text.
96-
/// The caller is responsible for interleaving the results with JS expressions.
103+
/// Format embedded code as Doc.
97104
///
98-
/// # Arguments
99-
/// * `allocator` - The arena allocator for allocating strings in `FormatElement::Text`
100-
/// * `group_id_builder` - Builder for creating unique `GroupId`s
101-
/// * `tag_name` - The template tag (e.g., "css", "gql", "html")
102-
/// * `texts` - The code texts to format (multiple quasis for GraphQL, single joined text for CSS/HTML)
103-
///
104-
/// # Returns
105-
/// * `Some(Ok(Vec<Vec<FormatElement<'a>>>))` - The formatted code as FormatElements for each input text
106-
/// * `Some(Err(String))` - An error message if formatting failed
107-
/// * `None` - No embedded formatter callback is set
105+
/// The result variant depends on the language (see [`EmbeddedDocResult`]).
108106
pub fn format_embedded_doc<'a>(
109107
&self,
110108
allocator: &'a Allocator,
111109
group_id_builder: &UniqueGroupIdBuilder,
112-
tag_name: &str,
110+
language: &str,
113111
texts: &[&str],
114-
) -> Option<Result<Vec<Vec<FormatElement<'a>>>, String>> {
112+
) -> Option<Result<EmbeddedDocResult<'a>, String>> {
115113
self.embedded_doc_formatter
116114
.as_ref()
117-
.map(|cb| cb(allocator, group_id_builder, tag_name, texts))
115+
.map(|cb| cb(allocator, group_id_builder, language, texts))
118116
}
119117

120118
/// Sort Tailwind CSS classes.

crates/oxc_formatter/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ use oxc_span::SourceType;
1919

2020
pub use crate::ast_nodes::{AstNode, AstNodes};
2121
pub use crate::external_formatter::{
22-
EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, ExternalCallbacks, TailwindCallback,
22+
EmbeddedDocFormatterCallback, EmbeddedDocResult, EmbeddedFormatterCallback, ExternalCallbacks,
23+
TailwindCallback,
2324
};
2425
pub use crate::formatter::format_element::tag::{
2526
Align, Condition, DedentMode, Group, GroupMode, Tag,

0 commit comments

Comments
 (0)