diff --git a/apps/oxfmt/src/core/external_formatter.rs b/apps/oxfmt/src/core/external_formatter.rs index 18dff8919725a..e02ec6eefc5e4 100644 --- a/apps/oxfmt/src/core/external_formatter.rs +++ b/apps/oxfmt/src/core/external_formatter.rs @@ -276,7 +276,7 @@ impl ExternalFormatter { let embedded_doc_callback: Option = if needs_embedded { let format_embedded_doc = Arc::clone(&self.format_embedded_doc); let options_for_doc = options.clone(); - Some(Arc::new(move |language: &str, texts: &[&str]| { + Some(Arc::new(move |allocator, group_id_builder, language: &str, texts: &[&str]| { let Some(parser_name) = language_to_prettier_parser(language) else { return Err(format!("Unsupported language: {language}")); }; @@ -302,7 +302,7 @@ impl ExternalFormatter { serde_json::from_str(&doc_json_str).map_err(|err| { format!("Failed to parse Doc JSON: {err}") })?; - from_prettier_doc::doc_json_to_embedded_ir(&doc_json) + from_prettier_doc::to_format_elements_for_template(&doc_json, allocator, group_id_builder) }) .collect() }) diff --git a/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs b/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs index 25939a7f42083..f01ff504e5bb0 100644 --- a/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs +++ b/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs @@ -1,37 +1,89 @@ +use std::num::NonZeroU8; + +use rustc_hash::FxHashMap; use serde_json::Value; -use oxc_formatter::{EmbeddedIR, LineMode, PrintMode}; +use oxc_allocator::{Allocator, StringBuilder}; +use oxc_formatter::{ + Align, Condition, DedentMode, 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`. -/// -/// This is the reverse of `to_prettier_doc.rs` which converts `FormatElement` → Prettier Doc JSON. -/// The Doc JSON comes from Prettier's `__debug.printToDoc()` API. -pub fn doc_json_to_embedded_ir(doc: &Value) -> Result, String> { +/// Converts a Prettier Doc JSON value into a flat `Vec>`, +/// with template-specific text escaping applied as post-processing. +pub fn to_format_elements_for_template<'a>( + doc: &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)?; - - strip_trailing_hardline(&mut out); - collapse_consecutive_hardlines(&mut out); + 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()); + } + } + }); Ok(out) } -fn convert_doc(doc: &Value, out: &mut Vec) -> Result<(), String> { +// --- + +/// Conversion context holding the allocator, group ID builder, and group ID mapping. +struct FmtCtx<'a, 'b> { + allocator: &'a Allocator, + group_id_builder: &'b UniqueGroupIdBuilder, + /// Maps numeric group IDs from Prettier Doc JSON to real `GroupId`s. + group_id_map: FxHashMap, +} + +impl<'a, 'b> FmtCtx<'a, 'b> { + fn new(allocator: &'a Allocator, group_id_builder: &'b UniqueGroupIdBuilder) -> Self { + Self { allocator, group_id_builder, group_id_map: FxHashMap::default() } + } + + fn resolve_group_id(&mut self, id: u32) -> GroupId { + *self.group_id_map.entry(id).or_insert_with(|| self.group_id_builder.group_id("xxx-in-js")) + } +} + +fn convert_doc<'a>( + doc: &Value, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, +) -> Result<(), String> { match doc { Value::String(s) => { if !s.is_empty() { - out.push(EmbeddedIR::Text(s.clone())); + let text = ctx.allocator.alloc_str(s); + // 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. + let width = TextWidth::from_text(text, IndentWidth::default()); + out.push(FormatElement::Text { text, width }); } Ok(()) } Value::Array(arr) => { for item in arr { - convert_doc(item, out)?; + convert_doc(item, out, ctx)?; } Ok(()) } @@ -41,32 +93,31 @@ fn convert_doc(doc: &Value, out: &mut Vec) -> Result<(), String> { }; match doc_type { "line" => { - convert_line(obj, out); + convert_line(obj, out, ctx); Ok(()) } - "group" => convert_group(obj, out), - "indent" => convert_indent(obj, out), - "align" => convert_align(obj, out), - "if-break" => convert_if_break(obj, out), - "indent-if-break" => convert_indent_if_break(obj, out), - "fill" => convert_fill(obj, out), - "line-suffix" => convert_line_suffix(obj, out), + "group" => convert_group(obj, out, ctx), + "indent" => convert_indent(obj, out, ctx), + "align" => convert_align(obj, out, ctx), + "if-break" => convert_if_break(obj, out, ctx), + "indent-if-break" => convert_indent_if_break(obj, out, ctx), + "fill" => convert_fill(obj, out, ctx), + "line-suffix" => convert_line_suffix(obj, out, ctx), "line-suffix-boundary" => { - out.push(EmbeddedIR::LineSuffixBoundary); + out.push(FormatElement::LineSuffixBoundary); Ok(()) } "break-parent" => { - out.push(EmbeddedIR::ExpandParent); + out.push(FormatElement::ExpandParent); Ok(()) } "label" => { - // Label is transparent in Prettier's printer (just processes contents) if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } Ok(()) } - "cursor" => Ok(()), // Ignore cursor markers + "cursor" => Ok(()), "trim" => Err("Unsupported Doc type: 'trim'".to_string()), _ => Err(format!("Unknown Doc type: '{doc_type}'")), } @@ -76,31 +127,34 @@ fn convert_doc(doc: &Value, out: &mut Vec) -> Result<(), String> { } } -fn convert_line(obj: &serde_json::Map, out: &mut Vec) { +fn convert_line<'a>( + obj: &serde_json::Map, + out: &mut Vec>, + ctx: &FmtCtx<'a, '_>, +) { let hard = obj.get("hard").and_then(Value::as_bool).unwrap_or(false); let soft = obj.get("soft").and_then(Value::as_bool).unwrap_or(false); let literal = obj.get("literal").and_then(Value::as_bool).unwrap_or(false); if hard && literal { - // literalline: newline without indent, plus break-parent - // Reverse of to_prettier_doc.rs: Text("\n") → literalline - out.push(EmbeddedIR::Text("\n".to_string())); - out.push(EmbeddedIR::ExpandParent); + let arena_text = ctx.allocator.alloc_str("\n"); + let width = TextWidth::multiline(0); + out.push(FormatElement::Text { text: arena_text, width }); + out.push(FormatElement::ExpandParent); } else if hard { - out.push(EmbeddedIR::Line(LineMode::Hard)); + out.push(FormatElement::Line(LineMode::Hard)); } else if soft { - out.push(EmbeddedIR::Line(LineMode::Soft)); + out.push(FormatElement::Line(LineMode::Soft)); } else { - out.push(EmbeddedIR::Line(LineMode::SoftOrSpace)); + out.push(FormatElement::Line(LineMode::SoftOrSpace)); } } -fn convert_group( +fn convert_group<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { - // Bail out on expandedStates (conditionalGroup) - // Even in Prettier, only JS and YAML use this. if obj.contains_key("expandedStates") { return Err("Unsupported: group with 'expandedStates' (conditionalGroup)".to_string()); } @@ -108,147 +162,155 @@ fn convert_group( let should_break = obj.get("break").and_then(Value::as_bool).unwrap_or(false); let id = extract_group_id(obj, "id")?; - out.push(EmbeddedIR::StartGroup { id, should_break }); + let gid = id.map(|n| ctx.resolve_group_id(n)); + let mode = if should_break { GroupMode::Expand } else { GroupMode::Flat }; + out.push(FormatElement::Tag(Tag::StartGroup(Group::new().with_id(gid).with_mode(mode)))); if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } - out.push(EmbeddedIR::EndGroup); + out.push(FormatElement::Tag(Tag::EndGroup)); Ok(()) } -fn convert_indent( +fn convert_indent<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { - out.push(EmbeddedIR::StartIndent); + out.push(FormatElement::Tag(Tag::StartIndent)); if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } - out.push(EmbeddedIR::EndIndent); + out.push(FormatElement::Tag(Tag::EndIndent)); Ok(()) } -fn convert_align( +fn convert_align<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { let n = &obj["n"]; match n { - // Numeric value Value::Number(num) => { if let Some(i) = num.as_i64() { if i == 0 { - // n=0: transparent (no-op), just emit contents if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } return Ok(()); } else if i == -1 { - // dedent (one level) - out.push(EmbeddedIR::StartDedent { to_root: false }); + out.push(FormatElement::Tag(Tag::StartDedent(DedentMode::Level))); if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } - out.push(EmbeddedIR::EndDedent { to_root: false }); + out.push(FormatElement::Tag(Tag::EndDedent(DedentMode::Level))); return Ok(()); } else if i > 0 && i <= 255 { #[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let count = i as u8; - out.push(EmbeddedIR::StartAlign(count)); - if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + if let Some(nz) = NonZeroU8::new(count) { + out.push(FormatElement::Tag(Tag::StartAlign(Align::new(nz)))); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out, ctx)?; + } + out.push(FormatElement::Tag(Tag::EndAlign)); + return Ok(()); } - out.push(EmbeddedIR::EndAlign); - return Ok(()); } } - // Fallthrough: n is a float or out of range Err(format!("Unsupported align value: {n}")) } - // -Infinity marker string Value::String(s) if s == NEGATIVE_INFINITY_MARKER => { - // dedentToRoot - out.push(EmbeddedIR::StartDedent { to_root: true }); + out.push(FormatElement::Tag(Tag::StartDedent(DedentMode::Root))); if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } - out.push(EmbeddedIR::EndDedent { to_root: true }); + out.push(FormatElement::Tag(Tag::EndDedent(DedentMode::Root))); Ok(()) } _ => Err(format!("Unsupported align value: {n}")), } } -fn convert_if_break( +fn convert_if_break<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { - let group_id = extract_group_id(obj, "groupId")?; + let group_id_num = extract_group_id(obj, "groupId")?; + let gid = group_id_num.map(|n| ctx.resolve_group_id(n)); // Break branch - out.push(EmbeddedIR::StartConditionalContent { mode: PrintMode::Expanded, group_id }); + out.push(FormatElement::Tag(Tag::StartConditionalContent( + Condition::new(PrintMode::Expanded).with_group_id(gid), + ))); if let Some(break_contents) = obj.get("breakContents") { - convert_doc(break_contents, out)?; + convert_doc(break_contents, out, ctx)?; } - out.push(EmbeddedIR::EndConditionalContent); + out.push(FormatElement::Tag(Tag::EndConditionalContent)); // Flat branch - out.push(EmbeddedIR::StartConditionalContent { mode: PrintMode::Flat, group_id }); + out.push(FormatElement::Tag(Tag::StartConditionalContent( + Condition::new(PrintMode::Flat).with_group_id(gid), + ))); if let Some(flat_contents) = obj.get("flatContents") { - convert_doc(flat_contents, out)?; + convert_doc(flat_contents, out, ctx)?; } - out.push(EmbeddedIR::EndConditionalContent); + out.push(FormatElement::Tag(Tag::EndConditionalContent)); Ok(()) } -fn convert_indent_if_break( +fn convert_indent_if_break<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { - // negate is not supported - // Even in Prettier, HTML only uses `indentIfBreak()`, but `negate` is never used in the codebase! if obj.get("negate").and_then(Value::as_bool).unwrap_or(false) { return Err("Unsupported: indent-if-break with 'negate'".to_string()); } - let Some(group_id) = extract_group_id(obj, "groupId")? else { + let Some(group_id_num) = extract_group_id(obj, "groupId")? else { return Err("indent-if-break requires 'groupId'".to_string()); }; + let gid = ctx.resolve_group_id(group_id_num); - out.push(EmbeddedIR::StartIndentIfGroupBreaks(group_id)); + out.push(FormatElement::Tag(Tag::StartIndentIfGroupBreaks(gid))); if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } - out.push(EmbeddedIR::EndIndentIfGroupBreaks(group_id)); + out.push(FormatElement::Tag(Tag::EndIndentIfGroupBreaks(gid))); Ok(()) } -fn convert_fill( +fn convert_fill<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { - out.push(EmbeddedIR::StartFill); + out.push(FormatElement::Tag(Tag::StartFill)); if let Some(Value::Array(parts)) = obj.get("parts") { for part in parts { - out.push(EmbeddedIR::StartEntry); - convert_doc(part, out)?; - out.push(EmbeddedIR::EndEntry); + out.push(FormatElement::Tag(Tag::StartEntry)); + convert_doc(part, out, ctx)?; + out.push(FormatElement::Tag(Tag::EndEntry)); } } - out.push(EmbeddedIR::EndFill); + out.push(FormatElement::Tag(Tag::EndFill)); Ok(()) } -fn convert_line_suffix( +fn convert_line_suffix<'a>( obj: &serde_json::Map, - out: &mut Vec, + out: &mut Vec>, + ctx: &mut FmtCtx<'a, '_>, ) -> Result<(), String> { - out.push(EmbeddedIR::StartLineSuffix); + out.push(FormatElement::Tag(Tag::StartLineSuffix)); if let Some(contents) = obj.get("contents") { - convert_doc(contents, out)?; + convert_doc(contents, out, ctx)?; } - out.push(EmbeddedIR::EndLineSuffix); + out.push(FormatElement::Tag(Tag::EndLineSuffix)); Ok(()) } @@ -269,51 +331,46 @@ fn extract_group_id( } } -/// Strip trailing `hardline` pattern from the IR. -/// -/// Prettier's internal `textToDoc()` behavior which calls `stripTrailingHardline()` before returning. -/// `__debug.printToDoc()` does not do this, so we need to handle it here. -/// +// --- + +/// Post-process `FormatElement`s: +/// - strip trailing hardline +/// - collapse consecutive hardlines into empty lines /// -/// Prettier's `hardline` is `[line(hard), break-parent]`, -/// which maps to `[Line(Hard), ExpandParent]` in EmbeddedIR. -fn strip_trailing_hardline(ir: &mut Vec) { +/// 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. if ir.len() >= 2 - && matches!(ir[ir.len() - 1], EmbeddedIR::ExpandParent) - && matches!(ir[ir.len() - 2], EmbeddedIR::Line(LineMode::Hard)) + && 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)` to produce a blank line (double newline). -fn collapse_consecutive_hardlines(ir: &mut Vec) { - if ir.len() < 4 { - return; - } + // 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 write = 0; let mut read = 0; while read < ir.len() { - // Check for the 4-element pattern: Line(Hard), ExpandParent, Line(Hard), ExpandParent if read + 3 < ir.len() - && matches!(ir[read], EmbeddedIR::Line(LineMode::Hard)) - && matches!(ir[read + 1], EmbeddedIR::ExpandParent) - && matches!(ir[read + 2], EmbeddedIR::Line(LineMode::Hard)) - && matches!(ir[read + 3], EmbeddedIR::ExpandParent) + && matches!(ir[read], FormatElement::Line(LineMode::Hard)) + && matches!(ir[read + 1], FormatElement::ExpandParent) + && matches!(ir[read + 2], FormatElement::Line(LineMode::Hard)) + && matches!(ir[read + 3], FormatElement::ExpandParent) { - ir[write] = EmbeddedIR::Line(LineMode::Empty); - ir[write + 1] = EmbeddedIR::ExpandParent; + ir[write] = FormatElement::Line(LineMode::Empty); + ir[write + 1] = FormatElement::ExpandParent; write += 2; read += 4; } else { if write != read { ir[write] = ir[read].clone(); } + each(&mut ir[write]); write += 1; read += 1; } @@ -322,199 +379,42 @@ fn collapse_consecutive_hardlines(ir: &mut Vec) { ir.truncate(write); } -// --- - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn test_simple_string() { - let doc = json!("hello"); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert_eq!(ir.len(), 1); - assert!(matches!(&ir[0], EmbeddedIR::Text(s) if s == "hello")); - } - - #[test] - fn test_array() { - let doc = json!(["a", "b"]); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert_eq!(ir.len(), 2); - } - - #[test] - fn test_line_modes() { - let soft = json!({"type": "line", "soft": true}); - let ir = doc_json_to_embedded_ir(&soft).unwrap(); - assert!(matches!(ir[0], EmbeddedIR::Line(LineMode::Soft))); - - let hard = json!({"type": "line", "hard": true}); - let ir = doc_json_to_embedded_ir(&hard).unwrap(); - assert!(matches!(ir[0], EmbeddedIR::Line(LineMode::Hard))); - - let literal = json!({"type": "line", "hard": true, "literal": true}); - let ir = doc_json_to_embedded_ir(&literal).unwrap(); - assert!(matches!(&ir[0], EmbeddedIR::Text(s) if s == "\n")); - assert!(matches!(ir[1], EmbeddedIR::ExpandParent)); - } - - #[test] - fn test_group() { - let doc = json!({"type": "group", "contents": "hello", "break": true, "id": 1}); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert!(matches!(&ir[0], EmbeddedIR::StartGroup { id: Some(1), should_break: true })); - assert!(matches!(&ir[1], EmbeddedIR::Text(s) if s == "hello")); - assert!(matches!(ir[2], EmbeddedIR::EndGroup)); - } - - #[test] - fn test_indent() { - let doc = json!({"type": "indent", "contents": "x"}); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert!(matches!(ir[0], EmbeddedIR::StartIndent)); - assert!(matches!(ir[2], EmbeddedIR::EndIndent)); - } - - #[test] - fn test_if_break_two_branches() { - let doc = json!({ - "type": "if-break", - "breakContents": "broken", - "flatContents": "flat" - }); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - // Break branch - assert!(matches!( - &ir[0], - EmbeddedIR::StartConditionalContent { mode: PrintMode::Expanded, group_id: None } - )); - assert!(matches!(&ir[1], EmbeddedIR::Text(s) if s == "broken")); - assert!(matches!(ir[2], EmbeddedIR::EndConditionalContent)); - // Flat branch - assert!(matches!( - &ir[3], - EmbeddedIR::StartConditionalContent { mode: PrintMode::Flat, group_id: None } - )); - assert!(matches!(&ir[4], EmbeddedIR::Text(s) if s == "flat")); - assert!(matches!(ir[5], EmbeddedIR::EndConditionalContent)); - } - - #[test] - fn test_align_dedent() { - let doc = json!({"type": "align", "n": -1, "contents": "x"}); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert!(matches!(ir[0], EmbeddedIR::StartDedent { to_root: false })); - } - - #[test] - fn test_align_dedent_to_root() { - let doc = json!({"type": "align", "n": "__NEGATIVE_INFINITY__", "contents": "x"}); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert!(matches!(ir[0], EmbeddedIR::StartDedent { to_root: true })); - } - - #[test] - fn test_label_transparent() { - let doc = json!({"type": "label", "label": {"hug": false}, "contents": "inner"}); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert_eq!(ir.len(), 1); - assert!(matches!(&ir[0], EmbeddedIR::Text(s) if s == "inner")); - } - - #[test] - fn test_unknown_type_bail_out() { - let doc = json!({"type": "unknown_thing"}); - assert!(doc_json_to_embedded_ir(&doc).is_err()); - } - - #[test] - fn test_trim_bail_out() { - let doc = json!({"type": "trim"}); - assert!(doc_json_to_embedded_ir(&doc).is_err()); - } - - #[test] - fn test_expanded_states_bail_out() { - let doc = json!({"type": "group", "contents": "", "expandedStates": []}); - assert!(doc_json_to_embedded_ir(&doc).is_err()); - } - - #[test] - fn test_strip_trailing_hardline() { - // hardline = [line(hard), break-parent] - let doc = json!(["hello", {"type": "line", "hard": true}, {"type": "break-parent"}]); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - // Trailing hardline should be stripped - assert_eq!(ir.len(), 1); - assert!(matches!(&ir[0], EmbeddedIR::Text(s) if s == "hello")); - } - - #[test] - fn test_no_strip_when_not_trailing_hardline() { - // Only Line(Hard) without ExpandParent — should not strip - let doc = json!(["hello", {"type": "line", "hard": true}]); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert_eq!(ir.len(), 2); - assert!(matches!(ir[1], EmbeddedIR::Line(LineMode::Hard))); - } - - #[test] - fn test_fill() { - let doc = json!({"type": "fill", "parts": ["a", {"type": "line"}, "b"]}); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert!(matches!(ir[0], EmbeddedIR::StartFill)); - assert!(matches!(ir[1], EmbeddedIR::StartEntry)); - assert!(matches!(&ir[2], EmbeddedIR::Text(s) if s == "a")); - assert!(matches!(ir[3], EmbeddedIR::EndEntry)); - // separator - assert!(matches!(ir[4], EmbeddedIR::StartEntry)); - assert!(matches!(ir[5], EmbeddedIR::Line(LineMode::SoftOrSpace))); - assert!(matches!(ir[6], EmbeddedIR::EndEntry)); - // second content - assert!(matches!(ir[7], EmbeddedIR::StartEntry)); - assert!(matches!(&ir[8], EmbeddedIR::Text(s) if s == "b")); - assert!(matches!(ir[9], EmbeddedIR::EndEntry)); - assert!(matches!(ir[10], EmbeddedIR::EndFill)); - } +/// Escape characters that would break template literal syntax. +/// +/// Equivalent to Prettier's `uncookTemplateElementValue`: +/// `cookedValue.replaceAll(/([\\`]|\$\{)/gu, String.raw`\$1`);` +fn escape_template_characters<'a>(s: &'a str, allocator: &'a Allocator) -> &'a str { + let bytes = s.as_bytes(); + let len = bytes.len(); + + // Fast path: scan for characters that need escaping. + let first_escape = (0..len).find(|&i| { + let ch = bytes[i]; + ch == b'\\' || ch == b'`' || (ch == b'$' && i + 1 < len && bytes[i + 1] == b'{') + }); + + let Some(first) = first_escape else { + return s; + }; - #[test] - fn test_collapse_consecutive_hardlines_to_empty_line() { - // Two hardlines in sequence: [Line(Hard), ExpandParent, Line(Hard), ExpandParent] - // should collapse to [Line(Empty), ExpandParent] - let doc = json!([ - "hello", - {"type": "line", "hard": true}, - {"type": "break-parent"}, - {"type": "line", "hard": true}, - {"type": "break-parent"}, - "world" - ]); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - // "hello" + Line(Empty) + ExpandParent + "world" - assert_eq!(ir.len(), 4); - assert!(matches!(&ir[0], EmbeddedIR::Text(s) if s == "hello")); - assert!(matches!(ir[1], EmbeddedIR::Line(LineMode::Empty))); - assert!(matches!(ir[2], EmbeddedIR::ExpandParent)); - assert!(matches!(&ir[3], EmbeddedIR::Text(s) if s == "world")); + // Slow path: build escaped string in the arena. + let mut result = StringBuilder::with_capacity_in(len + 1, allocator); + result.push_str(&s[..first]); + + let mut i = first; + while i < len { + let ch = bytes[i]; + if ch == b'\\' || ch == b'`' { + result.push('\\'); + result.push(ch as char); + } else if ch == b'$' && i + 1 < len && bytes[i + 1] == b'{' { + result.push_str("\\${"); + i += 1; // skip '{' + } else { + result.push(ch as char); + } + i += 1; } - #[test] - fn test_single_hardline_not_collapsed() { - // Single hardline should remain as-is - let doc = json!([ - "hello", - {"type": "line", "hard": true}, - {"type": "break-parent"}, - "world" - ]); - let ir = doc_json_to_embedded_ir(&doc).unwrap(); - assert_eq!(ir.len(), 4); - assert!(matches!(&ir[0], EmbeddedIR::Text(s) if s == "hello")); - assert!(matches!(ir[1], EmbeddedIR::Line(LineMode::Hard))); - assert!(matches!(ir[2], EmbeddedIR::ExpandParent)); - assert!(matches!(&ir[3], EmbeddedIR::Text(s) if s == "world")); - } + result.into_str() } diff --git a/crates/oxc_formatter/src/external_formatter.rs b/crates/oxc_formatter/src/external_formatter.rs index a6b8a2014ec70..3d21ec6736acc 100644 --- a/crates/oxc_formatter/src/external_formatter.rs +++ b/crates/oxc_formatter/src/external_formatter.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use super::formatter::format_element::{LineMode, PrintMode}; +use oxc_allocator::Allocator; + +use super::formatter::{FormatElement, group_id::UniqueGroupIdBuilder}; /// Callback function type for formatting embedded code. /// Takes (tag_name, code) and returns formatted code or an error. @@ -9,13 +11,24 @@ pub type EmbeddedFormatterCallback = /// Callback function type for formatting embedded code via Doc in batch. /// -/// Takes (tag_name, texts) and returns one `Vec` per input. -/// Used for the Doc→IR path (e.g., `printToDoc` → Doc JSON → `EmbeddedIR`). +/// Takes (allocator, group_id_builder, tag_name, texts) and returns one `Vec>` per input. +/// 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 Result>, String> + Send + Sync>; +pub type EmbeddedDocFormatterCallback = Arc< + dyn for<'a> Fn( + &'a Allocator, + &UniqueGroupIdBuilder, + &str, + &[&str], + ) -> Result>>, String> + + Send + + Sync, +>; /// Callback function type for sorting Tailwind CSS classes. /// Takes classes and returns the sorted versions. @@ -79,23 +92,29 @@ impl ExternalCallbacks { /// Format embedded code as Doc in batch. /// - /// Takes multiple texts and returns one `Vec` per input text. + /// Takes multiple texts and returns one `Vec>` per input text. /// The caller is responsible for interleaving the results with JS expressions. /// /// # 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") /// * `texts` - The code texts to format (multiple quasis for GraphQL, single joined text for CSS/HTML) /// /// # Returns - /// * `Some(Ok(Vec>))` - The formatted code as a vector of `EmbeddedIR` for each input text + /// * `Some(Ok(Vec>>))` - The formatted code as FormatElements for each input text /// * `Some(Err(String))` - An error message if formatting failed /// * `None` - No embedded formatter callback is set - pub fn format_embedded_doc( + pub fn format_embedded_doc<'a>( &self, + allocator: &'a Allocator, + group_id_builder: &UniqueGroupIdBuilder, tag_name: &str, texts: &[&str], - ) -> Option>, String>> { - self.embedded_doc_formatter.as_ref().map(|cb| cb(tag_name, texts)) + ) -> Option>>, String>> { + self.embedded_doc_formatter + .as_ref() + .map(|cb| cb(allocator, group_id_builder, tag_name, texts)) } /// Sort Tailwind CSS classes. @@ -116,58 +135,3 @@ impl ExternalCallbacks { } } } - -// --- - -/// Owned intermediate IR for embedded language formatting. -/// -/// This type bridges the callback boundary between `apps/oxfmt` (or other callers) and `oxc_formatter`. -/// Unlike `FormatElement<'a>`, it has no lifetime parameter and owns all its data, -/// so it can be returned from `Arc` callbacks. -/// -/// The `oxc_formatter` side converts `EmbeddedIR` → `FormatElement<'a>` using the allocator. -#[derive(Debug, Clone)] -pub enum EmbeddedIR { - Space, - HardSpace, - Line(LineMode), - ExpandParent, - /// Owned string (unlike `FormatElement::Text` which borrows from the arena). - Text(String), - LineSuffixBoundary, - // --- Tag equivalents (all fields pub, no lifetime) --- - StartIndent, - EndIndent, - /// Positive integer only. Converted to `Tag::StartAlign(Align(NonZeroU8))`. - StartAlign(u8), - EndAlign, - /// - `to_root: false` → `DedentMode::Level` - /// - `to_root: true` → `DedentMode::Root` - StartDedent { - to_root: bool, - }, - EndDedent { - to_root: bool, - }, - /// `id` is a numeric group ID (mapped to `GroupId` via `HashMap`). - StartGroup { - id: Option, - should_break: bool, - }, - EndGroup, - /// `mode` = Break or Flat, `group_id` references a group by numeric ID. - StartConditionalContent { - mode: PrintMode, - group_id: Option, - }, - EndConditionalContent, - /// GroupId is mandatory (matches `Tag::StartIndentIfGroupBreaks(GroupId)`). - StartIndentIfGroupBreaks(u32), - EndIndentIfGroupBreaks(u32), - StartFill, - EndFill, - StartEntry, - EndEntry, - StartLineSuffix, - EndLineSuffix, -} diff --git a/crates/oxc_formatter/src/formatter/format_element/tag.rs b/crates/oxc_formatter/src/formatter/format_element/tag.rs index 2817ea0234308..9f4125fd7a349 100644 --- a/crates/oxc_formatter/src/formatter/format_element/tag.rs +++ b/crates/oxc_formatter/src/formatter/format_element/tag.rs @@ -158,11 +158,13 @@ impl Group { Self { id: None, mode: Cell::new(GroupMode::Flat) } } + #[must_use] pub fn with_id(mut self, id: Option) -> Self { self.id = id; self } + #[must_use] pub fn with_mode(mut self, mode: GroupMode) -> Self { self.mode = Cell::new(mode); self @@ -208,6 +210,7 @@ impl Condition { Self { mode, group_id: None } } + #[must_use] pub fn with_group_id(mut self, id: Option) -> Self { self.group_id = id; self @@ -226,6 +229,10 @@ impl Condition { pub struct Align(pub(crate) NonZeroU8); impl Align { + pub fn new(count: NonZeroU8) -> Self { + Self(count) + } + pub fn count(&self) -> NonZeroU8 { self.0 } diff --git a/crates/oxc_formatter/src/formatter/formatter.rs b/crates/oxc_formatter/src/formatter/formatter.rs index 13cbc5811d1b6..c01de0e7d3542 100644 --- a/crates/oxc_formatter/src/formatter/formatter.rs +++ b/crates/oxc_formatter/src/formatter/formatter.rs @@ -67,6 +67,12 @@ impl<'buf, 'ast> Formatter<'buf, 'ast> { self.state().group_id(debug_name) } + /// Returns a reference to the unique group id builder for this document. + #[inline] + pub fn group_id_builder(&self) -> &super::UniqueGroupIdBuilder { + self.state().group_id_builder() + } + /// Joins multiple [Format] together without any separator /// /// ## Examples diff --git a/crates/oxc_formatter/src/formatter/group_id.rs b/crates/oxc_formatter/src/formatter/group_id.rs index abd089cb1d962..a1bc150ffdb29 100644 --- a/crates/oxc_formatter/src/formatter/group_id.rs +++ b/crates/oxc_formatter/src/formatter/group_id.rs @@ -66,7 +66,7 @@ impl From for u32 { } /// Builder to construct unique group ids that are unique if created with the same builder. -pub(super) struct UniqueGroupIdBuilder { +pub struct UniqueGroupIdBuilder { next_id: AtomicU32, } diff --git a/crates/oxc_formatter/src/formatter/mod.rs b/crates/oxc_formatter/src/formatter/mod.rs index 27d3c0ccc2c51..4a1553fd86877 100644 --- a/crates/oxc_formatter/src/formatter/mod.rs +++ b/crates/oxc_formatter/src/formatter/mod.rs @@ -41,7 +41,7 @@ use std::fmt::Debug; pub use buffer::{Buffer, BufferExtensions, VecBuffer}; pub use format_element::FormatElement; -pub use group_id::GroupId; +pub use group_id::{GroupId, UniqueGroupIdBuilder}; pub use self::comments::Comments; use self::printer::Printer; @@ -54,7 +54,7 @@ pub use self::{ state::FormatState, text_range::TextRange, }; -use self::{format_element::document::Document, group_id::UniqueGroupIdBuilder, prelude::TagKind}; +use self::{format_element::document::Document, prelude::TagKind}; #[derive(Debug)] pub struct Formatted<'a> { diff --git a/crates/oxc_formatter/src/formatter/state.rs b/crates/oxc_formatter/src/formatter/state.rs index a2112a8d94212..00cb811c312c5 100644 --- a/crates/oxc_formatter/src/formatter/state.rs +++ b/crates/oxc_formatter/src/formatter/state.rs @@ -52,6 +52,11 @@ impl<'ast> FormatState<'ast> { self.group_id_builder.group_id(debug_name) } + /// Returns a reference to the unique group id builder. + pub fn group_id_builder(&self) -> &UniqueGroupIdBuilder { + &self.group_id_builder + } + #[expect(clippy::mutable_key_type)] pub fn printed_interned_elements(&mut self) -> &mut FxHashMap, usize> { &mut self.printed_interned_elements diff --git a/crates/oxc_formatter/src/lib.rs b/crates/oxc_formatter/src/lib.rs index b44cb40715a47..30b22b4898948 100644 --- a/crates/oxc_formatter/src/lib.rs +++ b/crates/oxc_formatter/src/lib.rs @@ -19,15 +19,16 @@ use oxc_span::SourceType; pub use crate::ast_nodes::{AstNode, AstNodes}; pub use crate::external_formatter::{ - EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, EmbeddedIR, ExternalCallbacks, - TailwindCallback, + EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, ExternalCallbacks, TailwindCallback, +}; +pub use crate::formatter::format_element::tag::{ + Align, Condition, DedentMode, Group, GroupMode, Tag, }; -pub use crate::formatter::GroupId; -pub use crate::formatter::format_element::tag::{DedentMode, Tag}; pub use crate::formatter::format_element::{ - BestFittingElement, FormatElement, LineMode, PrintMode, + BestFittingElement, FormatElement, LineMode, PrintMode, TextWidth, }; pub use crate::formatter::{Format, Formatted}; +pub use crate::formatter::{GroupId, UniqueGroupIdBuilder}; pub use crate::ir_transform::options::*; pub use crate::options::*; pub use crate::print::{FormatVueBindingParams, FormatVueScriptGeneric}; diff --git a/crates/oxc_formatter/src/print/template/embed/graphql.rs b/crates/oxc_formatter/src/print/template/embed/graphql.rs index dfa2d486dfff9..da5784c6b3b5c 100644 --- a/crates/oxc_formatter/src/print/template/embed/graphql.rs +++ b/crates/oxc_formatter/src/print/template/embed/graphql.rs @@ -1,19 +1,19 @@ -use rustc_hash::FxHashMap; - +use oxc_allocator::Allocator; use oxc_ast::ast::*; use crate::{ ast_nodes::AstNode, - external_formatter::EmbeddedIR, - formatter::{Formatter, format_element::LineMode, prelude::*}, + formatter::{ + FormatElement, Formatter, + format_element::{LineMode, TextWidth}, + prelude::*, + }, print::template::{ FormatTemplateExpression, FormatTemplateExpressionOptions, TemplateExpression, }, write, }; -use super::write_embedded_ir; - /// Format a GraphQL template literal via the Doc→IR path. /// /// Handles both no-substitution and `${}` templates uniformly. @@ -87,26 +87,30 @@ pub(super) fn format_graphql_doc<'a>( let all_irs = if texts_to_format.is_empty() { vec![] } else { - let Some(Ok(irs)) = f - .context() - .external_callbacks() - .format_embedded_doc("tagged-graphql", &texts_to_format) - 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 { return false; }; irs }; // Phase 3: Build `ir_parts` by mapping formatted results back to original indices. - // Use `into_iter` to take ownership and avoid cloning `Vec`. + // Use `into_iter` to take ownership and avoid cloning. let mut irs_iter = all_irs.into_iter(); - let mut ir_parts: Vec>> = Vec::with_capacity(num_quasis); + let mut ir_parts: Vec>>> = Vec::with_capacity(num_quasis); for (idx, info) in infos.iter().enumerate() { if format_index_map[idx].is_some() { ir_parts.push(irs_iter.next()); } else if info.comments_only { // Build IR for comment-only quasis manually - let comment_ir = build_graphql_comment_ir(info.text); + let comment_ir = + build_graphql_comment_ir(info.text, f.allocator(), f.options().indent_width); ir_parts.push(comment_ir); } else { ir_parts.push(None); @@ -127,15 +131,14 @@ pub(super) fn format_graphql_doc<'a>( // Phase 4: Write the template structure // `["`", indent([hardline, join(hardline, parts)]), hardline, "`"]` // https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/graphql.js#L68C10-L68C73 - let format_content = format_with(|f: &mut Formatter<'_, 'a>| { - let mut group_id_map = FxHashMap::default(); + let format_content = format_once(|f: &mut Formatter<'_, 'a>| { let mut has_prev_part = false; - for (idx, maybe_ir) in ir_parts.iter().enumerate() { + for (idx, mut maybe_ir) in ir_parts.into_iter().enumerate() { let is_first = idx == 0; let is_last = idx == num_quasis - 1; - if let Some(ir) = maybe_ir { + if let Some(ir) = maybe_ir.take() { if !is_first && infos[idx].starts_with_blank_line { if has_prev_part { write!(f, [empty_line()]); @@ -143,7 +146,7 @@ pub(super) fn format_graphql_doc<'a>( } else if has_prev_part { write!(f, [hard_line_break()]); } - write_embedded_ir(ir, f, &mut group_id_map); + f.write_elements(ir); has_prev_part = true; } else if !is_first && !is_last && infos[idx].starts_with_blank_line && has_prev_part { write!(f, [empty_line()]); @@ -188,9 +191,14 @@ struct QuasiInfo<'a> { /// /// /// Extracts comment lines, joins with hardline, and preserves blank lines between comment groups. -fn build_graphql_comment_ir(text: &str) -> Option> { +fn build_graphql_comment_ir<'a>( + text: &str, + allocator: &'a Allocator, + indent_width: crate::IndentWidth, +) -> Option>> { + // This comes from `.cooked`, which has normalized line terminators let lines: Vec<&str> = text.split('\n').map(str::trim).collect(); - let mut parts: Vec = vec![]; + let mut parts: Vec> = vec![]; let mut seen_comment = false; for (i, line) in lines.iter().enumerate() { @@ -199,16 +207,20 @@ fn build_graphql_comment_ir(text: &str) -> Option> { } if i > 0 && lines[i - 1].is_empty() && seen_comment { - // Blank line before this comment group → emit empty line + text - parts.push(EmbeddedIR::Line(LineMode::Empty)); - parts.push(EmbeddedIR::ExpandParent); - parts.push(EmbeddedIR::Text((*line).to_string())); + // Blank line before this comment group -> emit empty line + text + parts.push(FormatElement::Line(LineMode::Empty)); + parts.push(FormatElement::ExpandParent); + let arena_text = allocator.alloc_str(line); + let width = TextWidth::from_text(arena_text, indent_width); + parts.push(FormatElement::Text { text: arena_text, width }); } else { if seen_comment { - parts.push(EmbeddedIR::Line(LineMode::Hard)); - parts.push(EmbeddedIR::ExpandParent); + parts.push(FormatElement::Line(LineMode::Hard)); + parts.push(FormatElement::ExpandParent); } - parts.push(EmbeddedIR::Text((*line).to_string())); + let arena_text = allocator.alloc_str(line); + let width = TextWidth::from_text(arena_text, indent_width); + parts.push(FormatElement::Text { text: arena_text, width }); } seen_comment = true; diff --git a/crates/oxc_formatter/src/print/template/embed/mod.rs b/crates/oxc_formatter/src/print/template/embed/mod.rs index acb36bddfe553..474bd57af2a6e 100644 --- a/crates/oxc_formatter/src/print/template/embed/mod.rs +++ b/crates/oxc_formatter/src/print/template/embed/mod.rs @@ -1,21 +1,12 @@ mod graphql; -use std::num::NonZeroU8; - -use rustc_hash::FxHashMap; - use oxc_allocator::{Allocator, StringBuilder}; use oxc_ast::ast::*; use oxc_syntax::line_terminator::LineTerminatorSplitter; use crate::{ ast_nodes::{AstNode, AstNodes}, - external_formatter::EmbeddedIR, - formatter::{ - FormatElement, Formatter, GroupId, - format_element::{TextWidth, tag}, - prelude::*, - }, + formatter::{Formatter, prelude::*}, write, }; @@ -279,149 +270,3 @@ fn dedent<'a>(text: &'a str, allocator: &'a Allocator) -> &'a str { result.into_str() } - -// --- - -/// Write a sequence of `EmbeddedIR` elements into the formatter buffer, -/// converting each to the corresponding `FormatElement<'a>`. -pub(super) fn write_embedded_ir( - ir: &[EmbeddedIR], - f: &mut Formatter<'_, '_>, - group_id_map: &mut FxHashMap, -) { - let indent_width = f.options().indent_width; - for item in ir { - match item { - EmbeddedIR::Space => f.write_element(FormatElement::Space), - EmbeddedIR::HardSpace => f.write_element(FormatElement::HardSpace), - EmbeddedIR::Line(mode) => f.write_element(FormatElement::Line(*mode)), - EmbeddedIR::ExpandParent => f.write_element(FormatElement::ExpandParent), - EmbeddedIR::Text(s) => { - // Escape template characters to avoid breaking template literal syntax - let escaped = escape_template_characters(s, f.allocator()); - let width = TextWidth::from_text(escaped, indent_width); - f.write_element(FormatElement::Text { text: escaped, width }); - } - EmbeddedIR::LineSuffixBoundary => { - f.write_element(FormatElement::LineSuffixBoundary); - } - EmbeddedIR::StartIndent => { - f.write_element(FormatElement::Tag(tag::Tag::StartIndent)); - } - EmbeddedIR::EndIndent => { - f.write_element(FormatElement::Tag(tag::Tag::EndIndent)); - } - EmbeddedIR::StartAlign(n) => { - if let Some(nz) = NonZeroU8::new(*n) { - f.write_element(FormatElement::Tag(tag::Tag::StartAlign(tag::Align(nz)))); - } - } - EmbeddedIR::EndAlign => { - f.write_element(FormatElement::Tag(tag::Tag::EndAlign)); - } - EmbeddedIR::StartDedent { to_root } => { - let mode = if *to_root { tag::DedentMode::Root } else { tag::DedentMode::Level }; - f.write_element(FormatElement::Tag(tag::Tag::StartDedent(mode))); - } - EmbeddedIR::EndDedent { to_root } => { - let mode = if *to_root { tag::DedentMode::Root } else { tag::DedentMode::Level }; - f.write_element(FormatElement::Tag(tag::Tag::EndDedent(mode))); - } - EmbeddedIR::StartGroup { id, should_break } => { - let gid = id.map(|n| resolve_group_id(n, group_id_map, f)); - let mode = - if *should_break { tag::GroupMode::Expand } else { tag::GroupMode::Flat }; - f.write_element(FormatElement::Tag(tag::Tag::StartGroup( - tag::Group::new().with_id(gid).with_mode(mode), - ))); - } - EmbeddedIR::EndGroup => { - f.write_element(FormatElement::Tag(tag::Tag::EndGroup)); - } - EmbeddedIR::StartConditionalContent { mode, group_id } => { - let gid = group_id.map(|n| resolve_group_id(n, group_id_map, f)); - f.write_element(FormatElement::Tag(tag::Tag::StartConditionalContent( - tag::Condition::new(*mode).with_group_id(gid), - ))); - } - EmbeddedIR::EndConditionalContent => { - f.write_element(FormatElement::Tag(tag::Tag::EndConditionalContent)); - } - EmbeddedIR::StartIndentIfGroupBreaks(id) => { - let gid = resolve_group_id(*id, group_id_map, f); - f.write_element(FormatElement::Tag(tag::Tag::StartIndentIfGroupBreaks(gid))); - } - EmbeddedIR::EndIndentIfGroupBreaks(id) => { - let gid = resolve_group_id(*id, group_id_map, f); - f.write_element(FormatElement::Tag(tag::Tag::EndIndentIfGroupBreaks(gid))); - } - EmbeddedIR::StartFill => { - f.write_element(FormatElement::Tag(tag::Tag::StartFill)); - } - EmbeddedIR::EndFill => { - f.write_element(FormatElement::Tag(tag::Tag::EndFill)); - } - EmbeddedIR::StartEntry => { - f.write_element(FormatElement::Tag(tag::Tag::StartEntry)); - } - EmbeddedIR::EndEntry => { - f.write_element(FormatElement::Tag(tag::Tag::EndEntry)); - } - EmbeddedIR::StartLineSuffix => { - f.write_element(FormatElement::Tag(tag::Tag::StartLineSuffix)); - } - EmbeddedIR::EndLineSuffix => { - f.write_element(FormatElement::Tag(tag::Tag::EndLineSuffix)); - } - } - } -} - -/// Look up or create a `GroupId` for the given numeric ID. -fn resolve_group_id(id: u32, map: &mut FxHashMap, f: &Formatter<'_, '_>) -> GroupId { - *map.entry(id).or_insert_with(|| f.group_id("embedded")) -} - -/// Escape characters that would break template literal syntax. -/// -/// Equivalent to Prettier's `uncookTemplateElementValue`: -/// `cookedValue.replaceAll(/([\\`]|\$\{)/gu, String.raw`\$1`);` -/// -/// -/// Returns the original string (arena-copied) when no escaping is needed, -/// avoiding a temporary `String` allocation. -fn escape_template_characters<'a>(s: &str, allocator: &'a Allocator) -> &'a str { - let bytes = s.as_bytes(); - let len = bytes.len(); - - // Fast path: scan for characters that need escaping. - let first_escape = (0..len).find(|&i| { - let ch = bytes[i]; - ch == b'\\' || ch == b'`' || (ch == b'$' && i + 1 < len && bytes[i + 1] == b'{') - }); - - let Some(first) = first_escape else { - return allocator.alloc_str(s); - }; - - // Slow path: build escaped string in the arena. - let mut result = StringBuilder::with_capacity_in(len + 1, allocator); - result.push_str(&s[..first]); - - let mut i = first; - while i < len { - let ch = bytes[i]; - if ch == b'\\' || ch == b'`' { - result.push('\\'); - result.push(ch as char); - } else if ch == b'$' && i + 1 < len && bytes[i + 1] == b'{' { - result.push_str("\\${"); - i += 1; // skip '{' - } else { - result.push(ch as char); - } - i += 1; - } - - result.into_str() -}