diff --git a/apps/oxfmt/cmp.js b/apps/oxfmt/cmp.js new file mode 100644 index 0000000000000..c8c547c2d8985 --- /dev/null +++ b/apps/oxfmt/cmp.js @@ -0,0 +1,79 @@ +// oxlint-disable + +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import * as prettier from "prettier"; +import { format as oxfmtFormat } from "./dist/index.js"; + +const FIXTURES_DIR = join( + import.meta.dirname, + "../../tasks/prettier_conformance/prettier/tests/format/js/multiparser-graphql", +); + +const EXCLUDE = new Set([ + "format.test.js", + "comment-tag.js", // /* GraphQL */ comment tag (not yet supported) + "expressions.js", // graphql() function call pattern (not yet supported) + "graphql.js", // graphql() function call pattern (not yet supported) +]); + +const files = (await readdir(FIXTURES_DIR)) + .filter((f) => f.endsWith(".js") && !EXCLUDE.has(f)) + .sort(); + +let matchCount = 0; +let mismatchCount = 0; +let errorCount = 0; + +for (const file of files) { + const filePath = join(FIXTURES_DIR, file); + const source = await readFile(filePath, "utf8"); + + const prettierOutput = await prettier.format(source, { + parser: "babel", + printWidth: 80, + }); + + let oxfmtOutput; + try { + const oxfmtResult = await oxfmtFormat(file, source, { printWidth: 80 }); + oxfmtOutput = oxfmtResult.code; + } catch (e) { + console.log(`✗ ${file} (ERROR: ${e.message})`); + errorCount++; + continue; + } + + if (prettierOutput === oxfmtOutput) { + console.log(`✓ ${file}`); + matchCount++; + } else { + console.log(`✗ ${file}`); + mismatchCount++; + printUnifiedDiff(prettierOutput, oxfmtOutput); + } +} + +console.log(`\n--- Summary ---`); +console.log( + `Match: ${matchCount}, Mismatch: ${mismatchCount}, Error: ${errorCount}, Total: ${files.length}`, +); + +function printUnifiedDiff(expected, actual) { + const expectedLines = expected.split("\n"); + const actualLines = actual.split("\n"); + console.log(" --- prettier"); + console.log(" +++ oxfmt"); + const maxLen = Math.max(expectedLines.length, actualLines.length); + for (let i = 0; i < maxLen; i++) { + const e = expectedLines[i]; + const a = actualLines[i]; + if (e === a) { + console.log(` ${e ?? ""}`); + } else { + if (e !== undefined) console.log(` -${e}`); + if (a !== undefined) console.log(` +${a}`); + } + } + console.log(); +} diff --git a/apps/oxfmt/src-js/bindings.d.ts b/apps/oxfmt/src-js/bindings.d.ts index 6b35cca5eb9ff..b1d213313a8f9 100644 --- a/apps/oxfmt/src-js/bindings.d.ts +++ b/apps/oxfmt/src-js/bindings.d.ts @@ -34,7 +34,7 @@ export declare const enum Severity { * # Panics * Panics if the current working directory cannot be determined. */ -export declare function format(filename: string, sourceText: string, options: any | undefined | null, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise +export declare function format(filename: string, sourceText: string, options: any | undefined | null, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatEmbeddedDocCb: (options: Record, texts: string[]) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise export interface FormatResult { /** The formatted code. */ @@ -49,7 +49,7 @@ export interface FormatResult { * This API is specialized for JS/TS snippets embedded in non-JS files. * Unlike `format()`, it is called only for js-in-xxx `textToDoc()` flow. */ -export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise +export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmtPluginOptionsJson: string, parentContext: string, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatEmbeddedDocCb: (options: Record, texts: string[]) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindClassesCb: (options: Record, classes: string[]) => Promise): Promise /** * NAPI based JS CLI entry point. @@ -66,4 +66,4 @@ export declare function jsTextToDoc(sourceExt: string, sourceText: string, oxfmt * - `mode`: If main logic will run in JS side, use this to indicate which mode * - `exitCode`: If main logic already ran in Rust side, return the exit code */ -export declare function runCli(args: Array, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindcssClassesCb: (options: Record, classes: string[]) => Promise): Promise<[string, number | undefined | null]> +export declare function runCli(args: Array, initExternalFormatterCb: (numThreads: number) => Promise, formatEmbeddedCb: (options: Record, code: string) => Promise, formatEmbeddedDocCb: (options: Record, texts: string[]) => Promise, formatFileCb: (options: Record, code: string) => Promise, sortTailwindcssClassesCb: (options: Record, classes: string[]) => Promise): Promise<[string, number | undefined | null]> diff --git a/apps/oxfmt/src-js/cli-worker.ts b/apps/oxfmt/src-js/cli-worker.ts index 1ae639fd51a9d..530f5db5d9c04 100644 --- a/apps/oxfmt/src-js/cli-worker.ts +++ b/apps/oxfmt/src-js/cli-worker.ts @@ -1,4 +1,9 @@ // `oxfmt` CLI - Worker Thread Entry Point // Re-exports core functions for use in `worker_threads` -export { formatEmbeddedCode, formatFile, sortTailwindClasses } from "./libs/apis"; +export { + formatEmbeddedCode, + formatEmbeddedDoc, + formatFile, + sortTailwindClasses, +} from "./libs/apis"; diff --git a/apps/oxfmt/src-js/cli.ts b/apps/oxfmt/src-js/cli.ts index 6ab1489fb9664..8ca3107b4d873 100644 --- a/apps/oxfmt/src-js/cli.ts +++ b/apps/oxfmt/src-js/cli.ts @@ -2,6 +2,7 @@ import { runCli } from "./bindings"; import { initExternalFormatter, formatEmbeddedCode, + formatEmbeddedDoc, formatFile, sortTailwindClasses, disposeExternalFormatter, @@ -28,6 +29,7 @@ void (async () => { args, initExternalFormatter, formatEmbeddedCode, + formatEmbeddedDoc, formatFile, sortTailwindClasses, ); diff --git a/apps/oxfmt/src-js/cli/worker-proxy.ts b/apps/oxfmt/src-js/cli/worker-proxy.ts index affab989513fc..5c7ac947b4126 100644 --- a/apps/oxfmt/src-js/cli/worker-proxy.ts +++ b/apps/oxfmt/src-js/cli/worker-proxy.ts @@ -2,6 +2,7 @@ import Tinypool from "tinypool"; import { resolvePlugins } from "../libs/apis"; import type { FormatEmbeddedCodeParam, + FormatEmbeddedDocParam, FormatFileParam, SortTailwindClassesArgs, } from "../libs/apis"; @@ -40,6 +41,17 @@ export async function formatEmbeddedCode( .catch(rethrowAsError); } +export async function formatEmbeddedDoc( + options: FormatEmbeddedDocParam["options"], + texts: string[], +): Promise { + return pool! + .run({ options, texts } satisfies FormatEmbeddedDocParam, { + name: "formatEmbeddedDoc", + }) + .catch(rethrowAsError); +} + export async function formatFile( options: FormatFileParam["options"], code: string, diff --git a/apps/oxfmt/src-js/index.ts b/apps/oxfmt/src-js/index.ts index 98ecd6fec9d09..f70ff9e43ff74 100644 --- a/apps/oxfmt/src-js/index.ts +++ b/apps/oxfmt/src-js/index.ts @@ -1,5 +1,11 @@ import { format as napiFormat, jsTextToDoc as napiJsTextToDoc } from "./bindings"; -import { resolvePlugins, formatEmbeddedCode, formatFile, sortTailwindClasses } from "./libs/apis"; +import { + resolvePlugins, + formatEmbeddedCode, + formatEmbeddedDoc, + formatFile, + sortTailwindClasses, +} from "./libs/apis"; import type { Options } from "prettier"; // napi-JS `oxfmt` API entry point @@ -18,6 +24,7 @@ export async function format(fileName: string, sourceText: string, options?: For options ?? {}, resolvePlugins, (options, code) => formatEmbeddedCode({ options, code }), + (options, texts) => formatEmbeddedDoc({ options, texts }), (options, code) => formatFile({ options, code }), (options, classes) => sortTailwindClasses({ options, classes }), ); @@ -39,6 +46,7 @@ export async function jsTextToDoc( parentContext, resolvePlugins, (options, code) => formatEmbeddedCode({ options, code }), + (options, texts) => formatEmbeddedDoc({ options, texts }), (_options, _code) => Promise.reject(/* Unreachable */), (options, classes) => sortTailwindClasses({ options, classes }), ); diff --git a/apps/oxfmt/src-js/libs/apis.ts b/apps/oxfmt/src-js/libs/apis.ts index 540fd6766c070..32379d77de932 100644 --- a/apps/oxfmt/src-js/libs/apis.ts +++ b/apps/oxfmt/src-js/libs/apis.ts @@ -50,11 +50,12 @@ export type FormatEmbeddedCodeParam = { }; /** - * Format xxx-in-js code snippets + * Format xxx-in-js code snippets into formatted string. + * + * This will be gradually replaced by `formatEmbeddedDoc` which returns `Doc`. + * For now, html|css|md-in-js are using this. * * @returns Formatted code snippet - * TODO: In the future, this should return `Doc` instead of string, - * otherwise, we cannot calculate `printWidth` correctly. */ export async function formatEmbeddedCode({ code, @@ -74,6 +75,60 @@ export async function formatEmbeddedCode({ // --- +export type FormatEmbeddedDocParam = { + texts: string[]; + options: Options; +}; + +/** + * Format xxx-in-js code snippets into Prettier `Doc` JSON strings. + * + * This makes `oxc_formatter` correctly handle `printWidth` even for embedded code. + * - For gql-in-js, `texts` contains multiple parts split by `${}` in a template literal + * - For others, `texts` always contains a single string with `${}` parts replaced by placeholders + * However, this function does not need to be aware of that, + * as it simply formats each text part independently and returns an array of formatted parts. + * + * @returns Doc JSON strings (one per input text) + */ +export async function formatEmbeddedDoc({ + texts, + options, +}: FormatEmbeddedDocParam): Promise { + const prettier = await loadPrettier(); + + // Enable Tailwind CSS plugin for embedded code (e.g., html`...` in JS) if needed + await setupTailwindPlugin(options); + + // NOTE: This will throw if: + // - Specified parser is not available + // - Or, code has syntax errors + // In such cases, Rust side will fallback to original code + return Promise.all( + texts.map(async (text) => { + // @ts-expect-error: Use internal API, but it's necessary and only way to get `Doc` + const doc = await prettier.__debug.printToDoc(text, options); + + // Serialize Doc to JSON, handling special values in a single pass: + // - Symbol group IDs (used by `group`, `if-break`, `indent-if-break`) → numeric counters + // - -Infinity (used by `dedentToRoot` via `align`) → marker string + const symbolToNumber = new Map(); + let nextId = 1; + + return JSON.stringify(doc, (_key, value) => { + if (typeof value === "symbol") { + if (!symbolToNumber.has(value)) symbolToNumber.set(value, nextId++); + return symbolToNumber.get(value); + } + if (value === -Infinity) return "__NEGATIVE_INFINITY__"; + return value; + }); + }), + ); +} + +// --- + export type FormatFileParam = { code: string; options: Options; diff --git a/apps/oxfmt/src/api/format_api.rs b/apps/oxfmt/src/api/format_api.rs index f03f02e43d4e1..dbf3b6d59df1d 100644 --- a/apps/oxfmt/src/api/format_api.rs +++ b/apps/oxfmt/src/api/format_api.rs @@ -5,8 +5,8 @@ use serde_json::Value; use oxc_napi::OxcError; use crate::core::{ - ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, JsFormatFileCb, - JsInitExternalFormatterCb, JsSortTailwindClassesCb, SourceFormatter, + ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, + JsFormatFileCb, JsInitExternalFormatterCb, JsSortTailwindClassesCb, SourceFormatter, resolve_options_from_value, }; @@ -25,6 +25,7 @@ pub fn run( options: Option, init_external_formatter_cb: JsInitExternalFormatterCb, format_embedded_cb: JsFormatEmbeddedCb, + format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_file_cb: JsFormatFileCb, sort_tailwind_classes_cb: JsSortTailwindClassesCb, ) -> ApiFormatResult { @@ -37,6 +38,7 @@ pub fn run( let external_formatter = ExternalFormatter::new( init_external_formatter_cb, format_embedded_cb, + format_embedded_doc_cb, format_file_cb, sort_tailwind_classes_cb, ); diff --git a/apps/oxfmt/src/api/text_to_doc_api.rs b/apps/oxfmt/src/api/text_to_doc_api.rs index 728a58da2e89e..2642d96aa72e8 100644 --- a/apps/oxfmt/src/api/text_to_doc_api.rs +++ b/apps/oxfmt/src/api/text_to_doc_api.rs @@ -14,9 +14,9 @@ use oxc_span::SourceType; use crate::{ core::{ - ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, JsFormatFileCb, - JsInitExternalFormatterCb, JsSortTailwindClassesCb, ResolvedOptions, SourceFormatter, - resolve_options_from_value, + ExternalFormatter, FormatFileStrategy, FormatResult, JsFormatEmbeddedCb, + JsFormatEmbeddedDocCb, JsFormatFileCb, JsInitExternalFormatterCb, JsSortTailwindClassesCb, + ResolvedOptions, SourceFormatter, resolve_options_from_value, }, prettier_compat::to_prettier_doc, }; @@ -52,6 +52,7 @@ pub fn run( parent_context: &str, init_external_formatter_cb: JsInitExternalFormatterCb, format_embedded_cb: JsFormatEmbeddedCb, + format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_file_cb: JsFormatFileCb, sort_tailwind_classes_cb: JsSortTailwindClassesCb, ) -> Option { @@ -72,6 +73,7 @@ pub fn run( oxfmt_plugin_options_json, init_external_formatter_cb, format_embedded_cb, + format_embedded_doc_cb, format_file_cb, sort_tailwind_classes_cb, )? @@ -92,6 +94,7 @@ fn run_full( oxfmt_plugin_options_json: &str, init_external_formatter_cb: JsInitExternalFormatterCb, format_embedded_cb: JsFormatEmbeddedCb, + format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_file_cb: JsFormatFileCb, sort_tailwind_classes_cb: JsSortTailwindClassesCb, ) -> Option { @@ -105,6 +108,7 @@ fn run_full( let external_formatter = ExternalFormatter::new( init_external_formatter_cb, format_embedded_cb, + format_embedded_doc_cb, format_file_cb, sort_tailwind_classes_cb, ); diff --git a/apps/oxfmt/src/core/external_formatter.rs b/apps/oxfmt/src/core/external_formatter.rs index 31bbb8816c8b4..18dff8919725a 100644 --- a/apps/oxfmt/src/core/external_formatter.rs +++ b/apps/oxfmt/src/core/external_formatter.rs @@ -9,9 +9,12 @@ use serde_json::Value; use tracing::debug_span; use oxc_formatter::{ - EmbeddedFormatterCallback, ExternalCallbacks, FormatOptions, TailwindCallback, + EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, ExternalCallbacks, FormatOptions, + TailwindCallback, }; +use crate::prettier_compat::from_prettier_doc; + /// Type alias for the init external formatter callback function signature. /// Takes num_threads as argument and returns plugin languages. pub type JsInitExternalFormatterCb = ThreadsafeFunction< @@ -43,6 +46,22 @@ pub type JsFormatEmbeddedCb = ThreadsafeFunction< false, >; +/// Type alias for the Doc-path callback function signature (batch). +/// Takes (options, texts[]) as arguments and returns Doc JSON string[] (one per text). +/// The `options` object includes `parser` field set by Rust side. +pub type JsFormatEmbeddedDocCb = ThreadsafeFunction< + // Input arguments + FnArgs<(Value, Vec)>, // (options, texts) + // Return type (what JS function returns) + Promise>, + // Arguments (repeated) + FnArgs<(Value, Vec)>, + // Error status + Status, + // CalleeHandled + false, +>; + /// Type alias for the callback function signature. /// Takes (options, code) as arguments and returns formatted code. /// The `options` object includes `parser` and `filepath` fields set by Rust side. @@ -80,6 +99,7 @@ pub type JsSortTailwindClassesCb = ThreadsafeFunction< struct TsfnHandles { init: Arc>>, format_embedded: Arc>>, + format_embedded_doc: Arc>>, format_file: Arc>>, sort_tailwind: Arc>>, } @@ -89,6 +109,7 @@ impl TsfnHandles { fn cleanup(&self) { let _ = self.init.write().unwrap().take(); let _ = self.format_embedded.write().unwrap().take(); + let _ = self.format_embedded_doc.write().unwrap().take(); let _ = self.format_file.write().unwrap().take(); let _ = self.sort_tailwind.write().unwrap().take(); } @@ -100,6 +121,11 @@ impl TsfnHandles { type FormatEmbeddedWithConfigCallback = Arc Result + Send + Sync>; +/// Callback function type for formatting embedded code via Doc IR path (batch). +/// Takes (options, texts) and returns Doc JSON strings (one per text) or an error. +type FormatEmbeddedDocWithConfigCallback = + Arc Result, String> + Send + Sync>; + /// Callback function type for formatting files with config. /// Takes (options, code) and returns formatted code or an error. /// The `options` Value is owned and includes `parser` and `filepath` set by the caller. @@ -123,6 +149,7 @@ pub struct ExternalFormatter { handles: TsfnHandles, pub init: InitExternalFormatterCallback, pub format_embedded: FormatEmbeddedWithConfigCallback, + pub format_embedded_doc: FormatEmbeddedDocWithConfigCallback, pub format_file: FormatFileWithConfigCallback, pub sort_tailwindcss_classes: TailwindWithConfigCallback, } @@ -132,6 +159,7 @@ impl std::fmt::Debug for ExternalFormatter { f.debug_struct("ExternalFormatter") .field("init", &"") .field("format_embedded", &"") + .field("format_embedded_doc", &"") .field("format_file", &"") .field("sort_tailwindcss_classes", &"") .finish() @@ -147,12 +175,14 @@ impl ExternalFormatter { pub fn new( init_cb: JsInitExternalFormatterCb, format_embedded_cb: JsFormatEmbeddedCb, + format_embedded_doc_cb: JsFormatEmbeddedDocCb, format_file_cb: JsFormatFileCb, sort_tailwindcss_classes_cb: JsSortTailwindClassesCb, ) -> Self { // Wrap TSFNs in Arc>> so they can be explicitly dropped let init_handle = Arc::new(RwLock::new(Some(init_cb))); let format_embedded_handle = Arc::new(RwLock::new(Some(format_embedded_cb))); + let format_embedded_doc_handle = Arc::new(RwLock::new(Some(format_embedded_doc_cb))); let format_file_handle = Arc::new(RwLock::new(Some(format_file_cb))); let sort_tailwind_handle = Arc::new(RwLock::new(Some(sort_tailwindcss_classes_cb))); @@ -160,18 +190,21 @@ impl ExternalFormatter { let handles = TsfnHandles { init: Arc::clone(&init_handle), format_embedded: Arc::clone(&format_embedded_handle), + format_embedded_doc: Arc::clone(&format_embedded_doc_handle), format_file: Arc::clone(&format_file_handle), sort_tailwind: Arc::clone(&sort_tailwind_handle), }; let rust_init = wrap_init_external_formatter(init_handle); - let rust_format_embedded = wrap_format_embedded(format_embedded_handle); + let rust_format_embedded = wrap_format_embedded(Arc::clone(&format_embedded_handle)); + let rust_format_embedded_doc = wrap_format_embedded_doc(format_embedded_doc_handle); let rust_format_file = wrap_format_file(format_file_handle); let rust_tailwind = wrap_sort_tailwind_classes(sort_tailwind_handle); Self { handles, init: rust_init, format_embedded: rust_format_embedded, + format_embedded_doc: rust_format_embedded_doc, format_file: rust_format_file, sort_tailwindcss_classes: rust_tailwind, } @@ -240,6 +273,44 @@ impl ExternalFormatter { None }; + 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]| { + let Some(parser_name) = language_to_prettier_parser(language) else { + return Err(format!("Unsupported language: {language}")); + }; + debug_span!("oxfmt::external::format_embedded_doc", parser = parser_name) + .in_scope(|| { + let mut options = options_for_doc.clone(); + if let Value::Object(ref mut map) = options { + map.insert( + "parser".to_string(), + Value::String(parser_name.to_string()), + ); + } + let doc_json_strs = + (format_embedded_doc)(options, texts).map_err(|err| { + format!( + "Failed to get Doc for embedded code (parser '{parser_name}'): {err}" + ) + })?; + 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::doc_json_to_embedded_ir(&doc_json) + }) + .collect() + }) + })) + } else { + None + }; + let needs_tailwind = format_options.sort_tailwindcss.is_some(); let tailwind_callback: Option = if needs_tailwind { let sort_tailwindcss_classes = Arc::clone(&self.sort_tailwindcss_classes); @@ -253,6 +324,7 @@ impl ExternalFormatter { ExternalCallbacks::new() .with_embedded_formatter(embedded_callback) + .with_embedded_doc_formatter(embedded_doc_callback) .with_tailwind(tailwind_callback) } @@ -270,11 +342,15 @@ impl ExternalFormatter { handles: TsfnHandles { init: Arc::new(RwLock::new(None)), format_embedded: Arc::new(RwLock::new(None)), + format_embedded_doc: Arc::new(RwLock::new(None)), format_file: Arc::new(RwLock::new(None)), sort_tailwind: Arc::new(RwLock::new(None)), }, init: Arc::new(|_| Err("Dummy init called".to_string())), format_embedded: Arc::new(|_, _| Err("Dummy format_embedded called".to_string())), + format_embedded_doc: Arc::new(|_, _: &[&str]| { + Err("Dummy format_embedded_doc called".to_string()) + }), format_file: Arc::new(|_, _| Err("Dummy format_file called".to_string())), sort_tailwindcss_classes: Arc::new(|_, _| vec![]), } @@ -362,6 +438,32 @@ fn wrap_format_embedded( }) } +/// Wrap JS `formatEmbeddedDoc` callback as a normal Rust function (batch). +/// The `options` Value is received with `parser` already set by the caller. +fn wrap_format_embedded_doc( + cb_handle: Arc>>, +) -> FormatEmbeddedDocWithConfigCallback { + Arc::new(move |options: Value, texts: &[&str]| { + let guard = cb_handle.read().unwrap(); + let Some(cb) = guard.as_ref() else { + return Err("JS callback unavailable (environment shutting down)".to_string()); + }; + let texts_owned: Vec = texts.iter().map(|t| (*t).to_string()).collect(); + let result = block_on(async { + let status = cb.call_async(FnArgs::from((options, texts_owned))).await; + match status { + Ok(promise) => match promise.await { + Ok(doc_jsons) => Ok(doc_jsons), + Err(err) => Err(err.reason.clone()), + }, + Err(err) => Err(err.reason.clone()), + } + }); + drop(guard); + result + }) +} + /// Wrap JS `formatFile` callback as a normal Rust function. /// The `options` Value is received with `parser` and `filepath` already set by the caller. fn wrap_format_file( diff --git a/apps/oxfmt/src/core/mod.rs b/apps/oxfmt/src/core/mod.rs index 8b96e2a57d992..4230b537f11e9 100644 --- a/apps/oxfmt/src/core/mod.rs +++ b/apps/oxfmt/src/core/mod.rs @@ -17,6 +17,6 @@ pub use support::FormatFileStrategy; #[cfg(feature = "napi")] pub use external_formatter::{ - ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb, JsInitExternalFormatterCb, - JsSortTailwindClassesCb, + ExternalFormatter, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb, + JsInitExternalFormatterCb, JsSortTailwindClassesCb, }; diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index 722c5ab24547b..5c09d807ee085 100644 --- a/apps/oxfmt/src/main_napi.rs +++ b/apps/oxfmt/src/main_napi.rs @@ -9,8 +9,8 @@ use crate::{ api::{format_api, text_to_doc_api}, cli::{FormatRunner, MigrateSource, Mode, format_command, init_miette, init_rayon}, core::{ - ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb, JsInitExternalFormatterCb, - JsSortTailwindClassesCb, utils, + ExternalFormatter, JsFormatEmbeddedCb, JsFormatEmbeddedDocCb, JsFormatFileCb, + JsInitExternalFormatterCb, JsSortTailwindClassesCb, utils, }, lsp::run_lsp, stdin::StdinRunner, @@ -38,6 +38,8 @@ pub async fn run_cli( init_external_formatter_cb: JsInitExternalFormatterCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_embedded_cb: JsFormatEmbeddedCb, + #[napi(ts_arg_type = "(options: Record, texts: string[]) => Promise")] + format_embedded_doc_cb: JsFormatEmbeddedDocCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_file_cb: JsFormatFileCb, #[napi(ts_arg_type = "(options: Record, classes: string[]) => Promise")] @@ -77,6 +79,7 @@ pub async fn run_cli( let external_formatter = ExternalFormatter::new( init_external_formatter_cb, format_embedded_cb, + format_embedded_doc_cb, format_file_cb, sort_tailwindcss_classes_cb, ); @@ -142,6 +145,8 @@ pub async fn format( init_external_formatter_cb: JsInitExternalFormatterCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_embedded_cb: JsFormatEmbeddedCb, + #[napi(ts_arg_type = "(options: Record, texts: string[]) => Promise")] + format_embedded_doc_cb: JsFormatEmbeddedDocCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_file_cb: JsFormatFileCb, #[napi(ts_arg_type = "(options: Record, classes: string[]) => Promise")] @@ -153,6 +158,7 @@ pub async fn format( options, init_external_formatter_cb, format_embedded_cb, + format_embedded_doc_cb, format_file_cb, sort_tailwind_classes_cb, ); @@ -178,6 +184,8 @@ pub async fn js_text_to_doc( init_external_formatter_cb: JsInitExternalFormatterCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_embedded_cb: JsFormatEmbeddedCb, + #[napi(ts_arg_type = "(options: Record, texts: string[]) => Promise")] + format_embedded_doc_cb: JsFormatEmbeddedDocCb, #[napi(ts_arg_type = "(options: Record, code: string) => Promise")] format_file_cb: JsFormatFileCb, #[napi(ts_arg_type = "(options: Record, classes: string[]) => Promise")] @@ -192,6 +200,7 @@ pub async fn js_text_to_doc( &parent_context, init_external_formatter_cb, format_embedded_cb, + format_embedded_doc_cb, format_file_cb, sort_tailwind_classes_cb, ) diff --git a/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs b/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs new file mode 100644 index 0000000000000..25939a7f42083 --- /dev/null +++ b/apps/oxfmt/src/prettier_compat/from_prettier_doc.rs @@ -0,0 +1,520 @@ +use serde_json::Value; + +use oxc_formatter::{EmbeddedIR, LineMode, PrintMode}; + +/// 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> { + let mut out = vec![]; + convert_doc(doc, &mut out)?; + + strip_trailing_hardline(&mut out); + collapse_consecutive_hardlines(&mut out); + + Ok(out) +} + +fn convert_doc(doc: &Value, out: &mut Vec) -> Result<(), String> { + match doc { + Value::String(s) => { + if !s.is_empty() { + out.push(EmbeddedIR::Text(s.clone())); + } + Ok(()) + } + Value::Array(arr) => { + for item in arr { + convert_doc(item, out)?; + } + Ok(()) + } + Value::Object(obj) => { + let Some(doc_type) = obj.get("type").and_then(Value::as_str) else { + return Err("Doc object missing 'type' field".to_string()); + }; + match doc_type { + "line" => { + convert_line(obj, out); + 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), + "line-suffix-boundary" => { + out.push(EmbeddedIR::LineSuffixBoundary); + Ok(()) + } + "break-parent" => { + out.push(EmbeddedIR::ExpandParent); + Ok(()) + } + "label" => { + // Label is transparent in Prettier's printer (just processes contents) + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + Ok(()) + } + "cursor" => Ok(()), // Ignore cursor markers + "trim" => Err("Unsupported Doc type: 'trim'".to_string()), + _ => Err(format!("Unknown Doc type: '{doc_type}'")), + } + } + Value::Null => Ok(()), + _ => Err(format!("Unexpected Doc value type: {doc}")), + } +} + +fn convert_line(obj: &serde_json::Map, out: &mut Vec) { + 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); + } else if hard { + out.push(EmbeddedIR::Line(LineMode::Hard)); + } else if soft { + out.push(EmbeddedIR::Line(LineMode::Soft)); + } else { + out.push(EmbeddedIR::Line(LineMode::SoftOrSpace)); + } +} + +fn convert_group( + obj: &serde_json::Map, + out: &mut Vec, +) -> 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()); + } + + 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 }); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + out.push(EmbeddedIR::EndGroup); + Ok(()) +} + +fn convert_indent( + obj: &serde_json::Map, + out: &mut Vec, +) -> Result<(), String> { + out.push(EmbeddedIR::StartIndent); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + out.push(EmbeddedIR::EndIndent); + Ok(()) +} + +fn convert_align( + obj: &serde_json::Map, + out: &mut Vec, +) -> 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)?; + } + return Ok(()); + } else if i == -1 { + // dedent (one level) + out.push(EmbeddedIR::StartDedent { to_root: false }); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + out.push(EmbeddedIR::EndDedent { to_root: false }); + 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)?; + } + 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 }); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + out.push(EmbeddedIR::EndDedent { to_root: true }); + Ok(()) + } + _ => Err(format!("Unsupported align value: {n}")), + } +} + +fn convert_if_break( + obj: &serde_json::Map, + out: &mut Vec, +) -> Result<(), String> { + let group_id = extract_group_id(obj, "groupId")?; + + // Break branch + out.push(EmbeddedIR::StartConditionalContent { mode: PrintMode::Expanded, group_id }); + if let Some(break_contents) = obj.get("breakContents") { + convert_doc(break_contents, out)?; + } + out.push(EmbeddedIR::EndConditionalContent); + + // Flat branch + out.push(EmbeddedIR::StartConditionalContent { mode: PrintMode::Flat, group_id }); + if let Some(flat_contents) = obj.get("flatContents") { + convert_doc(flat_contents, out)?; + } + out.push(EmbeddedIR::EndConditionalContent); + + Ok(()) +} + +fn convert_indent_if_break( + obj: &serde_json::Map, + out: &mut Vec, +) -> 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 { + return Err("indent-if-break requires 'groupId'".to_string()); + }; + + out.push(EmbeddedIR::StartIndentIfGroupBreaks(group_id)); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + out.push(EmbeddedIR::EndIndentIfGroupBreaks(group_id)); + Ok(()) +} + +fn convert_fill( + obj: &serde_json::Map, + out: &mut Vec, +) -> Result<(), String> { + out.push(EmbeddedIR::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(EmbeddedIR::EndFill); + Ok(()) +} + +fn convert_line_suffix( + obj: &serde_json::Map, + out: &mut Vec, +) -> Result<(), String> { + out.push(EmbeddedIR::StartLineSuffix); + if let Some(contents) = obj.get("contents") { + convert_doc(contents, out)?; + } + out.push(EmbeddedIR::EndLineSuffix); + Ok(()) +} + +/// Extracts a numeric group ID from a Doc object field. +/// The ID may be a number (from Symbol→numeric conversion in JS) or a string like "G123". +fn extract_group_id( + obj: &serde_json::Map, + field: &str, +) -> Result, String> { + match obj.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::Number(n)) => n + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .map(Some) + .ok_or_else(|| format!("Invalid group ID: {n}")), + Some(other) => Err(format!("Invalid group ID: {other}")), + } +} + +/// 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. +/// +/// +/// Prettier's `hardline` is `[line(hard), break-parent]`, +/// which maps to `[Line(Hard), ExpandParent]` in EmbeddedIR. +fn strip_trailing_hardline(ir: &mut Vec) { + if ir.len() >= 2 + && matches!(ir[ir.len() - 1], EmbeddedIR::ExpandParent) + && matches!(ir[ir.len() - 2], EmbeddedIR::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; + } + + 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) + { + ir[write] = EmbeddedIR::Line(LineMode::Empty); + ir[write + 1] = EmbeddedIR::ExpandParent; + write += 2; + read += 4; + } else { + if write != read { + ir[write] = ir[read].clone(); + } + write += 1; + read += 1; + } + } + + 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)); + } + + #[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")); + } + + #[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")); + } +} diff --git a/apps/oxfmt/src/prettier_compat/mod.rs b/apps/oxfmt/src/prettier_compat/mod.rs index e602f6c7b49f0..4c51fee97cd09 100644 --- a/apps/oxfmt/src/prettier_compat/mod.rs +++ b/apps/oxfmt/src/prettier_compat/mod.rs @@ -1 +1,2 @@ +pub mod from_prettier_doc; pub mod to_prettier_doc; diff --git a/crates/oxc_formatter/src/external_formatter.rs b/crates/oxc_formatter/src/external_formatter.rs index 030eca12863a4..a6b8a2014ec70 100644 --- a/crates/oxc_formatter/src/external_formatter.rs +++ b/crates/oxc_formatter/src/external_formatter.rs @@ -1,10 +1,22 @@ use std::sync::Arc; +use super::formatter::format_element::{LineMode, PrintMode}; + /// Callback function type for formatting embedded code. /// Takes (tag_name, code) and returns formatted code or an error. pub type EmbeddedFormatterCallback = Arc Result + Send + Sync>; +/// 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`). +/// +/// 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>; + /// Callback function type for sorting Tailwind CSS classes. /// Takes classes and returns the sorted versions. pub type TailwindCallback = Arc) -> Vec + Send + Sync>; @@ -17,13 +29,14 @@ pub type TailwindCallback = Arc) -> Vec + Send + Sync #[derive(Default)] pub struct ExternalCallbacks { embedded_formatter: Option, + embedded_doc_formatter: Option, tailwind: Option, } impl ExternalCallbacks { /// Create a new `ExternalCallbacks` with no callbacks set. pub fn new() -> Self { - Self { embedded_formatter: None, tailwind: None } + Self { embedded_formatter: None, embedded_doc_formatter: None, tailwind: None } } /// Set the embedded formatter callback. @@ -33,6 +46,16 @@ impl ExternalCallbacks { self } + /// Set the embedded Doc formatter callback (Doc→IR path). + #[must_use] + pub fn with_embedded_doc_formatter( + mut self, + callback: Option, + ) -> Self { + self.embedded_doc_formatter = callback; + self + } + /// Set the Tailwind callback. #[must_use] pub fn with_tailwind(mut self, callback: Option) -> Self { @@ -54,6 +77,27 @@ 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. + /// + /// # Arguments + /// * `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(Err(String))` - An error message if formatting failed + /// * `None` - No embedded formatter callback is set + pub fn format_embedded_doc( + &self, + tag_name: &str, + texts: &[&str], + ) -> Option>, String>> { + self.embedded_doc_formatter.as_ref().map(|cb| cb(tag_name, texts)) + } + /// Sort Tailwind CSS classes. /// /// # Arguments @@ -72,3 +116,58 @@ 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/lib.rs b/crates/oxc_formatter/src/lib.rs index 4ba4eeba4f8be..b44cb40715a47 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::{ - EmbeddedFormatterCallback, ExternalCallbacks, TailwindCallback, + EmbeddedDocFormatterCallback, EmbeddedFormatterCallback, EmbeddedIR, ExternalCallbacks, + TailwindCallback, }; pub use crate::formatter::GroupId; pub use crate::formatter::format_element::tag::{DedentMode, Tag}; diff --git a/crates/oxc_formatter/src/print/template/embed/graphql.rs b/crates/oxc_formatter/src/print/template/embed/graphql.rs new file mode 100644 index 0000000000000..dfa2d486dfff9 --- /dev/null +++ b/crates/oxc_formatter/src/print/template/embed/graphql.rs @@ -0,0 +1,218 @@ +use rustc_hash::FxHashMap; + +use oxc_ast::ast::*; + +use crate::{ + ast_nodes::AstNode, + external_formatter::EmbeddedIR, + formatter::{Formatter, format_element::LineMode, 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. +/// Called from both: +/// - tagged template (gql`...`) +/// - and function call (`graphql(schema, `...`)`) +pub(super) fn format_graphql_doc<'a>( + quasi: &AstNode<'a, TemplateLiteral<'a>>, + f: &mut Formatter<'_, 'a>, +) -> bool { + let quasis = &quasi.quasis; + let num_quasis = quasis.len(); + + // Phase 1: Analyze each quasi + let mut infos: Vec> = Vec::with_capacity(num_quasis); + for (idx, quasi_elem) in quasis.iter().enumerate() { + // Use `.cooked` value instead of `.raw` like Prettier. + // Bail out if cooked is `None` (e.g. invalid escape sequence) + let Some(cooked) = quasi_elem.value.cooked.as_ref() else { + return false; + }; + let text = cooked.as_str(); + // `.cooked` has normalized line terminators + let lines: Vec<&str> = text.split('\n').collect(); + + // Bail out if interpolation occurs within a GraphQL comment. + // https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/graphql.js#L37-L40 + // Must use `lines.last()` (from split), not `text.lines().next_back()`. + // Because `lines()` strips trailing empty lines, causing false positives when text ends with `\n` + // (e.g. `"\n# comment\n"`). + if idx != num_quasis - 1 + && let Some(last_line) = lines.last() + && last_line.contains('#') + { + return false; + } + + let num_lines = lines.len(); + // Detect blank lines around expressions. + // https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/graphql.js#L26-L31 + let starts_with_blank_line = + num_lines > 2 && lines[0].trim().is_empty() && lines[1].trim().is_empty(); + let ends_with_blank_line = num_lines > 2 + && lines[num_lines - 1].trim().is_empty() + && lines[num_lines - 2].trim().is_empty(); + + // Check if every line in the text is whitespace-only or a GraphQL comment (`# ...`). + // https://github.com/prettier/prettier/blob/90983f40dce5e20beea4e5618b5e0426a6a7f4f0/src/language-js/embed/graphql.js#L33-L35 + let comments_only = lines.iter().all(|line| { + let trimmed = line.trim(); + trimmed.is_empty() || trimmed.starts_with('#') + }); + + infos.push(QuasiInfo { text, comments_only, starts_with_blank_line, ends_with_blank_line }); + } + + // Phase 2: Collect non-skip texts for batch formatting. + // Only send texts that actually need formatting to JS. + let mut texts_to_format: Vec<&str> = Vec::new(); + let mut format_index_map: Vec> = Vec::with_capacity(num_quasis); + for info in &infos { + if info.comments_only { + format_index_map.push(None); + } else { + format_index_map.push(Some(texts_to_format.len())); + texts_to_format.push(info.text); + } + } + + // PERF: Batch send only non-skip texts, get IRs back. + 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 { + 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`. + let mut irs_iter = all_irs.into_iter(); + 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); + ir_parts.push(comment_ir); + } else { + ir_parts.push(None); + } + } + + // Collect expressions via AstNode-aware iterator + // (`FormatTemplateExpression` needs AstNode-wrapped expressions) + let expressions: Vec<_> = quasi.expressions().iter().collect(); + + // Early return for empty/whitespace-only templates with no expressions. + // Do not use `block_indent()`, it requires at least one element. + if expressions.is_empty() && ir_parts.iter().all(Option::is_none) { + write!(f, ["``"]); + return true; + } + + // 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 mut has_prev_part = false; + + for (idx, maybe_ir) in ir_parts.iter().enumerate() { + let is_first = idx == 0; + let is_last = idx == num_quasis - 1; + + if let Some(ir) = maybe_ir { + if !is_first && infos[idx].starts_with_blank_line { + if has_prev_part { + write!(f, [empty_line()]); + } + } else if has_prev_part { + write!(f, [hard_line_break()]); + } + write_embedded_ir(ir, f, &mut group_id_map); + has_prev_part = true; + } else if !is_first && !is_last && infos[idx].starts_with_blank_line && has_prev_part { + write!(f, [empty_line()]); + } + + if !is_last { + if infos[idx].ends_with_blank_line && has_prev_part { + write!(f, [empty_line()]); + has_prev_part = false; // Next part won't add another separator + } + + if let Some(expr) = expressions.get(idx) { + if has_prev_part { + write!(f, [hard_line_break()]); + } + let te = TemplateExpression::Expression(expr); + FormatTemplateExpression::new(&te, FormatTemplateExpressionOptions::default()) + .fmt(f); + has_prev_part = true; + } + } + } + }); + + write!(f, ["`", block_indent(&format_content), "`"]); + true +} + +/// Per-quasi metadata extracted during the analysis phase. +struct QuasiInfo<'a> { + /// The cooked text of the quasi (always `Some` — `None` causes early bail-out). + text: &'a str, + /// Whether the quasi contains only whitespace and/or GraphQL comments. + comments_only: bool, + /// Blank line at the beginning of this quasi. + starts_with_blank_line: bool, + /// Blank line at the end of this quasi. + ends_with_blank_line: bool, +} + +/// Build IR for a comment-only quasi +/// +/// +/// Extracts comment lines, joins with hardline, and preserves blank lines between comment groups. +fn build_graphql_comment_ir(text: &str) -> Option> { + let lines: Vec<&str> = text.split('\n').map(str::trim).collect(); + let mut parts: Vec = vec![]; + let mut seen_comment = false; + + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + + 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())); + } else { + if seen_comment { + parts.push(EmbeddedIR::Line(LineMode::Hard)); + parts.push(EmbeddedIR::ExpandParent); + } + parts.push(EmbeddedIR::Text((*line).to_string())); + } + + seen_comment = true; + } + + if parts.is_empty() { None } else { Some(parts) } +} diff --git a/crates/oxc_formatter/src/print/template/embed/mod.rs b/crates/oxc_formatter/src/print/template/embed/mod.rs index 998a1b34e39a9..dbfebdd1dea5c 100644 --- a/crates/oxc_formatter/src/print/template/embed/mod.rs +++ b/crates/oxc_formatter/src/print/template/embed/mod.rs @@ -1,13 +1,39 @@ +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}, - formatter::{Formatter, prelude::*}, + external_formatter::EmbeddedIR, + formatter::{ + FormatElement, Formatter, GroupId, + format_element::{TextWidth, tag}, + prelude::*, + }, write, }; +/// Try to format a tagged template with the embedded formatter if supported. +/// Returns `true` if formatting was performed, `false` if not applicable. +pub(super) fn try_format_embedded_template<'a>( + tagged: &AstNode<'a, TaggedTemplateExpression<'a>>, + f: &mut Formatter<'_, 'a>, +) -> bool { + match get_tag_name(&tagged.tag) { + Some("css" | "styled") => try_embed_css(tagged, 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), + _ => false, + } +} + fn get_tag_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> { let expr = expr.get_inner_expression(); match expr { @@ -19,121 +45,26 @@ fn get_tag_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> { } } -/// Format embedded language content (CSS, GraphQL, etc.) -/// inside a template literal using an external formatter (Prettier). -/// -/// NOTE: Unlike Prettier, which formats embedded languages in-process via its document IR -/// (e.g. `textToDoc()` → `indent([hardline, doc])`), -/// we communicate with the external formatter over a plain text interface. -/// -/// This means we must: -/// - Dedent the inherited JS/TS indentation before sending -/// - Reconstruct the template structure (`block_indent()`) from the formatted text -/// -/// If `format_embedded()` could return `FormatElement` (IR) directly, -/// most of work in this function would be unnecessary. -fn format_embedded_template<'a>( +/// Try to format a template literal inside css prop or styled-jsx with the embedded formatter. +/// Returns `true` if formatting was attempted, `false` if not applicable. +pub(super) fn try_format_css_template<'a>( + template_literal: &AstNode<'a, TemplateLiteral<'a>>, f: &mut Formatter<'_, 'a>, - language: &str, - template_content: &str, ) -> bool { - // Whitespace-only templates become empty backticks. - // Regular template literals would preserve them as-is. - if template_content.trim().is_empty() { - write!(f, ["``"]); - return true; - } - - // Strip inherited indentation. - // So the external formatter receives clean embedded language content. - // Otherwise, indentation may be duplicated on each formatting pass. - let template_content = dedent(template_content, f.context().allocator()); - - let Some(Ok(formatted)) = - f.context().external_callbacks().format_embedded(language, template_content) - else { + // TODO: Support expressions in the css-in-js + if !template_literal.is_no_substitution_template() { return false; - }; - - // Format with proper template literal structure: - // - Opening backtick - // - Hard line break (newline after backtick) - // - Indented content (each line will be indented) - // - Hard line break (newline before closing backtick) - // - Closing backtick - let format_content = format_with(|f: &mut Formatter<'_, 'a>| { - let content = f.context().allocator().alloc_str(&formatted); - for line in LineTerminatorSplitter::new(content) { - if line.is_empty() { - write!(f, [empty_line()]); - } else { - write!(f, [text(line), hard_line_break()]); - } - } - }); - - write!(f, ["`", block_indent(&format_content), "`"]); - - true -} - -/// Strip the common leading indentation from all non-empty lines in `text`. -/// Returns the original `text` unchanged if there is no common indentation. -fn dedent<'a>(text: &'a str, allocator: &'a Allocator) -> &'a str { - let min_indent = text - .split('\n') - .filter(|line| !line.trim_ascii_start().is_empty()) - .map(|line| line.bytes().take_while(u8::is_ascii_whitespace).count()) - .min() - .unwrap_or(0); - - if min_indent == 0 { - return text; } - let mut result = StringBuilder::with_capacity_in(text.len(), allocator); - for (i, line) in text.split('\n').enumerate() { - if i > 0 { - result.push('\n'); - } - let strip = line.bytes().take_while(u8::is_ascii_whitespace).count().min(min_indent); - result.push_str(&line[strip..]); - } - - result.into_str() -} - -/// Try to format a tagged template with the embedded formatter if supported. -/// Returns `true` if formatting was performed, `false` if not applicable. -pub(in super::super) fn try_format_embedded_template<'a>( - tagged: &AstNode<'a, TaggedTemplateExpression<'a>>, - f: &mut Formatter<'_, 'a>, -) -> bool { - let quasi = &tagged.quasi; - // TODO: Support expressions in the template - if !quasi.is_no_substitution_template() { + if !is_in_css_jsx(template_literal) { return false; } - let language = match get_tag_name(&tagged.tag) { - Some("css" | "styled") => "tagged-css", - Some("gql" | "graphql") => "tagged-graphql", - Some("html") => "tagged-html", - Some("md" | "markdown") => "tagged-markdown", - _ => return false, - }; - - let template_content = quasi.quasis[0].value.raw.as_str(); - - format_embedded_template(f, language, template_content) + let template_content = template_literal.quasis()[0].value.raw.as_str(); + format_embedded_template(f, "styled-jsx", template_content) } /// Check if the template literal is inside a `css` prop or ` -/// ``` fn is_in_css_jsx<'a>(node: &AstNode<'a, TemplateLiteral<'a>>) -> bool { let AstNodes::JSXExpressionContainer(container) = node.parent() else { return false; @@ -162,57 +93,27 @@ fn is_in_css_jsx<'a>(node: &AstNode<'a, TemplateLiteral<'a>>) -> bool { false } -/// Try to format a template literal inside css prop or styled-jsx with the embedded formatter. -/// Returns `true` if formatting was attempted, `false` if not applicable. -pub(in super::super) fn try_format_css_template<'a>( - template_literal: &AstNode<'a, TemplateLiteral<'a>>, - f: &mut Formatter<'_, 'a>, -) -> bool { - // TODO: Support expressions in the template - if !template_literal.is_no_substitution_template() { - return false; - } - - if !is_in_css_jsx(template_literal) { - return false; - } - - let quasi = template_literal.quasis(); - let template_content = quasi[0].value.raw.as_str(); - - format_embedded_template(f, "styled-jsx", template_content) -} - /// Try to format a template literal inside Angular @Component's template/styles property. /// Returns `true` if formatting was performed, `false` if not applicable. -pub(in super::super) fn try_format_angular_component<'a>( +pub(super) fn try_format_angular_component<'a>( template_literal: &AstNode<'a, TemplateLiteral<'a>>, f: &mut Formatter<'_, 'a>, ) -> bool { - // TODO: Support expressions in the template + // TODO: Support expressions in the css-in-js and html-in-js + // Also need to split html or css path with using `.raw` for css, `.cooked` for html if !template_literal.is_no_substitution_template() { return false; } - // Check if inside `@Component` decorator's `template/styles` property let Some(language) = get_angular_component_language(template_literal) else { return false; }; - let quasi = template_literal.quasis(); - let template_content = quasi[0].value.raw.as_str(); - + let template_content = template_literal.quasis()[0].value.raw.as_str(); format_embedded_template(f, language, template_content) } -/// Check if this template literal is one of: -/// ```ts -/// @Component({ -/// template: `...`, -/// styles: `...`, -/// // or styles: [`...`] -/// }) -/// ``` +/// Detect Angular `@Component({ template: \`...\`, styles: \`...\` })`. fn get_angular_component_language(node: &AstNode<'_, TemplateLiteral<'_>>) -> Option<&'static str> { let prop = match node.parent() { AstNodes::ObjectProperty(prop) => prop, @@ -225,7 +126,6 @@ fn get_angular_component_language(node: &AstNode<'_, TemplateLiteral<'_>>) -> Op _ => return None, }; - // Skip computed properties if prop.computed { return None; } @@ -233,7 +133,6 @@ fn get_angular_component_language(node: &AstNode<'_, TemplateLiteral<'_>>) -> Op return None; }; - // Check parent chain: ObjectExpression -> CallExpression(Component) -> Decorator let AstNodes::ObjectExpression(obj) = prop.parent() else { return None; }; @@ -250,10 +149,261 @@ fn get_angular_component_language(node: &AstNode<'_, TemplateLiteral<'_>>) -> Op return None; } - let language = match key.name.as_str() { - "template" => "angular-template", - "styles" => "angular-styles", - _ => return None, + match key.name.as_str() { + "template" => Some("angular-template"), + "styles" => Some("angular-styles"), + _ => None, + } +} + +// --- + +fn try_embed_css<'a>( + tagged: &AstNode<'a, TaggedTemplateExpression<'a>>, + f: &mut Formatter<'_, 'a>, +) -> bool { + // TODO: Remove this check and use placeholder approach for expressions + if !tagged.quasi.is_no_substitution_template() { + return false; + } + let template_content = tagged.quasi.quasis[0].value.raw.as_str(); + format_embedded_template(f, "tagged-css", template_content) +} + +fn try_embed_html<'a>( + tagged: &AstNode<'a, TaggedTemplateExpression<'a>>, + f: &mut Formatter<'_, 'a>, +) -> bool { + // TODO: Remove this check and use placeholder approach for expressions + if !tagged.quasi.is_no_substitution_template() { + return false; + } + let template_content = tagged.quasi.quasis[0].value.raw.as_str(); + format_embedded_template(f, "tagged-html", template_content) +} + +fn try_embed_markdown<'a>( + tagged: &AstNode<'a, TaggedTemplateExpression<'a>>, + f: &mut Formatter<'_, 'a>, +) -> bool { + // Markdown never supports expressions (Prettier doesn't either) + if !tagged.quasi.is_no_substitution_template() { + return false; + } + let template_content = tagged.quasi.quasis[0].value.raw.as_str(); + format_embedded_template(f, "tagged-markdown", template_content) +} + +// --- + +/// Format embedded language content inside a template literal using the string path. +/// +/// This is the shared formatting logic for no-substitution templates: +/// dedent → external formatter (Prettier) → reconstruct template structure. +fn format_embedded_template<'a>( + f: &mut Formatter<'_, 'a>, + language: &str, + template_content: &str, +) -> bool { + if template_content.trim().is_empty() { + write!(f, ["``"]); + return true; + } + + let template_content = dedent(template_content, f.context().allocator()); + + let Some(Ok(formatted)) = + f.context().external_callbacks().format_embedded(language, template_content) + else { + return false; }; - Some(language) + + let format_content = format_with(|f: &mut Formatter<'_, 'a>| { + let content = f.context().allocator().alloc_str(&formatted); + for line in LineTerminatorSplitter::new(content) { + if line.is_empty() { + write!(f, [empty_line()]); + } else { + write!(f, [text(line), hard_line_break()]); + } + } + }); + + // NOTE: This path always returns the formatted string with each line indented, + // regardless of the length of the content, which may not be compatible with Prettier in some cases. + // If we use `Doc` like in the gql-in-js path, it would behave aligned with Prettier. + write!(f, ["`", block_indent(&format_content), "`"]); + true +} + +/// Strip the common leading indentation from all non-empty lines in `text`. +/// The `text` here is taken from `.raw`, so only `\n` is used as the line terminator. +fn dedent<'a>(text: &'a str, allocator: &'a Allocator) -> &'a str { + let min_indent = text + .split('\n') + .filter(|line| !line.trim_ascii_start().is_empty()) + .map(|line| line.bytes().take_while(u8::is_ascii_whitespace).count()) + .min() + .unwrap_or(0); + + if min_indent == 0 { + return text; + } + + let mut result = StringBuilder::with_capacity_in(text.len(), allocator); + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + result.push('\n'); + } + let strip = line.bytes().take_while(u8::is_ascii_whitespace).count().min(min_indent); + result.push_str(&line[strip..]); + } + + 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() }