fix(formatter,oxfmt): apply effective print width for JSDoc fence (#25413)

As JSDoc is always contained within the ` * ` fenced comments, the `printWidth` option should not have been used as is.

The dedicated `jsdocPrintWidth` option has not yet been implemented. #24633
This commit is contained in:
leaysgur committed 2026-08-09 14:49:14 +00:00
1 parent ce29325cfe
commit 1a2c64a356
14 files changed
+202 -57

No files matched your search

+2 -1
View File
@@ -62,9 +62,10 @@ Per-language options are NOT built up front: `ResolvedDispatchConfig` maps them
A separate string-out channel (the session's `string_embedder` service, NOT the dispatcher) carries the string-in/string-out consumers:
- JSDoc fenced code blocks: routing follows ONE rule, the same `dispatcher::route` table
- a `Native` fence language formats through the dispatcher via a thin string adapter (`embed/fence.rs::format_native_fence`, EVERY build, the pure Rust build wires it via `services::for_root`)
- a `Native` fence language formats through `FormatSession::dispatch_to_string` via a thin string adapter (`embed/jsdoc_fence.rs::format_native_fence`, EVERY build, the pure Rust build wires it via `services::for_root`)
- md/html/angular fences stay on the Prettier string path (`embed/prettier_string.rs`, napi only; their Doc→IR conversion has unrepresentable cases);
- everything else stays verbatim
- the embedder carries the caller's effective print width; both branches honor it (native via `PrintWidth` override, Prettier via `printWidth` in the options JSON), so a fence prints at the same width a JS/TS snippet in the same position would (see `upstream-jsdoc-bugs.md` #11 for the deliberate divergence from upstream's flat `printWidth - 4`)
- temporary html-in-js string recovery (`format_js_in_html_as_fallback` in `oxc_formatter/src/print/template/embed/html.rs`): rescues the template as text when the Doc→IR conversion cannot represent the returned Doc
NOTE: These string-out channel is temporary workaround, should be replaced by native implementations and Prettier usage should be eliminated in the future.
+2 -1
View File
@@ -228,7 +228,8 @@ impl ResolvedDispatchConfig {
}
/// Printer options from the shared resolved core bundle;
/// the fence adapter ([`super::fence`]) prints dispatched child IR standalone with these.
/// the fence adapter ([`super::jsdoc_fence`]) derives its per-fence options from these
/// (width overridden to the fence's effective width).
pub fn print_options(&self) -> PrinterOptions {
self.core.as_print_options()
}
@@ -13,27 +13,27 @@ use tracing::debug_span;
use oxc_allocator::Allocator;
use oxc_formatter_core::{
DispatchOutcome, DispatchRequest, DispatchResult, Document, FormatDispatcher, FormatSession,
InputKind, SessionServices, TailwindSorter,
DispatchRequest, FormatDispatcher, FormatSession, InputKind, PrintWidth, SessionServices,
TailwindSorter,
};
use super::dispatcher::ResolvedDispatchConfig;
/// Format a JSDoc fenced code block through the native dispatch registry:
/// a string-in/string-out adapter over the IR contract.
/// a string-in/string-out adapter over `FormatSession::dispatch_to_string`.
///
/// Load-bearing notes:
/// - The fence has no parent index space, so its Tailwind classes are sorted here
/// (element-wise: the sorter reorders classes WITHIN each collected string, never the vector,
/// keeping `TailwindClass(index)` references valid). The pure build passes no sorter.
/// - `Err` keeps the fence verbatim, covering both `PreserveOriginal`
/// (parse failure; a failed native language never re-routes to Prettier) and operational errors.
/// - `print_width` is the fence's effective width at its comment position;
/// it overrides the configured width, the other print knobs come from the resolved config
/// - `Err` keeps the fence verbatim, covering both the deliberate keep
/// (`Ok(None)`; a failed native language never re-routes to Prettier) and operational errors
/// - The session-less `StringEmbedder` contract forces a fresh root session per fence,
/// so `dispatch_depth` resets at this string boundary (inert today: no native fence language re-dispatches).
/// Threading the parent session through the callback is the eventual fix.
pub fn format_native_fence(
language: &str,
code: &str,
print_width: usize,
fence_dispatcher: &FormatDispatcher,
dispatch_config: &ResolvedDispatchConfig,
sort_tailwind: Option<&TailwindSorter>,
@@ -49,29 +49,25 @@ pub fn format_native_fence(
..SessionServices::default()
},
);
let outcome = session.dispatch(DispatchRequest {
language,
text: code,
input_kind: InputKind::Fragment,
parent_context: None,
})?;
let DispatchOutcome::Formatted(result) = outcome else {
return Err(format!("Native formatter for '{language}' kept the input as-is"));
};
// The parent-less consumer: no index space to remap into,
// so the classes sort locally instead of going through `into_doc`.
let DispatchResult { doc: ir, tailwind_classes, .. } = result;
let tailwind_classes = session.sort_tailwind_classes(tailwind_classes);
let mut code = Document::new(ir, tailwind_classes)
.print(code.len(), dispatch_config.print_options())
.map_err(|err| err.to_string())?
.into_code();
// The block is re-embedded line-by-line into the comment; no trailing newline
code.truncate(code.trim_end().len());
Ok(code)
let printer_options = dispatch_config
.print_options()
.with_print_width(PrintWidth::new(u32::try_from(print_width).unwrap_or(u32::MAX)));
session
.dispatch_to_string(
DispatchRequest {
language,
text: code,
input_kind: InputKind::Fragment,
parent_context: None,
},
printer_options,
)?
.map(|mut code| {
// The block is re-embedded line-by-line into the comment; no trailing newline
code.truncate(code.trim_end().len());
code
})
.ok_or_else(|| format!("Native formatter for '{language}' kept the input as-is"))
})
}
+2 -2
View File
@@ -9,7 +9,7 @@
//! `ResolvedDispatchConfig` (lazy per-language options) + `build_dispatcher` with a Rust branch per `NativeLanguage`;
//! IR integrates into the parent's arena / `GroupId` space
//! - [`services`] (every build): the root `SessionServices` assembly (`for_root`, one definition per build)
//! - [`fence`] (every build): the JSDoc native-fence string adapter over the registry
//! - [`jsdoc_fence`] (every build): the JSDoc native-fence string adapter over the registry
//! - [`prettier_doc`] (napi only): Prettier Doc→IR path for the `Route::Prettier` set
//! - [`prettier_string`] (napi only): the Prettier string paths of the string-out channel
//! (md/html/angular JSDoc fences + the html-in-js string recovery; results re-embed line-by-line)
@@ -21,7 +21,7 @@ use std::sync::Arc;
use serde_json::Value;
pub mod dispatcher;
pub mod fence;
pub mod jsdoc_fence;
#[cfg(feature = "napi")]
pub mod prettier_doc;
#[cfg(feature = "napi")]
+8 -5
View File
@@ -7,7 +7,7 @@
//! so the parent re-requests the same HTML via this string channel and substitutes placeholders back to `${expr}`.
//!
//! Fence routing follows ONE rule, the shared routing table ([`dispatcher::route`]):
//! a `Route::Native` language formats through the dispatcher via [`super::fence::format_native_fence`] (the build-independent adapter);
//! a `Route::Native` language formats through the dispatcher via [`super::jsdoc_fence::format_native_fence`] (the build-independent adapter);
//! the `Route::Prettier` set (md/html/angular) stays on the Prettier string path
//! (their Doc→IR conversion has unrepresentable cases,
//! so forcing them through the dispatcher would regress to verbatim; the wall falls with the HTML Rust port).
@@ -26,9 +26,9 @@ use crate::core::{
embed::{
FormatEmbeddedWithConfigCallback,
dispatcher::{self, Route},
fence,
jsdoc_fence,
},
options::inject_parser,
options::{inject_parser, inject_print_width},
};
/// Build the napi build's string embedder installed on the session.
@@ -49,13 +49,14 @@ pub fn build_string_embedder(
// Fence dispatchers never take the Prettier fallback (native fences never fall back),
// so one is invariant across the callback's lifetime: build it once, not per fence.
let fence_dispatcher = dispatcher::build_dispatcher(Arc::clone(&dispatch_config), None);
Arc::new(move |language: &str, code: &str| {
Arc::new(move |language: &str, code: &str, print_width: usize| {
let parser_name = match dispatcher::route(language) {
// Native branch (JSDoc fenced code blocks): through the dispatcher, never Prettier.
Route::Native(_) => {
return fence::format_native_fence(
return jsdoc_fence::format_native_fence(
language,
code,
print_width,
&fence_dispatcher,
&dispatch_config,
sort_tailwind.as_ref(),
@@ -71,6 +72,8 @@ pub fn build_string_embedder(
// because there may be multiple embedded sections in one JS/TS file.
let mut options = dispatch_config.external_options().clone();
inject_parser(&mut options, parser_name);
// The same effective width the native branch prints at
inject_print_width(&mut options, print_width);
(format_embedded)(options, code)
.map(|mut code| {
// Remove trailing newline added by Prettier without allocation.
+3 -2
View File
@@ -34,10 +34,11 @@ pub fn for_root(dispatch_config: &Arc<ResolvedDispatchConfig>) -> SessionService
let string_embedder = dispatcher.as_ref().map(|dispatcher| {
let fence_dispatcher = Arc::clone(dispatcher);
let dispatch_config = Arc::clone(dispatch_config);
Arc::new(move |language: &str, code: &str| {
super::fence::format_native_fence(
Arc::new(move |language: &str, code: &str, print_width: usize| {
super::jsdoc_fence::format_native_fence(
language,
code,
print_width,
&fence_dispatcher,
&dispatch_config,
None,
+1 -1
View File
@@ -34,6 +34,6 @@ pub use to_oxc_toml::to_oxc_toml;
#[cfg(feature = "napi")]
pub use to_prettier::{
build_external_options, inject_filepath, inject_oxfmt_plugin_payload, inject_parser,
inject_svelte_plugin_payload, inject_tailwind_plugin_payload, to_prettier,
inject_print_width, inject_svelte_plugin_payload, inject_tailwind_plugin_payload, to_prettier,
};
pub use validate::{ValidatedOptions, validate};
@@ -159,6 +159,11 @@ pub fn inject_parser(opts: &mut Value, parser_name: &str) {
as_object_mut(opts).insert("parser".to_string(), Value::String(parser_name.to_string()));
}
/// Inject `printWidth` key, overriding the configured one.
pub fn inject_print_width(opts: &mut Value, print_width: usize) {
as_object_mut(opts).insert("printWidth".to_string(), Value::from(print_width));
}
/// Inject `filepath` key.
///
/// Some plugins (Tailwind sorter, etc.) depend on it.
+80
View File
@@ -145,6 +145,86 @@ describe("JSDoc", () => {
);
});
it("should print native fences at the fence's effective width, not the full printWidth", async () => {
// Effective width at top level = printWidth(100) - " * "(3) - 4 = 93,
// the same width JS/TS snippets in the same position already use.
// The 94-char field line must break; the 92-char one must not.
// (Upstream `prettier-plugin-jsdoc` uses a flat printWidth - 4, which overflows `printWidth` for indented comments;
// see tasks/prettier_conformance/jsdoc/upstream-jsdoc-bugs.md)
const source = `
/**
* \`\`\`graphql
* query { fieldName(argOne: "value1", argTwo: "value2", argThree: "value3", four: 4444, five: 5555577) }
* \`\`\`
*
* \`\`\`graphql
* query { fieldName(argOne: "value1", argTwo: "value2", argThree: "value3", four: 4444, five: 55555) }
* \`\`\`
*/
`.trim();
const result = await format("a.ts", source, { jsdoc: {} });
expect(result.errors).toStrictEqual([]);
expect(result.code).toBe(
`
/**
* \`\`\`graphql
* query {
* fieldName(
* argOne: "value1"
* argTwo: "value2"
* argThree: "value3"
* four: 4444
* five: 5555577
* )
* }
* \`\`\`
*
* \`\`\`graphql
* query {
* fieldName(argOne: "value1", argTwo: "value2", argThree: "value3", four: 4444, five: 55555)
* }
* \`\`\`
*/
`.trimStart(),
);
});
it("should pass the fence's effective width to the Prettier string path too", async () => {
// Same 93-char boundary as the native case, exercised through the Prettier
// branch (html fences go to Prettier with `printWidth` injected):
// the 96-char element must break, the 93-char one must not.
const source = `
/**
* \`\`\`html
* <section class="wrapper-xl" data-role="banner" id="hero"><span>hello world text</span></section>
* \`\`\`
*
* \`\`\`html
* <section class="wrapper" data-role="banner" id="hero"><span>hello world text</span></section>
* \`\`\`
*/
`.trim();
const result = await format("a.ts", source, { jsdoc: {} });
expect(result.errors).toStrictEqual([]);
expect(result.code).toBe(
`
/**
* \`\`\`html
* <section class="wrapper-xl" data-role="banner" id="hero">
* <span>hello world text</span>
* </section>
* \`\`\`
*
* \`\`\`html
* <section class="wrapper" data-role="banner" id="hero"><span>hello world text</span></section>
* \`\`\`
*/
`.trimStart(),
);
});
it("should keep fenced code verbatim for languages outside the registry", async () => {
const source = `
/**
@@ -765,14 +765,9 @@ fn format_code_value<'a>(
&& !is_js_ts_lang(l)
{
if let Some(embed) = opts.string_embedder
&& let Ok(formatted) = embed(l, code)
&& let Ok(formatted) = embed(l, code, width)
{
let mut result = formatted;
// Trim trailing newline that Prettier adds
if result.ends_with('\n') {
result.pop();
}
return Cow::Owned(result);
return Cow::Owned(formatted);
}
return Cow::Borrowed(code);
}
@@ -286,7 +286,10 @@ fn format_js_in_html_as_fallback<'a>(
expressions: &[&AstNode<'a, Expression<'a>>],
f: &mut JsFormatter<'_, 'a>,
) -> bool {
let Some(Ok(formatted)) = f.session().string_embedder().map(|embed| embed("html", joined))
// Configured width; the template's indent is not subtracted (recovery path, matches prior behavior)
let print_width = usize::from(f.options().line_width.value());
let Some(Ok(formatted)) =
f.session().string_embedder().map(|embed| embed("html", joined, print_width))
else {
return false;
};
+2 -1
View File
@@ -87,7 +87,8 @@ The core is parameterized over a consumer-supplied context so it stays language-
- The printer's `debug_assert` backstops any hand-rolled consumption that skips the merge
- `FormatSession` (`session.rs`) is the execution unit:
- One arena, one shared `GroupId` space (`Arc<UniqueGroupIdBuilder>`), the host's `SessionServices`, and the input's envelope semantics (`InputKind`), usable by standalone roots and dispatched children alike
- `SessionServices` names the three per-run duties, one field each: `dispatcher` (IR channel), `string_embedder` (string-out channel; temporary while some languages only format via a string API), `tailwind_sorter` (print-time batch sort). Core only transports them
- `SessionServices` names the three per-run duties, one field each: `dispatcher` (IR channel), `string_embedder` (string-out channel, `(language, code, print_width)`; temporary while some languages only format via a string API), `tailwind_sorter` (print-time batch sort). Core only transports them
- `FormatSession::dispatch_to_string(request, printer_options)` is the string-out counterpart of `dispatch` (caller supplies the printer options; `Ok(None)` = deliberate keep; see its rustdoc)
- `FormatState` holds one (`new_with_session`; plain `new` wraps a service-less `PhysicalFile` session), and `Formatter::session()` exposes it during a write
- A dispatch states its request as `DispatchRequest` (language, text, `InputKind`, pair-specific context) and yields `Result<DispatchOutcome, String>`:
- `DispatchOutcome::PreserveOriginal` is the DELIBERATE "keep the source as-is" answer (unsupported language, child parse failure, no dispatcher installed); `Err` is reserved for operational failures (transport / internal, recursion limit)
+45 -5
View File
@@ -4,20 +4,25 @@ use std::sync::Arc;
use oxc_allocator::Allocator;
use crate::{DispatchOutcome, DispatchRequest, FormatDispatcher, UniqueGroupIdBuilder};
use crate::{
DispatchOutcome, DispatchRequest, DispatchResult, Document, FormatDispatcher, PrinterOptions,
UniqueGroupIdBuilder,
};
/// Upper bound on nested embedded dispatches;
/// exceeding it is an operational error, catching accidental A-in-B-in-A infinite recursion.
/// Real nesting is shallow (css-in-md-in-js would be 3); 8 is a generous ceiling.
const MAX_DISPATCH_DEPTH: u8 = 8;
/// String-in/string-out embedded formatter: `(language, code)` to formatted code.
/// String-in/string-out embedded formatter: `(language, code, print_width)` to formatted code.
///
/// The string-out channel's session-carried contract,
/// for consumers whose result must come back as TEXT
/// The string-out channel's session-carried contract, for consumers whose result must come back as TEXT
/// (a JSDoc fence re-embedded line-by-line into a comment, the html-in-js temporary recovery path).
/// `print_width` is the caller's effective width at the embedding position
/// (e.g. a JSDoc fence's comment-content width minus the fence indent).
/// The returned text carries no trailing whitespace (implementations trim before returning).
/// `Err` means "keep the input verbatim".
pub type StringEmbedder = Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;
pub type StringEmbedder = Arc<dyn Fn(&str, &str, usize) -> Result<String, String> + Send + Sync>;
/// Print-time batch sorter for the classes referenced by `FormatElement::TailwindClass(index)`;
/// runs once when a root formatter finalizes its `Document`.
@@ -141,6 +146,41 @@ impl<'a> FormatSession<'a> {
dispatcher(&child, request)
}
/// Formats one embedded request and prints the result to text:
/// dispatch → local Tailwind sort → [`Document`] print.
///
/// The string-out counterpart of [`Self::dispatch`], for consumers whose result
/// must come back as TEXT. The caller supplies `printer_options`, typically its own
/// print options with the embedding position's effective width, and owns any
/// re-embedding conventions (e.g. trailing-newline handling) on the returned text.
/// A text consumer has no parent index space, so Tailwind classes sort locally
/// through this session's sorter instead of merging upward.
///
/// Returns `Ok(None)` when the dispatch deliberately preserved the input
/// (see [`DispatchOutcome::PreserveOriginal`]); the caller keeps its original text.
///
/// # Errors
/// Operational failures only (dispatch transport, recursion limit, print).
pub fn dispatch_to_string(
&self,
request: DispatchRequest<'_>,
printer_options: PrinterOptions,
) -> Result<Option<String>, String> {
let text_len = request.text.len();
let DispatchOutcome::Formatted(DispatchResult { doc, tailwind_classes, .. }) =
self.dispatch(request)?
else {
return Ok(None);
};
let tailwind_classes = self.sort_tailwind_classes(tailwind_classes);
let code = Document::new(doc, tailwind_classes)
.print(text_len, printer_options)
.map_err(|err| err.to_string())?
.into_code();
Ok(Some(code))
}
/// The arena shared between the root and every embedded child.
#[inline]
pub fn allocator(&self) -> &'a Allocator {
@@ -303,3 +303,22 @@ The plugin has two bugs with `@typedef{type}` (no space before brace):
```
**Found in**: conformance tests (main/011-bad-defined-name)
## 11. Fenced-code width base ignores the comment prefix, exceeding printWidth in indented comments
**Severity**: Low — fence content in indented comments can exceed printWidth
The plugin formats fenced code (and `@example` bodies) at a flat `printWidth - 4`
(`utils.ts` `formatCode`: `examplePrintWith = printWidth - 4`), without subtracting the
comment's indent or the `*` line prefix. At top level the re-embedded line lands at
`content + 3 <= printWidth - 1`, but for a comment indented by N columns the final line is
`N + 3 + (printWidth - 4)`, exceeding printWidth for any N > 1.
**Root cause**: `formatCode` receives only `printWidth`; the re-embedding indent
(`beginningSpace`) is applied after formatting and never enters the width budget.
**oxfmt behavior**: fences format at `effective width - 4`, where the effective width already
subtracts the comment indent and the `*` prefix — the same base every JS/TS snippet in the
same position uses. Final lines never exceed `printWidth - 4 + prefix`. At top level this is
3 chars narrower than upstream (content 93 vs 96 at printWidth=100); pinned by
`apps/oxfmt/test/api/jsdoc.test.ts` ("effective width" case).