fix(formatter): format xxx-in-js inside JSDoc js fence (#25414)

Such as CSS-in-JS-in-JSDoc_Markdown-in-JS. 😇
This commit is contained in:
leaysgur committed 2026-08-09 14:49:15 +00:00
1 parent 1a2c64a356
commit ab52a593f3
8 files changed
+193 -227

No files matched your search

+30
View File
@@ -225,6 +225,36 @@ describe("JSDoc", () => {
);
});
it("should format embedded languages inside a fenced js block", async () => {
// The JS snippet formats on the parent session, so xxx-in-js inside the fence dispatch like anywhere else
// (upstream formats these too: formatCode runs prettier.format, whose embed pass applies).
const source = `
/**
* \`\`\`js
* const s = css\`a{color: red}\`;
* \`\`\`
*/
export const a = 1;
`.trim();
const result = await format("a.ts", source, { jsdoc: {} });
expect(result.errors).toStrictEqual([]);
expect(result.code).toBe(
`
/**
* \`\`\`js
* const s = css\`
* a {
* color: red;
* }
* \`;
* \`\`\`
*/
export const a = 1;
`.trimStart(),
);
});
it("should keep fenced code verbatim for languages outside the registry", async () => {
const source = `
/**
@@ -1,30 +1,26 @@
use oxc_allocator::Allocator;
use oxc_formatter_core::LineWidth;
use oxc_formatter_core::{FormatSession, LineWidth};
use oxc_span::SourceType;
use crate::options::TrailingCommas;
use crate::{JsFormatOptions, format_program, parse_for_format};
use crate::{JsFormatOptions, format_with_session};
/// Helper for:
/// - Parse a snippet
/// - Parse a snippet ([`format_with_session`]'s gate: any parse diagnostic rejects)
/// - and format it to a string with trailing whitespace trimmed
/// - (no string embedder)
///
/// Returns `None` if parsing fails (panic or any error), the gate every embedded site uses.
/// Formats on the parent session (same arena; embeds inside the snippet
/// dispatch through the parent's services).
///
/// # Panics
/// Panics if the formatted IR is invalid or unbalanced, indicating a formatter bug.
fn parse_and_build<'a>(
allocator: &'a Allocator,
session: &FormatSession<'a>,
source_text: &'a str,
source_type: SourceType,
options: JsFormatOptions,
) -> Option<String> {
let ret = parse_for_format(allocator, source_text, source_type);
if ret.panicked || !ret.diagnostics.is_empty() {
return None;
}
let mut code = format_program(allocator, &ret.program, options).print().unwrap().into_code();
let formatted = format_with_session(session, source_text, source_type, options).ok()?;
let mut code = formatted.print().unwrap().into_code();
code.truncate(code.trim_end().len());
Some(code)
}
@@ -91,10 +87,15 @@ pub(super) fn update_template_depth(line: &str, mut depth: u32) -> u32 {
/// Format a JS/TS/JSX snippet extracted from a JSDoc comment.
///
/// For now, this is JSDoc-specific helper, NOT a generic embedded-language formatter.
/// JSDoc snippets are standalone comment fragments rather than parent-integrated IR,
/// so they go through `format_program` directly (no orchestrator round trip)
/// and return a string for line-by-line splicing into the surrounding comment.
/// This is a JSDoc-specific helper, NOT a generic embedded-language formatter.
/// JSDoc snippets are standalone comment fragments rather than parent-integrated IR:
/// the snippet itself formats in-crate on the parent session (no dispatcher round trip)
/// and returns a string for line-by-line splicing into the surrounding comment,
/// while embeds INSIDE the snippet (css-in-js, …) dispatch through the session normally.
///
/// The snippet inherits the parent session's `InputKind` and dispatch depth AS-IS
/// (the fence boundary is not a dispatch; the same interim shape as the string
/// channel's fresh-session reset on the oxfmt side).
///
/// JSDoc-specific behaviour (inline comments below give the full rationale):
/// - object-literal wrap for `{`-leading snippets
@@ -106,7 +107,7 @@ pub(super) fn format_jsdoc_js_snippet(
code: &str,
print_width: usize,
format_options: &JsFormatOptions,
allocator: &Allocator,
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();
@@ -121,7 +122,7 @@ pub(super) fn format_jsdoc_js_snippet(
// Try to parse and format with the given source type
let try_format = |code: &str, source_type: SourceType| {
parse_and_build(allocator, code, source_type, base_options.clone())
parse_and_build(session, code, source_type, base_options.clone())
};
// If code starts with `{`, try expression wrapping FIRST to handle object
@@ -129,14 +130,14 @@ pub(super) fn format_jsdoc_js_snippet(
// with labels. The upstream plugin uses `parser: "json"` for this case.
let trimmed = code.trim();
if trimmed.starts_with('{') {
let wrapped = allocator.alloc_concat_strs_array(["(", trimmed, ")"]);
let wrapped = session.allocator().alloc_concat_strs_array(["(", trimmed, ")"]);
// Use TrailingCommas::None for object literals since JSON-like code
// shouldn't have trailing commas
let obj_options =
JsFormatOptions { trailing_commas: TrailingCommas::None, ..base_options.clone() };
let try_format_obj = |code: &str, source_type: SourceType| -> Option<String> {
let formatted = parse_and_build(allocator, code, source_type, obj_options.clone())?;
let formatted = parse_and_build(session, code, source_type, obj_options.clone())?;
// Remove the wrapping parens and trailing semicolon
if let Some(inner) = formatted.strip_prefix('(')
&& let Some(inner) = inner.strip_suffix(");")
@@ -203,7 +204,7 @@ pub(super) fn format_jsdoc_js_snippet(
pub(super) fn format_type_via_formatter(
type_str: &str,
type_options: &JsFormatOptions,
allocator: &Allocator,
session: &FormatSession<'_>,
) -> Option<String> {
if type_str.is_empty() {
return None;
@@ -216,8 +217,8 @@ pub(super) fn format_type_via_formatter(
if rest.is_empty() {
return None;
}
let wrapped = allocator.alloc_concat_strs_array(["(", rest, ")[]"]);
let formatted = format_type_via_formatter(wrapped, type_options, allocator)?;
let wrapped = session.allocator().alloc_concat_strs_array(["(", rest, ")[]"]);
let formatted = format_type_via_formatter(wrapped, type_options, session)?;
let inner = formatted.strip_suffix("[]")?;
let mut result = String::with_capacity(inner.len() + 3);
result.push_str("...");
@@ -236,9 +237,9 @@ pub(super) fn format_type_via_formatter(
// Let the TS formatter handle quote conversion naturally, matching Prettier's
// TS parser which uses the configured quote style (double by default).
// No string literal protection needed — the formatter should convert quotes.
let input = allocator.alloc_concat_strs_array(["type __t = ", type_str, ";"]);
let input = session.allocator().alloc_concat_strs_array(["type __t = ", type_str, ";"]);
let formatted = parse_and_build(allocator, input, SourceType::tsx(), type_options.clone())?;
let formatted = parse_and_build(session, input, SourceType::tsx(), type_options.clone())?;
// Strip the `type __t = ` prefix (11 chars) using slice, matching upstream's
// `pretty.slice(TYPE_START.length)` approach. This handles both same-line and
@@ -7,8 +7,7 @@ use std::borrow::Cow;
use markdown::{Constructs, ParseOptions, to_mdast};
use oxc_allocator::Allocator;
use oxc_formatter_core::StringEmbedder;
use oxc_formatter_core::FormatSession;
use crate::JsFormatOptions;
@@ -33,15 +32,14 @@ pub fn format_description_mdast(
max_width: usize,
tag_string_length: usize,
capitalize: bool,
format_options: Option<&JsFormatOptions>,
allocator: Option<&Allocator>,
string_embedder: Option<&StringEmbedder>,
format_options: &JsFormatOptions,
session: &FormatSession<'_>,
) -> String {
if text.trim().is_empty() {
return String::new();
}
let jsdoc_opts = format_options.and_then(|opts| opts.jsdoc.as_ref());
let jsdoc_opts = format_options.jsdoc.as_ref();
let description_with_dot = jsdoc_opts.is_some_and(|o| o.description_with_dot);
let prefer_code_fences = jsdoc_opts.is_some_and(|o| o.prefer_code_fences);
let line_wrapping_style =
@@ -143,8 +141,7 @@ pub fn format_description_mdast(
line_wrapping_style,
source: &protected,
format_options,
allocator,
string_embedder,
session,
};
serialize_children(&root, 0, opts.tag_string_length, &opts, &mut lines);
@@ -160,7 +157,6 @@ pub(super) struct SerializeOptions<'a> {
pub(super) prefer_code_fences: bool,
pub(super) line_wrapping_style: crate::LineWrappingStyle,
pub(super) source: &'a str,
pub(super) format_options: Option<&'a JsFormatOptions>,
pub(super) allocator: Option<&'a Allocator>,
pub(super) string_embedder: Option<&'a StringEmbedder>,
pub(super) format_options: &'a JsFormatOptions,
pub(super) session: &'a FormatSession<'a>,
}
@@ -759,22 +759,21 @@ fn format_code_value<'a>(
width: usize,
opts: &SerializeOptions<'_>,
) -> Cow<'a, str> {
if let (Some(format_options), Some(allocator)) = (opts.format_options, opts.allocator) {
// For fenced code with an explicit non-JS/TS lang, try external formatter (CSS, HTML, etc.)
if let Some(l) = lang
&& !is_js_ts_lang(l)
// For fenced code with an explicit non-JS/TS lang, try external formatter (CSS, HTML, etc.)
if let Some(l) = lang
&& !is_js_ts_lang(l)
{
if let Some(embed) = opts.session.string_embedder()
&& let Ok(formatted) = embed(l, code, width)
{
if let Some(embed) = opts.string_embedder
&& let Ok(formatted) = embed(l, code, width)
{
return Cow::Owned(formatted);
}
return Cow::Borrowed(code);
}
// Fenced JS/TS, fenced with no lang, or indented code: try formatting
if let Some(formatted) = format_jsdoc_js_snippet(code, width, format_options, allocator) {
return Cow::Owned(formatted);
}
return Cow::Borrowed(code);
}
// Fenced JS/TS, fenced with no lang, or indented code: try formatting
if let Some(formatted) = format_jsdoc_js_snippet(code, width, opts.format_options, opts.session)
{
return Cow::Owned(formatted);
}
Cow::Borrowed(code)
}
@@ -1,8 +1,7 @@
use std::borrow::Cow;
use oxc_allocator::Allocator;
use oxc_ast::Comment;
use oxc_formatter_core::{LineWidth, StringEmbedder};
use oxc_formatter_core::{FormatSession, LineWidth};
use oxc_jsdoc::JSDoc;
use oxc_span::Span;
@@ -54,14 +53,15 @@ const LINE_PREFIX_LEN: usize = 3;
/// Holds the shared per-comment state for JSDoc formatting,
/// reducing parameter passing across formatting functions.
///
/// Uses two lifetimes: `'a` for the allocator (tied to output strings)
/// and `'o` for options (only need to live as long as the formatter).
/// Uses two lifetimes: `'a` for the arena (tied to output strings)
/// and `'o` for the options / session references (only need to live as long as the formatter).
pub(super) struct JsdocFormatter<'a, 'o> {
pub(super) options: &'o JsdocOptions,
pub(super) format_options: &'o JsFormatOptions,
pub(super) type_format_options: JsFormatOptions,
pub(super) allocator: &'a Allocator,
pub(super) string_embedder: Option<&'o StringEmbedder>,
/// The parent JS run's session: arena, string embedder, and (for fenced
/// JS/TS snippets) the dispatcher for embeds inside the snippet.
pub(super) session: &'o FormatSession<'a>,
pub(super) wrap_width: usize,
pub(super) content_lines: LineBuffer,
}
@@ -70,9 +70,8 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
fn new(
options: &'o JsdocOptions,
format_options: &'o JsFormatOptions,
allocator: &'a Allocator,
session: &'o FormatSession<'a>,
available_width: usize,
string_embedder: Option<&'o StringEmbedder>,
) -> Self {
let wrap_width = available_width.saturating_sub(LINE_PREFIX_LEN);
// Use commentContentPrintWidth (= wrap_width) as the line width for type
@@ -92,8 +91,7 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
options,
format_options,
type_format_options,
allocator,
string_embedder,
session,
wrap_width,
content_lines: LineBuffer::new(),
}
@@ -177,9 +175,8 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
self.wrap_width,
0,
self.options.capitalize_descriptions,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
if self.options.description_tag {
// Emit as @description tag
@@ -380,7 +377,7 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
if tmp == content {
return None;
}
let alloc_first = self.allocator.alloc_str(first);
let alloc_first = self.session.allocator().alloc_str(first);
return Some(FormattedJsdoc::SingleLine(alloc_first));
}
@@ -408,7 +405,7 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
}
// Arena-allocate only the inner content (without /** */ wrapper)
let alloc_content = self.allocator.alloc_str(content_str);
let alloc_content = self.session.allocator().alloc_str(content_str);
Some(FormattedJsdoc::MultiLine(alloc_content))
}
@@ -1069,9 +1066,7 @@ pub fn format_jsdoc_comment<'a>(
available_width: usize,
f: &JsFormatter<'_, 'a>,
) -> Option<FormattedJsdoc<'a>> {
let string_embedder = f.session().string_embedder();
let fmt =
JsdocFormatter::new(options, f.options(), f.allocator(), available_width, string_embedder);
let fmt = JsdocFormatter::new(options, f.options(), f.session(), available_width);
fmt.format(comment, source_text)
}
@@ -1160,10 +1155,15 @@ mod tests {
assert!(!should_remove_empty_tag("abstract"));
}
fn fmt_type(type_str: &str) -> Option<String> {
fn fmt_type_with_opts(type_str: &str, opts: &JsFormatOptions) -> Option<String> {
use crate::formatter::jsdoc::embedded::format_type_via_formatter;
let allocator = oxc_allocator::Allocator::default();
format_type_via_formatter(type_str, &JsFormatOptions::default(), &allocator)
let session = FormatSession::new(&allocator, oxc_formatter_core::InputKind::Fragment);
format_type_via_formatter(type_str, opts, &session)
}
fn fmt_type(type_str: &str) -> Option<String> {
fmt_type_with_opts(type_str, &JsFormatOptions::default())
}
#[test]
@@ -1182,8 +1182,6 @@ mod tests {
}
fn fmt_type_width(type_str: &str, width: u16) -> Option<String> {
use crate::formatter::jsdoc::embedded::format_type_via_formatter;
let allocator = oxc_allocator::Allocator::default();
let opts = JsFormatOptions {
line_width: LineWidth::try_from(width).unwrap(),
jsdoc: None,
@@ -1191,7 +1189,7 @@ mod tests {
sort_tailwindcss: None,
..JsFormatOptions::default()
};
format_type_via_formatter(type_str, &opts, &allocator)
fmt_type_with_opts(type_str, &opts)
}
#[test]
@@ -6,6 +6,7 @@ use super::{
embedded::{
format_jsdoc_js_snippet, format_type_via_formatter, is_js_ts_lang, update_template_depth,
},
mdast_serialize::format_description_mdast,
normalize::{
capitalize_first, normalize_markdown_emphasis, normalize_type,
normalize_type_preserve_quotes, normalize_type_return, strip_jsdoc_stars_preserve_newlines,
@@ -16,7 +17,7 @@ use super::{
should_preserve_description_verbatim, should_skip_description_formatting,
strip_default_is_suffix,
},
wrap::{str_width, wrap_text},
wrap::str_width,
};
/// Replace standalone JSDoc `*` type syntax with `any` in a multiline type string.
@@ -163,7 +164,7 @@ impl JsdocFormatter<'_, '_> {
// wrap_width minus the code indent width.
let effective_width = self.wrap_width.saturating_sub(self.code_indent_width());
if let Some(formatted) =
format_jsdoc_js_snippet(code, effective_width, self.format_options, self.allocator)
format_jsdoc_js_snippet(code, effective_width, self.format_options, self.session)
.filter(|f| {
// Reject pseudo-code that parses as valid JS but produces
// structurally different output (e.g., `{undefined}('popup', 'options')`
@@ -208,7 +209,7 @@ impl JsdocFormatter<'_, '_> {
inner_code,
effective_width,
self.format_options,
self.allocator,
self.session,
) {
self.push_formatted_code_lines(&formatted, indent);
} else {
@@ -295,7 +296,7 @@ impl JsdocFormatter<'_, '_> {
if let Some(formatted) = format_type_via_formatter(
&formatter_input,
&self.type_format_options,
self.allocator,
self.session,
) {
// If the formatter collapsed a multi-line type to single
// line, verify it fits within the available width. The tag
@@ -339,17 +340,20 @@ impl JsdocFormatter<'_, '_> {
if let Some(np) = &name_part {
let name_raw = np.raw();
if is_type_optional && !name_raw.starts_with('[') {
name_str = self.allocator.alloc_concat_strs_array(["[", name_raw, "]"]);
name_str = self.session.allocator().alloc_concat_strs_array(["[", name_raw, "]"]);
} else if name_raw.starts_with('[') && name_raw.ends_with(']') {
if let Some(eq_pos) = name_raw.find('=') {
let name_part_inner = &name_raw[1..eq_pos];
let val = name_raw[eq_pos + 1..name_raw.len() - 1].trim();
if val.is_empty() {
name_str =
self.allocator.alloc_concat_strs_array(["[", name_part_inner, "]"]);
name_str = self.session.allocator().alloc_concat_strs_array([
"[",
name_part_inner,
"]",
]);
} else {
default_value = Some(val);
name_str = self.allocator.alloc_concat_strs_array([
name_str = self.session.allocator().alloc_concat_strs_array([
"[",
name_part_inner,
"=",
@@ -408,14 +412,13 @@ impl JsdocFormatter<'_, '_> {
if !desc_raw.is_empty() {
let indent = self.continuation_indent();
let indent_width = self.wrap_width.saturating_sub(self.continuation_indent_width());
let desc = wrap_text(
let desc = format_description_mdast(
desc_raw,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
}
@@ -457,14 +460,13 @@ impl JsdocFormatter<'_, '_> {
self.content_lines.push_empty();
let indent = self.continuation_indent();
let indent_width = self.wrap_width.saturating_sub(self.continuation_indent_width());
let desc = wrap_text(
let desc = format_description_mdast(
desc_raw,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
return;
@@ -483,14 +485,13 @@ impl JsdocFormatter<'_, '_> {
self.content_lines.push_empty();
let indent = self.continuation_indent();
let indent_width = self.wrap_width.saturating_sub(self.continuation_indent_width());
let mut desc = wrap_text(
let mut desc = format_description_mdast(
desc_raw,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
// Skip leading blank line from wrap_text since we already added one
if desc.starts_with('\n') {
@@ -607,14 +608,13 @@ impl JsdocFormatter<'_, '_> {
let s = self.content_lines.begin_line();
s.push_str(&tag_line);
s.push_str(" -");
let desc = wrap_text(
let desc = format_description_mdast(
&remaining_desc,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
return;
@@ -657,26 +657,24 @@ impl JsdocFormatter<'_, '_> {
line.push_str(" -");
}
self.content_lines.push(line);
let desc = wrap_text(
let desc = format_description_mdast(
&full_desc,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
} else {
// Append description inline with tag_string_length offset
let desc = wrap_text(
let desc = format_description_mdast(
&full_desc,
indent_width,
tag_str_len,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
let mut iter = desc.split('\n');
if let Some(first) = iter.next() {
@@ -748,7 +746,7 @@ impl JsdocFormatter<'_, '_> {
if let Some(formatted) = format_type_via_formatter(
&formatter_input,
&self.type_format_options,
self.allocator,
self.session,
) {
if was_multiline && !formatted.contains('\n') {
normalized_type_str = Cow::Owned(star_stripped.unwrap());
@@ -807,7 +805,8 @@ impl JsdocFormatter<'_, '_> {
// Append single-token description to the last line of the tag
let mut lines: Vec<&str> = tag_line.split('\n').collect();
if let Some(last) = lines.last_mut() {
let combined = self.allocator.alloc_concat_strs_array([last, " ", &desc_text]);
let combined =
self.session.allocator().alloc_concat_strs_array([last, " ", &desc_text]);
for line in &lines[..lines.len() - 1] {
self.content_lines.push(*line);
}
@@ -823,14 +822,13 @@ impl JsdocFormatter<'_, '_> {
for line in lines_iter {
self.content_lines.push(line);
}
let desc = wrap_text(
let desc = format_description_mdast(
&desc_text,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
}
@@ -872,25 +870,23 @@ impl JsdocFormatter<'_, '_> {
let first_word_w = desc_text_no_dash.split_whitespace().next().map_or(0, str_width);
if prefix_len + first_word_w > self.wrap_width {
self.content_lines.push(tag_line);
let desc = wrap_text(
let desc = format_description_mdast(
&desc_text_no_dash,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
} else {
let desc = wrap_text(
let desc = format_description_mdast(
&desc_text_no_dash,
indent_width,
tag_str_len,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
let mut iter = desc.split('\n');
if let Some(first) = iter.next() {
@@ -1020,14 +1016,13 @@ impl JsdocFormatter<'_, '_> {
}
} else if skip_fmt {
// Single-line skip-formatting: wrap at full width, no continuation indent.
let mut desc = wrap_text(
let mut desc = format_description_mdast(
raw_ws_desc,
self.wrap_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
if desc.starts_with('\n') {
desc.remove(0);
@@ -1036,14 +1031,13 @@ impl JsdocFormatter<'_, '_> {
} else {
let indent = self.continuation_indent();
let indent_width = self.wrap_width.saturating_sub(self.continuation_indent_width());
let mut desc = wrap_text(
let mut desc = format_description_mdast(
&desc_text,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
// Skip leading blank line from wrap_text since we already added one
if desc.starts_with('\n') {
@@ -1060,14 +1054,13 @@ impl JsdocFormatter<'_, '_> {
self.content_lines.push(tag_line);
let indent = self.continuation_indent();
let indent_width = self.wrap_width.saturating_sub(self.continuation_indent_width());
let desc = wrap_text(
let desc = format_description_mdast(
&desc_text,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
return;
@@ -1091,14 +1084,13 @@ impl JsdocFormatter<'_, '_> {
let indent = self.continuation_indent();
let indent_width =
self.wrap_width.saturating_sub(self.continuation_indent_width());
let desc = wrap_text(
let desc = format_description_mdast(
desc_part,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
return;
@@ -1179,14 +1171,13 @@ impl JsdocFormatter<'_, '_> {
let indent_width =
self.wrap_width.saturating_sub(self.continuation_indent_width());
let tag_str_len = prefix_len.saturating_sub(self.continuation_indent_width());
let desc = wrap_text(
let desc = format_description_mdast(
raw_ws_desc,
indent_width,
tag_str_len,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
let mut iter = desc.split('\n');
if let Some(first) = iter.next() {
@@ -1220,25 +1211,23 @@ impl JsdocFormatter<'_, '_> {
let first_word_w = desc_text.split_whitespace().next().map_or(0, str_width);
if prefix_len + first_word_w > self.wrap_width {
self.content_lines.push(tag_line);
let desc = wrap_text(
let desc = format_description_mdast(
&desc_text,
indent_width,
0,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
self.push_indented_desc(indent, desc);
} else {
let desc = wrap_text(
let desc = format_description_mdast(
&desc_text,
indent_width,
tag_str_len,
false,
Some(self.format_options),
Some(self.allocator),
self.string_embedder,
self.format_options,
self.session,
);
let mut iter = desc.split('\n');
if let Some(first) = iter.next() {
@@ -454,37 +454,33 @@ fn flush_paragraph_balance(
}
}
/// Wrap text into lines, preserving structured content (lists, code blocks, tables, etc.)
/// and wrapping plain paragraphs to the given max width.
///
/// Delegates to `format_description_mdast` for full markdown-aware formatting.
pub fn wrap_text(
text: &str,
max_width: usize,
tag_string_length: usize,
capitalize: bool,
format_options: Option<&crate::JsFormatOptions>,
allocator: Option<&oxc_allocator::Allocator>,
string_embedder: Option<&oxc_formatter_core::StringEmbedder>,
) -> String {
if text.is_empty() {
return String::new();
}
super::mdast_serialize::format_description_mdast(
text,
max_width,
tag_string_length,
capitalize,
format_options,
allocator,
string_embedder,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Thin harness over `format_description_mdast` with default options
/// on a service-less `Fragment` session.
fn wrap_text(
text: &str,
max_width: usize,
tag_string_length: usize,
capitalize: bool,
) -> String {
let allocator = oxc_allocator::Allocator::default();
let session = oxc_formatter_core::FormatSession::new(
&allocator,
oxc_formatter_core::InputKind::Fragment,
);
crate::formatter::jsdoc::mdast_serialize::format_description_mdast(
text,
max_width,
tag_string_length,
capitalize,
&crate::JsFormatOptions::default(),
&session,
)
}
// Pins the JS-`.length` semantics (see `str_width` doc):
// the jsdoc conformance still passes even with `unicode_width`-based width.
#[test]
@@ -499,7 +495,7 @@ mod tests {
#[test]
fn test_wrap_simple_text() {
let result = wrap_text("This is a short line", 80, 0, false, None, None, None);
let result = wrap_text("This is a short line", 80, 0, false);
assert_eq!(result, "This is a short line");
}
@@ -510,9 +506,6 @@ mod tests {
40,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -524,8 +517,7 @@ mod tests {
#[test]
fn test_wrap_preserves_markdown_list() {
let result =
wrap_text("- item one\n- item two\n- item three", 80, 0, false, None, None, None);
let result = wrap_text("- item one\n- item two\n- item three", 80, 0, false);
assert_eq!(result, "- item one\n- item two\n- item three");
}
@@ -536,9 +528,6 @@ mod tests {
40,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -548,42 +537,27 @@ mod tests {
#[test]
fn test_wrap_converts_code_fence_to_indented() {
let result = wrap_text(
"Some text\n```\ncode here\n indented\n```\nMore text",
80,
0,
false,
None,
None,
None,
);
let result =
wrap_text("Some text\n```\ncode here\n indented\n```\nMore text", 80, 0, false);
// Fenced code without language tag is converted to indented code block.
assert_eq!(result, "Some text\n\n code here\n indented\n\nMore text");
}
#[test]
fn test_wrap_preserves_code_fence_with_language() {
let result = wrap_text(
"Some text\n```js\nconst x = 1;\n```\nMore text",
80,
0,
false,
None,
None,
None,
);
let result = wrap_text("Some text\n```js\nconst x = 1;\n```\nMore text", 80, 0, false);
assert_eq!(result, "Some text\n\n```js\nconst x = 1;\n```\n\nMore text");
}
#[test]
fn test_wrap_empty_lines() {
let result = wrap_text("Paragraph one\n\nParagraph two", 80, 0, false, None, None, None);
let result = wrap_text("Paragraph one\n\nParagraph two", 80, 0, false);
assert_eq!(result, "Paragraph one\n\nParagraph two");
}
#[test]
fn test_wrap_empty_text() {
let result = wrap_text("", 80, 0, false, None, None, None);
let result = wrap_text("", 80, 0, false);
assert!(result.is_empty());
}
@@ -591,8 +565,7 @@ mod tests {
fn test_numbered_spread_list_collapses_blank_lines() {
// Upstream prettier-plugin-jsdoc removes blank lines between list items
// even when the source has them (spread lists).
let result =
wrap_text("1. Thing 1\n\n2. Thing 2\n\n3. Thing 3", 80, 0, false, None, None, None);
let result = wrap_text("1. Thing 1\n\n2. Thing 2\n\n3. Thing 3", 80, 0, false);
assert_eq!(result, "1. Thing 1\n2. Thing 2\n3. Thing 3");
}
@@ -603,9 +576,6 @@ mod tests {
77,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -621,9 +591,6 @@ mod tests {
77,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -639,9 +606,6 @@ mod tests {
97,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -660,9 +624,6 @@ mod tests {
77,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -675,9 +636,6 @@ mod tests {
77,
0,
false,
None,
None,
None,
);
assert_eq!(
result,
@@ -690,9 +648,6 @@ mod tests {
77,
0,
false,
None,
None,
None,
);
assert!(
result.contains('\n'),
@@ -704,8 +659,7 @@ mod tests {
fn test_spread_list_collapses_blank_lines() {
// Upstream prettier-plugin-jsdoc removes blank lines between list items
// even when the source has them (spread lists).
let result =
wrap_text("- item one\n\n- item two\n\n- item three", 80, 0, false, None, None, None);
let result = wrap_text("- item one\n\n- item two\n\n- item three", 80, 0, false);
assert_eq!(result, "- item one\n- item two\n- item three");
}
@@ -715,7 +669,7 @@ mod tests {
let input =
"The options object:\n\n const result = process(options);\n console.log(result);";
// Simulate @param {object} options: tag_str_len=22, indent_width=78
let result = wrap_text(input, 78, 22, false, None, None, None);
let result = wrap_text(input, 78, 22, false);
// The indented code block should be preserved with 4-space indent
assert!(
result.contains(" const result = process(options);"),
+1 -2
View File
@@ -199,8 +199,7 @@ impl<'a> FormatSession<'a> {
}
/// The string channel service ([`StringEmbedder`]), when this run installs one.
/// (A handle rather than an invoking method: the JSDoc serializer threads it
/// through session-less layers; see the alias for the `Err`-means-verbatim contract.)
/// (A handle rather than an invoking method; see the alias for the `Err`-means-verbatim contract.)
pub fn string_embedder(&self) -> Option<&StringEmbedder> {
self.services.string_embedder.as_ref()
}