refactor(formatter_core): make embedded dispatch parent-to-child only (#28159)

Remove `child_context`, every usecases are handled only parent -> child direction.
This commit is contained in:
leaysgur committed 2026-10-26 03:52:42 +00:00
1 parent 014e3d2676
commit f525b34636
10 files changed
+112 -165

No files matched your search

+6 -6
View File
@@ -226,17 +226,17 @@ impl ResolvedDispatchConfig {
}
/// Fallback invoked for [`Route::Prettier`] languages.
/// Same shape as `FormatDispatcher` minus the request envelope
/// (the Doc path consumes neither `input_kind` nor `parent_context` today;
/// Same shape as `FormatDispatcher`, plus the routed language
/// (the Doc path consumes `parent_context` only for html(angular)-in-js's [`oxc_formatter::HtmlInJsTemplate`], not `input_kind`;
/// re-examine if it ever serves envelope-bearing inputs).
///
/// Assembled only in napi builds ([`super::prettier_doc`]);
/// the pure Rust build passes `None` and these languages are deliberately preserved as-is.
pub type PrettierDocFallback = Arc<
dyn for<'a> Fn(
dyn for<'a, 'r> Fn(
&FormatSession<'a>,
PrettierLanguage,
&str,
&DispatchRequest<'r>,
) -> Result<DispatchResponse<'a>, String>
+ Send
+ Sync,
@@ -323,7 +323,7 @@ pub fn build_dispatcher(
// deliberate skip otherwise (pure build).
Route::Prettier(language) => {
if let Some(fallback) = &fallback {
fallback(session, language, text)
fallback(session, language, &request)
} else {
debug!(
"No fallback for Prettier language '{}' in this build, part stays as-is",
@@ -349,7 +349,7 @@ fn format_native<'a, E: std::fmt::Display>(
format_to_ir: impl FnOnce() -> Result<EmbeddedIr<'a>, E>,
) -> DispatchResponse<'a> {
debug_span!("oxfmt::embed::format_to_ir", language).in_scope(|| match format_to_ir() {
Ok(embedded) => DispatchResponse::Formatted(embedded.into()),
Ok(embedded) => DispatchResponse::Formatted(embedded),
Err(err) => {
debug!("native '{language}' format_to_ir failed, part stays as-is: {err}");
DispatchResponse::PreserveOriginal
+59 -42
View File
@@ -11,8 +11,11 @@ use serde::Deserialize;
use serde_json::Value;
use tracing::{debug, debug_span};
use oxc_formatter::HtmlEmbedMeta;
use oxc_formatter_core::{DispatchPayload, DispatchResponse, FormatSession};
use oxc_allocator::ArenaVec;
use oxc_formatter::HtmlInJsTemplate;
use oxc_formatter_core::{
DispatchRequest, DispatchResponse, EmbeddedIr, FormatElement, FormatSession, Tag,
};
use crate::{
core::{
@@ -31,46 +34,60 @@ pub fn build_prettier_fallback(
dispatch_config: Arc<ResolvedDispatchConfig>,
format_embedded_doc: FormatEmbeddedDocWithConfigCallback,
) -> PrettierDocFallback {
Arc::new(move |session: &FormatSession<'_>, language: PrettierLanguage, text: &str| {
let parser_name = language.parser();
let Some(options) = dispatch_config.prettier_options_for(language) else {
debug!("The plugin for parser '{parser_name}' is not enabled, part stays as-is");
return Ok(DispatchResponse::PreserveOriginal);
};
debug_span!("oxfmt::external::format_embedded_doc", parser = parser_name)
.in_scope(|| {
let doc_json_str = (format_embedded_doc)(options, text).map_err(|err| {
format!("Failed to get Doc for embedded code (parser '{parser_name}'): {err}")
})?;
// Prettier's Doc can produce deeply nested arrays
// (e.g., md-in-js with `proseWrap: preserve`, which nests each word in `[[[prev, " "], word], " "]`).
// The default recursion limit of 128 is not enough for long paragraphs.
// This only affects this deserialization call;
// other `serde_json` usage in the codebase keeps the default limit.
let mut de = serde_json::Deserializer::from_str(&doc_json_str);
de.disable_recursion_limit();
let doc_json = serde_json::Value::deserialize(&mut de)
.map_err(|e| format!("Failed to parse Doc JSON: {e}"))?;
Arc::new(
move |session: &FormatSession<'_>,
language: PrettierLanguage,
request: &DispatchRequest<'_>| {
let text = request.text;
let parser_name = language.parser();
let Some(options) = dispatch_config.prettier_options_for(language) else {
debug!("The plugin for parser '{parser_name}' is not enabled, part stays as-is");
return Ok(DispatchResponse::PreserveOriginal);
};
debug_span!("oxfmt::external::format_embedded_doc", parser = parser_name)
.in_scope(|| {
let doc_json_str = (format_embedded_doc)(options, text).map_err(|err| {
format!(
"Failed to get Doc for embedded code (parser '{parser_name}'): {err}"
)
})?;
// Prettier's Doc can produce deeply nested arrays
// (e.g., md-in-js with `proseWrap: preserve`, which nests each word in `[[[prev, " "], word], " "]`).
// The default recursion limit of 128 is not enough for long paragraphs.
// This only affects this deserialization call;
// other `serde_json` usage in the codebase keeps the default limit.
let mut de = serde_json::Deserializer::from_str(&doc_json_str);
de.disable_recursion_limit();
let doc_json = serde_json::Value::deserialize(&mut de)
.map_err(|e| format!("Failed to parse Doc JSON: {e}"))?;
let allocator = session.allocator();
let (mut ir, metadata) = from_prettier_doc::convert_envelope(
doc_json,
allocator,
session.group_id_builder(),
)?;
from_prettier_doc::postprocess(&mut ir, allocator);
// HTML/Angular additionally surface `HtmlEmbedMeta` to the embed site.
let child_context = language.wants_html_meta().then(|| {
Box::new(HtmlEmbedMeta {
has_multiple_root_elements: metadata
let allocator = session.allocator();
let (mut ir, metadata) = from_prettier_doc::convert_envelope(
doc_json,
allocator,
session.group_id_builder(),
)?;
from_prettier_doc::postprocess(&mut ir, allocator);
// html(angular)-in-js: indent the content when more than one node prints at the root
let indent = request
.parent_context_as::<HtmlInJsTemplate>()
.is_some_and(|c| c.indent_multiple_roots)
&& metadata
.get("htmlHasMultipleRootElements")
.and_then(Value::as_bool),
}) as Box<dyn std::any::Any>
});
Ok(DispatchResponse::Formatted(DispatchPayload { doc: ir, child_context }))
})
.inspect_err(|err| {
debug!("Failed to format embedded doc for parser '{parser_name}': {err}");
})
})
.and_then(Value::as_bool)
.unwrap_or(true);
if indent {
let mut indented = ArenaVec::with_capacity_in(ir.len() + 2, &allocator);
indented.push(FormatElement::Tag(Tag::StartIndent));
indented.extend(ir);
indented.push(FormatElement::Tag(Tag::EndIndent));
ir = indented;
}
Ok(DispatchResponse::Formatted(EmbeddedIr { ir }))
})
.inspect_err(|err| {
debug!("Failed to format embedded doc for parser '{parser_name}': {err}");
})
},
)
}
-6
View File
@@ -199,12 +199,6 @@ impl PrettierLanguage {
_ => None,
}
}
/// Whether the Doc→IR conversion must surface `HtmlEmbedMeta`
/// (`htmlHasMultipleRootElements`) to the embed site.
pub fn wants_html_meta(self) -> bool {
matches!(self, Self::Html | Self::Angular)
}
}
// ---
+5 -5
View File
@@ -23,11 +23,11 @@ pub struct CssInJsTemplate;
/// which would need escaping in the template.
pub struct MarkdownInJsTemplate;
/// Child→parent pair context for HTML/Angular formatted as an embedded child (html-in-js).
pub struct HtmlEmbedMeta {
/// Whether the parsed HTML has more than one root element.
/// Used to decide whether to `indent` the template content.
pub has_multiple_root_elements: Option<bool>,
/// Parent→child context for HTML/Angular dispatched from a JS template literal (html-in-js).
pub struct HtmlInJsTemplate {
/// The backticks hug the content (no whitespace on both ends, whitespace sensitivity not `ignore`):
/// the child indents its content when more than one node prints at the root.
pub indent_multiple_roots: bool,
}
/// What a dispatched JS/TS program is embedded in, for the print rules that depend on it.
+1 -1
View File
@@ -26,7 +26,7 @@ use oxc_span::SourceType;
// or the special-purpose AST-in `format_program`.
pub(crate) use crate::ast_nodes::{AstNode, AstNodes};
pub use crate::embed_context::{
CssInJsTemplate, HtmlEmbedMeta, JsEmbeddedIn, MarkdownInJsTemplate,
CssInJsTemplate, HtmlInJsTemplate, JsEmbeddedIn, MarkdownInJsTemplate,
};
// `JsFormatContext` is public solely as the type parameter of the returned `Formatted`;
// its methods are not part of the public contract.
@@ -1,13 +1,12 @@
use oxc_allocator::ArenaStringBuilder;
use oxc_ast::ast::*;
use oxc_formatter_core::{
DispatchRequest, DispatchResponse, FormatElement, InputKind, map_text_in_ir,
push_text_with_literal_lines,
FormatElement, dispatch_fragment_ir, map_text_in_ir, push_text_with_literal_lines,
};
use crate::{
ast_nodes::AstNode,
embed_context::HtmlEmbedMeta,
embed_context::HtmlInJsTemplate,
format_args,
formatter::prelude::*,
print::template::{
@@ -52,39 +51,19 @@ pub(super) fn format_html_doc<'a>(
let has_leading_ws = cooked.starts_with(|c: char| c.is_ascii_whitespace());
let has_trailing_ws = cooked.ends_with(|c: char| c.is_ascii_whitespace());
let ws_ignore = f.options().html_whitespace_sensitivity_ignore;
let Ok(DispatchResponse::Formatted(result)) = f.session().dispatch(DispatchRequest {
language: embedded_language,
text: cooked,
input_kind: InputKind::Fragment,
parent_context: None,
}) else {
let context = html_in_js_template(has_leading_ws, has_trailing_ws, ws_ignore);
let Some(ir) = dispatch_fragment_ir(f, embedded_language, cooked, Some(&context)) else {
return false;
};
let Some(html_has_multiple_root_elements) = result
.child_context
.as_ref()
.and_then(|context| context.downcast_ref::<HtmlEmbedMeta>())
.map(|meta| meta.has_multiple_root_elements)
else {
return false;
};
let ir = result.doc;
// Re-escape template chars in `Text` runs:
// the IR is reinserted into a JS template literal built from `.cooked` values.
let ir = super::escape_template_chars_in_ir(ir, f);
let content = format_once(|f| f.write_elements(ir));
let ws_ignore = f.options().html_whitespace_sensitivity_ignore;
write_html_template(
f,
&content,
has_leading_ws,
has_trailing_ws,
html_has_multiple_root_elements.unwrap_or(true),
ws_ignore,
);
write_html_template(f, &content, has_leading_ws, has_trailing_ws, ws_ignore);
return true;
}
@@ -112,27 +91,14 @@ pub(super) fn format_html_doc<'a>(
let has_leading_ws = joined.starts_with(|c: char| c.is_ascii_whitespace());
let has_trailing_ws = joined.ends_with(|c: char| c.is_ascii_whitespace());
let ws_ignore = f.options().html_whitespace_sensitivity_ignore;
// Phase 2: Format via the dispatcher (IR path)
let Ok(DispatchResponse::Formatted(result)) = f.session().dispatch(DispatchRequest {
language: embedded_language,
text: joined,
input_kind: InputKind::Fragment,
parent_context: None,
}) else {
let context = html_in_js_template(has_leading_ws, has_trailing_ws, ws_ignore);
let Some(ir) = dispatch_fragment_ir(f, embedded_language, joined, Some(&context)) else {
return false;
};
let Some(html_has_multiple_root_elements) = result
.child_context
.as_ref()
.and_then(|context| context.downcast_ref::<HtmlEmbedMeta>())
.map(|meta| meta.has_multiple_root_elements)
else {
return false;
};
let ir = result.doc;
// Validate before formatting any expression.
// Formatting consumes comment state,
// so discovering an unusable placeholder layout afterwards would make the verbatim fallback unsafe.
@@ -188,19 +154,19 @@ pub(super) fn format_html_doc<'a>(
true
});
let format_content = format_once(move |f: &mut JsFormatter<'_, 'a>| f.write_elements(ir));
let ws_ignore = f.options().html_whitespace_sensitivity_ignore;
write_html_template(
f,
&format_content,
has_leading_ws,
has_trailing_ws,
html_has_multiple_root_elements.unwrap_or(true),
ws_ignore,
);
write_html_template(f, &format_content, has_leading_ws, has_trailing_ws, ws_ignore);
true
}
/// The child indents its content only where the backticks hug it (the last case of [`write_html_template`]).
fn html_in_js_template(
has_leading_ws: bool,
has_trailing_ws: bool,
ws_ignore: bool,
) -> HtmlInJsTemplate {
HtmlInJsTemplate { indent_multiple_roots: !(ws_ignore || has_leading_ws && has_trailing_ws) }
}
// ---
/// Write the HTML template with appropriate wrapping based on whitespace and top-level count.
@@ -214,12 +180,12 @@ pub(super) fn format_html_doc<'a>(
/// - Otherwise: `group(["`", leadingWS?, maybeIndent(group(content)), trailingWS?, "`"])`
/// → content hugs the backtick directly
/// → multiple root elements wraps with `indent`, single does not
/// → the child does the `indent` on request ([`HtmlInJsTemplate`])
fn write_html_template<'a>(
f: &mut JsFormatter<'_, 'a>,
content: &impl Format<'a, JsFormatContext<'a>>,
has_leading_ws: bool,
has_trailing_ws: bool,
has_multiple_root_elements: bool,
ws_ignore: bool,
) {
if ws_ignore {
@@ -249,11 +215,7 @@ fn write_html_template<'a>(
// group(["`", leadingWS?, maybeIndent(group(content)), trailingWS?, "`"])
let leading = if has_leading_ws { " " } else { "" };
let trailing = if has_trailing_ws { " " } else { "" };
if has_multiple_root_elements {
write!(f, [group(&format_args!("`", leading, indent(&group(content)), trailing, "`"))]);
} else {
write!(f, [group(&format_args!("`", leading, group(content), trailing, "`"))]);
}
write!(f, [group(&format_args!("`", leading, group(content), trailing, "`"))]);
}
}
+3 -2
View File
@@ -78,7 +78,7 @@ The core is parameterized over a consumer-supplied context so it stays language-
### Embedded-language infrastructure (`session/`)
`FormatSession` / `FormatDispatcher` / `DispatchRequest` / `DispatchPayload` let one formatter's IR be built inside another's document (e.g. graphql-in-js):
`FormatSession` / `FormatDispatcher` / `DispatchRequest` / `EmbeddedIr` let one formatter's IR be built inside another's document (e.g. graphql-in-js):
- The orchestrator (oxfmt) assembles the dispatcher, mapping language names to formatter implementations (or a Prettier fallback)
- Formatter crates only invoke it via `FormatSession::dispatch`
@@ -86,8 +86,9 @@ The core is parameterized over a consumer-supplied context so it stays language-
- A child adds classes straight into the parent's index space, so its doc is written as-is (no remap)
- A root formatter opens a fresh scope (`with_new_tailwind_scope`) and sorts it when finalizing its `Document` (`take_sorted_tailwind_classes`)
- A language crate's `format_to_ir` entry returns `EmbeddedIr`, one shape for every child language, no per-crate tuples
- The dispatcher answers with it as-is (`DispatchResponse::Formatted`): data flows parent→child only, a child reports nothing back
- Only language-pair specific data crosses as `dyn Any`, core never learns concrete languages:
- When the parent picks the child language, it passes the pair's own data (css-in-js's `CssInJsTemplate`, html-in-js's `has_multiple_root_elements`)
- When the parent picks the child language, it passes the pair's own data (css-in-js's `CssInJsTemplate`, html-in-js's `HtmlInJsTemplate`)
- When it cannot (a Markdown code block's name resolves in the host), it passes a fact about itself to every child,
and the host translates it into the pair's data once the child language is known (`XxxInMarkdownCodeBlock` → `JsEmbeddedIn`)
- `FormatSession` (`session/mod.rs`) is the execution unit:
+2 -2
View File
@@ -48,8 +48,8 @@ pub use printer::{PrintResult, PrintWidth, Printed, PrinterOptions};
// which is what makes print-without-finalize unrepresentable outside core.
pub(crate) use printer::Printer;
pub use session::embedded::{
DispatchPayload, DispatchRequest, DispatchResponse, EmbeddedIr, FormatDispatcher,
dispatch_fragment_ir, dispatch_ir, map_text_in_ir, push_text_with_literal_lines,
DispatchRequest, DispatchResponse, EmbeddedIr, FormatDispatcher, dispatch_fragment_ir,
dispatch_ir, map_text_in_ir, push_text_with_literal_lines,
};
pub use session::{FormatSession, InputKind, SessionServices, StringEmbedder, TailwindSorter};
pub use source::{SourceText, SpanCursor, rebase_labels, restore_crlf_labels};
@@ -34,8 +34,7 @@ pub struct DispatchRequest<'r> {
pub input_kind: InputKind,
/// Parent→child language-pair specific data,
/// downcast by the implementation (`None` for most pairs; e.g. JS's `CssInJsTemplate`).
/// The borrowed counterpart of [`DispatchPayload::child_context`]
/// (borrowed because the parent outlives the dispatch call).
/// Only parent→child: a child formats what the parent asks for, it reports nothing back.
pub parent_context: Option<&'r dyn Any>,
}
@@ -55,8 +54,8 @@ impl DispatchRequest<'_> {
/// optional-embed callers degrade the same way for both,
/// but the two must never be conflated at the source.
pub enum DispatchResponse<'a> {
/// The child formatted the request; consume [`DispatchPayload`].
Formatted(DispatchPayload<'a>),
/// The child formatted the request; the parent writes its IR as-is.
Formatted(EmbeddedIr<'a>),
/// Deliberately not formatted; keep the original source untouched.
PreserveOriginal,
}
@@ -77,34 +76,16 @@ pub type FormatDispatcher = Arc<
+ Sync,
>;
/// IR built by a language crate's embedded entry point (`format_to_ir`) for one input text.
/// The orchestrator's dispatcher wraps it into a [`DispatchPayload`].
/// IR built by a language crate's embedded entry point (`format_to_ir`) for one input text,
/// carried by [`DispatchResponse::Formatted`].
///
/// Every language crate's `format_to_ir` returns this shape,
/// so a new child language only has to fill in the fields (no per-crate tuple conventions).
pub struct EmbeddedIr<'a> {
/// The formatter IR, arena-allocated alongside its elements.
/// Its `FormatElement::TailwindClass` indices point into the session's class scope.
pub ir: ArenaVec<'a, FormatElement<'a>>,
}
impl<'a> From<EmbeddedIr<'a>> for DispatchPayload<'a> {
fn from(embedded: EmbeddedIr<'a>) -> Self {
Self { doc: embedded.ir, child_context: None }
}
}
/// The child's formatted product, carried by [`DispatchResponse::Formatted`].
pub struct DispatchPayload<'a> {
/// The formatted IR, arena-allocated alongside its elements.
/// Its `FormatElement::TailwindClass` indices already point into the parent's class scope
/// (the child session shares it), so the parent writes it as-is.
pub doc: ArenaVec<'a, FormatElement<'a>>,
/// Child→parent language-pair specific data,
/// downcast by the parent (`None` for most pairs; e.g. HTML's `has_multiple_root_elements`).
/// The owned counterpart of [`DispatchRequest::parent_context`]
/// (owned because it outlives the child's stack frame).
pub child_context: Option<Box<dyn Any>>,
pub ir: ArenaVec<'a, FormatElement<'a>>,
}
/// Dispatches one embedded fragment and hands out its doc:
@@ -125,7 +106,6 @@ pub fn dispatch_fragment_ir<'a, C: FormatContext>(
///
/// `None` covers [`DispatchResponse::PreserveOriginal`] and operational errors alike;
/// the caller keeps its original source.
/// Embed sites that must inspect [`DispatchPayload::child_context`] first stay manual.
pub fn dispatch_ir<'a, C: FormatContext>(
f: &Formatter<'_, 'a, C>,
request: DispatchRequest<'_>,
@@ -133,7 +113,7 @@ pub fn dispatch_ir<'a, C: FormatContext>(
let Ok(DispatchResponse::Formatted(result)) = f.session().dispatch(request) else {
return None;
};
Some(result.doc)
Some(result.ir)
}
/// Rebuild an embedded IR, descending into BestFitting variants and interned content.
+8 -15
View File
@@ -7,7 +7,7 @@ use std::{cell::RefCell, rc::Rc, sync::Arc};
use oxc_allocator::Allocator;
use crate::{
DispatchPayload, DispatchRequest, DispatchResponse, Document, FormatDispatcher, LineEnding,
DispatchRequest, DispatchResponse, Document, EmbeddedIr, FormatDispatcher, LineEnding,
PrinterOptions, UniqueGroupIdBuilder,
};
@@ -200,8 +200,7 @@ impl<'a> FormatSession<'a> {
) -> Result<Option<String>, String> {
let text_len = request.text.len();
let scoped = self.with_new_tailwind_scope();
let DispatchResponse::Formatted(DispatchPayload { doc, .. }) = scoped.dispatch(request)?
else {
let DispatchResponse::Formatted(EmbeddedIr { ir: doc }) = scoped.dispatch(request)? else {
return Ok(None);
};
@@ -282,8 +281,8 @@ mod tests {
use super::{FormatSession, InputKind, MAX_DISPATCH_DEPTH, SessionServices, TailwindSorter};
use crate::{
DispatchPayload, DispatchRequest, DispatchResponse, Document, FormatDispatcher,
FormatElement, FormatState, Interned, PrinterOptions,
DispatchRequest, DispatchResponse, Document, EmbeddedIr, FormatDispatcher, FormatElement,
FormatState, Interned, PrinterOptions,
};
fn request<'r>() -> DispatchRequest<'r> {
@@ -307,10 +306,7 @@ mod tests {
fn dispatch_preserves_a_bom_headed_text() {
let allocator = Allocator::default();
let dispatcher: FormatDispatcher = Arc::new(|ctx, _request| {
Ok(DispatchResponse::Formatted(DispatchPayload {
doc: ArenaVec::new_in(&ctx.allocator()),
child_context: None,
}))
Ok(DispatchResponse::Formatted(EmbeddedIr { ir: ArenaVec::new_in(&ctx.allocator()) }))
});
let session = FormatSession::with_services(
&allocator,
@@ -329,10 +325,7 @@ mod tests {
fn dispatch_stops_at_the_depth_limit() {
let allocator = Allocator::default();
let dispatcher: FormatDispatcher = Arc::new(|ctx, _request| {
Ok(DispatchResponse::Formatted(DispatchPayload {
doc: ArenaVec::new_in(&ctx.allocator()),
child_context: None,
}))
Ok(DispatchResponse::Formatted(EmbeddedIr { ir: ArenaVec::new_in(&ctx.allocator()) }))
});
let mut session = FormatSession::with_services(
&allocator,
@@ -405,7 +398,7 @@ mod tests {
[FormatElement::Interned(Interned::new(inner))],
&ctx.allocator(),
);
Ok(DispatchResponse::Formatted(DispatchPayload { doc, child_context: None }))
Ok(DispatchResponse::Formatted(EmbeddedIr { ir: doc }))
});
let session = FormatSession::with_services(
&allocator,
@@ -426,7 +419,7 @@ mod tests {
[FormatElement::TailwindClass(parent_index), FormatElement::Space],
&&allocator,
);
elements.extend(payload.doc);
elements.extend(payload.ir);
let printed = Document::new(elements, session.take_sorted_tailwind_classes())
.print(0, PrinterOptions::default())