fix(formatter): print JSDoc embedded snippets with LF (#27821)

Otherwise, CRLF is appeared in IR, then panics.
This commit is contained in:
leaysgur committed 2026-10-16 10:17:39 +00:00
1 parent 2c1cc20fa4
commit a64677e2a7
9 files changed
+91 -40

No files matched your search

+3 -3
View File
@@ -21,7 +21,7 @@ use oxc_formatter_core::{StringEmbedder, TailwindSorter};
use crate::core::{
embed::{FormatEmbeddedWithConfigCallback, dispatcher, jsdoc_fence},
language::{Route, route_embedded},
options::inject_print_width,
options::inject_string_embed_options,
};
/// Build the napi build's string embedder installed on the session.
@@ -62,8 +62,8 @@ pub fn build_string_embedder(
let Some(mut options) = dispatch_config.prettier_options_for(prettier_language) else {
return Err(format!("No plugin enabled for parser: {parser_name}"));
};
// The same effective width the native branch prints at
inject_print_width(&mut options, print_width);
// The same effective width and line ending the native branch prints with
inject_string_embed_options(&mut options, print_width);
(format_embedded)(options, code)
.map(|mut code| {
// Remove trailing newline added by Prettier without allocation.
+2 -2
View File
@@ -37,7 +37,7 @@ pub use to_oxc_formatter_yaml::to_oxc_formatter_yaml;
#[cfg(feature = "napi")]
pub use to_prettier::{
build_prettier_options, inject_filepath, inject_opt_in_plugin_payloads,
inject_oxfmt_plugin_payload, inject_parser, inject_print_width, inject_tailwind_plugin_payload,
to_prettier,
inject_oxfmt_plugin_payload, inject_parser, inject_string_embed_options,
inject_tailwind_plugin_payload, to_prettier,
};
pub use validate::{ValidatedOptions, validate};
+7 -3
View File
@@ -172,9 +172,13 @@ 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 the keys for a string re-embedded into the parent's IR, overriding the configured ones:
/// - `printWidth`: the effective width at the embedding position
/// - `endOfLine`: `lf`, since the IR only takes `\n`
pub fn inject_string_embed_options(opts: &mut Value, print_width: usize) {
let opts = as_object_mut(opts);
opts.insert("printWidth".to_string(), Value::from(print_width));
opts.insert("endOfLine".to_string(), Value::from("lf"));
}
/// Inject `filepath` key.
@@ -1,4 +1,4 @@
use oxc_formatter_core::{FormatSession, LineWidth};
use oxc_formatter_core::{FormatSession, LineEnding, LineWidth};
use oxc_span::SourceType;
use crate::options::TrailingCommas;
@@ -25,6 +25,22 @@ fn parse_and_build<'a>(
Some(code)
}
/// Options for a JS/TS snippet formatted to a string and re-embedded into the comment text.
///
/// - `line_ending` is `Lf`: the comment is IR text which only takes `\n`, the host converts once at the end
/// - `jsdoc` / `sort_imports` / `sort_tailwindcss` are disabled (top-level concerns)
pub(super) fn snippet_options(format_options: &JsFormatOptions, width: usize) -> JsFormatOptions {
let width = u16::try_from(width).unwrap_or(80).clamp(1, LineWidth::MAX);
JsFormatOptions {
line_ending: LineEnding::Lf,
line_width: LineWidth::try_from(width).unwrap(),
jsdoc: None,
sort_imports: None,
sort_tailwindcss: None,
..format_options.clone()
}
}
/// Returns `true` if the fenced code block language is JS/TS/JSX/TSX (case-insensitive).
pub(super) fn is_js_ts_lang(lang: &str) -> bool {
["js", "javascript", "jsx", "ts", "typescript", "tsx"]
@@ -111,16 +127,7 @@ pub(super) fn format_jsdoc_js_snippet(
format_options: &JsFormatOptions,
session: &FormatSession<'_>,
) -> Option<String> {
let width = u16::try_from(print_width).unwrap_or(80).clamp(1, 320);
let line_width = LineWidth::try_from(width).unwrap();
let base_options = JsFormatOptions {
line_width,
jsdoc: None,
sort_imports: None,
sort_tailwindcss: None,
..format_options.clone()
};
let base_options = snippet_options(format_options, print_width);
// Try to parse and format with the given source type
let try_format = |code: &str, source_type: SourceType| {
@@ -1,7 +1,7 @@
use std::borrow::Cow;
use oxc_ast::Comment;
use oxc_formatter_core::{FormatSession, LineWidth};
use oxc_formatter_core::FormatSession;
use oxc_jsdoc::JSDoc;
use oxc_span::Span;
@@ -10,7 +10,7 @@ use crate::options::{JsdocOptions, QuoteStyle};
use crate::{JsFormatOptions, write};
use super::{
imports::process_import_tags, line_buffer::LineBuffer,
embedded::snippet_options, imports::process_import_tags, line_buffer::LineBuffer,
mdast_serialize::format_description_mdast, normalize::normalize_tag_kind,
param_order::reorder_param_tags,
};
@@ -80,14 +80,7 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
// `commentContentPrintWidth` to Prettier's TS formatter. This lets the
// formatter wrap complex types (object literals, function types) across
// multiple lines when they exceed the available width.
let type_width = u16::try_from(wrap_width).unwrap_or(80).clamp(1, LineWidth::MAX);
let type_format_options = JsFormatOptions {
line_width: LineWidth::try_from(type_width).unwrap(),
jsdoc: None,
sort_imports: None,
sort_tailwindcss: None,
..format_options.clone()
};
let type_format_options = snippet_options(format_options, wrap_width);
Self {
options,
format_options,
@@ -1179,14 +1172,7 @@ mod tests {
}
fn fmt_type_width(type_str: &str, width: u16) -> Option<String> {
let opts = JsFormatOptions {
line_width: LineWidth::try_from(width).unwrap(),
jsdoc: None,
sort_imports: None,
sort_tailwindcss: None,
..JsFormatOptions::default()
};
fmt_type_with_opts(type_str, &opts)
fmt_type_with_opts(type_str, &snippet_options(&JsFormatOptions::default(), width.into()))
}
#[test]
@@ -0,0 +1,6 @@
/**
* @returns {{ aaaaaaaaaaaaaaaa: string; bbbbbbbbbbbbbbbbbb: number }} x
* @example
* const x = { aaaaaaaaaaaaaaaa: 1, bbbbbbbbbbbbbbbbbb: 2 };
*/
function f() {}
@@ -0,0 +1,46 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
/**
* @returns {{ aaaaaaaaaaaaaaaa: string; bbbbbbbbbbbbbbbbbb: number }} x
* @example
* const x = { aaaaaaaaaaaaaaaa: 1, bbbbbbbbbbbbbbbbbb: 2 };
*/
function f() {}
==================== Output ====================
--------------------------------------------------
{ endOfLine: "crlf", jsdoc: true, printWidth: 40 }
--------------------------------------------------
/**
* @example
* const x = { aaaaaaaaaaaaaaaa: 1, bbbbbbbbbbbbbbbbbb: 2 };
* @returns {{
* aaaaaaaaaaaaaaaa: string;
* bbbbbbbbbbbbbbbbbb: number;
* }} X
*/
function f() {}
--------------------------------------------------
{ endOfLine: "crlf", jsdoc: true, printWidth: 80 }
--------------------------------------------------
/**
* @example
* const x = { aaaaaaaaaaaaaaaa: 1, bbbbbbbbbbbbbbbbbb: 2 };
* @returns {{ aaaaaaaaaaaaaaaa: string; bbbbbbbbbbbbbbbbbb: number }} X
*/
function f() {}
---------------------------------------------------
{ endOfLine: "crlf", jsdoc: true, printWidth: 100 }
---------------------------------------------------
/**
* @example
* const x = { aaaaaaaaaaaaaaaa: 1, bbbbbbbbbbbbbbbbbb: 2 };
* @returns {{ aaaaaaaaaaaaaaaa: string; bbbbbbbbbbbbbbbbbb: number }} X
*/
function f() {}
===================== End =====================
@@ -0,0 +1 @@
[{ "jsdoc": true, "endOfLine": "crlf", "printWidth": 40 }]
+4 -3
View File
@@ -7,8 +7,8 @@ use std::{cell::RefCell, rc::Rc, sync::Arc};
use oxc_allocator::Allocator;
use crate::{
DispatchPayload, DispatchRequest, DispatchResponse, Document, FormatDispatcher, PrinterOptions,
UniqueGroupIdBuilder,
DispatchPayload, DispatchRequest, DispatchResponse, Document, FormatDispatcher, LineEnding,
PrinterOptions, UniqueGroupIdBuilder,
};
/// Upper bound on nested embedded dispatches;
@@ -184,6 +184,7 @@ impl<'a> FormatSession<'a> {
/// 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.
/// The line ending is always `Lf`: the text goes back into the parent's IR, which only takes `\n`.
/// A text consumer has no parent index space, so the child's Tailwind classes
/// collect in a fresh scope and sort locally instead of joining this session's scope.
///
@@ -205,7 +206,7 @@ impl<'a> FormatSession<'a> {
};
let code = Document::new(doc, scoped.take_sorted_tailwind_classes())
.print(text_len, printer_options)
.print(text_len, printer_options.with_line_ending(LineEnding::Lf))
.map_err(|err| err.to_string())?
.into_code();
Ok(Some(code))