Skip to content

Commit 23ca71a

Browse files
Dunqingclaude
andcommitted
perf(formatter): optimize JSDoc formatting hot paths
- Skip process_import_tags when no @import tags present - Use streaming byte comparison (compare_multiline_jsdoc) to avoid allocating a temp String for unchanged-comment detection - Replace str_width() with len() for ASCII-only tag lines and separators - Track has_import_tags during tag collection to avoid unnecessary work Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 821d868 commit 23ca71a

File tree

2 files changed

+79
-34
lines changed

2 files changed

+79
-34
lines changed

crates/oxc_formatter/src/formatter/jsdoc/serialize.rs

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,11 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
128128
if !desc_trimmed.is_empty() {
129129
merged_desc.push_str(desc_trimmed);
130130
}
131-
// Collect effective tags, absorbing @description tag content
131+
// Collect effective tags, absorbing @description tag content.
132+
// Track whether @import tags exist to skip import processing (common case).
132133
let mut effective_tags: Vec<(&oxc_jsdoc::parser::JSDocTag<'_>, &str)> =
133134
Vec::with_capacity(sorted_tags.len());
135+
let mut has_import_tags = false;
134136
for (tag, normalized_kind) in &sorted_tags {
135137
if should_remove_empty_tag(normalized_kind) && !tag_has_content(tag) {
136138
continue;
@@ -146,6 +148,9 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
146148
}
147149
continue;
148150
}
151+
if *normalized_kind == "import" {
152+
has_import_tags = true;
153+
}
149154
effective_tags.push((tag, normalized_kind));
150155
}
151156

@@ -172,9 +177,15 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
172177
// Reorder @param tags to match the function signature order
173178
reorder_param_tags(&mut effective_tags, comment, source_text);
174179

175-
// Pre-process @import tags: merge by module, sort, format
176-
let (mut import_lines, parsed_import_indices) = process_import_tags(&effective_tags);
177-
let has_imports = !import_lines.is_empty();
180+
// Pre-process @import tags: merge by module, sort, format.
181+
// Skip entirely when no @import tags exist (common case) to avoid allocation.
182+
let (mut import_lines, parsed_import_indices) = if has_import_tags {
183+
let (lines, indices) = process_import_tags(&effective_tags);
184+
(Some(lines), indices)
185+
} else {
186+
(None, smallvec::SmallVec::new())
187+
};
188+
let has_imports = import_lines.as_ref().is_some_and(|l| !l.is_empty());
178189
let mut imports_emitted = false;
179190

180191
// Format tags
@@ -189,8 +200,7 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
189200
if !self.content_lines.is_empty() && !self.content_lines.last_is_empty() {
190201
self.content_lines.push_empty();
191202
}
192-
let import_str =
193-
std::mem::replace(&mut import_lines, LineBuffer::new()).into_string();
203+
let import_str = import_lines.take().unwrap().into_string();
194204
self.content_lines.push(import_str);
195205
imports_emitted = true;
196206
prev_normalized_kind = Some("import");
@@ -322,26 +332,9 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
322332
return Some(FormattedJsdoc::SingleLine(alloc_first));
323333
}
324334

325-
// Build temp string with `/** ... */` wrapper for comparison against original
326-
let capacity =
327-
content_str.len() + content_str.bytes().filter(|&b| b == b'\n').count() * 4 + 10;
328-
let mut tmp = String::with_capacity(capacity);
329-
tmp.push_str("/**");
330-
331-
for line in std::iter::once(first).chain(second).chain(iter) {
332-
tmp.push('\n');
333-
if line.is_empty() {
334-
tmp.push_str(" *");
335-
} else {
336-
tmp.push_str(" * ");
337-
tmp.push_str(line);
338-
}
339-
}
340-
tmp.push('\n');
341-
tmp.push_str(" */");
342-
343-
// Compare with original — if unchanged, return None
344-
if tmp == content {
335+
// Compare with original without allocating a temp string.
336+
// Stream-compare the formatted output against `content` byte-by-byte.
337+
if compare_multiline_jsdoc(content, first, second, iter) {
345338
return None;
346339
}
347340

@@ -450,6 +443,57 @@ impl<'a, 'o> JsdocFormatter<'a, 'o> {
450443
}
451444
}
452445

446+
/// Compare a multi-line JSDoc comment's formatted content against the original source
447+
/// without allocating a temp string. Returns `true` if the formatted output would be
448+
/// identical to `original`.
449+
fn compare_multiline_jsdoc<'a>(
450+
original: &str,
451+
first_line: &'a str,
452+
second_line: Option<&'a str>,
453+
rest: impl Iterator<Item = &'a str>,
454+
) -> bool {
455+
let original = original.as_bytes();
456+
let mut pos = 0;
457+
458+
// Match "/**"
459+
if original.get(pos..pos + 3).is_none_or(|s| s != b"/**") {
460+
return false;
461+
}
462+
pos += 3;
463+
464+
// Match each content line: "\n * {line}" or "\n *" for empty lines
465+
for line in std::iter::once(first_line).chain(second_line).chain(rest) {
466+
if pos >= original.len() || original[pos] != b'\n' {
467+
return false;
468+
}
469+
pos += 1;
470+
if line.is_empty() {
471+
if original.get(pos..pos + 2).is_none_or(|s| s != b" *") {
472+
return false;
473+
}
474+
pos += 2;
475+
} else {
476+
if original.get(pos..pos + 3).is_none_or(|s| s != b" * ") {
477+
return false;
478+
}
479+
pos += 3;
480+
let line_bytes = line.as_bytes();
481+
if original.get(pos..pos + line_bytes.len()).is_none_or(|s| s != line_bytes) {
482+
return false;
483+
}
484+
pos += line_bytes.len();
485+
}
486+
}
487+
488+
// Match "\n */"
489+
if original.get(pos..pos + 4).is_none_or(|s| s != b"\n */") {
490+
return false;
491+
}
492+
pos += 4;
493+
494+
pos == original.len()
495+
}
496+
453497
/// Trim trailing whitespace from an owned `String` in place, avoiding a reallocation.
454498
pub(super) fn truncate_trim_end(s: &mut String) {
455499
let trimmed_len = s.trim_end().len();

crates/oxc_formatter/src/formatter/jsdoc/tag_formatters.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,8 @@ impl JsdocFormatter<'_, '_> {
327327
let desc_raw = desc_raw.trim();
328328

329329
if desc_raw.is_empty() && default_value.is_none() {
330-
// Try type wrapping if line is too long
331-
if str_width(&tag_line) > self.wrap_width
330+
// Try type wrapping if line is too long (tag_line is ASCII)
331+
if tag_line.len() > self.wrap_width
332332
&& !normalized_type_str.is_empty()
333333
&& self.wrap_type_expression(
334334
&tag_line[..tag_prefix_len],
@@ -413,8 +413,9 @@ impl JsdocFormatter<'_, '_> {
413413
};
414414
let has_remaining = !remaining_desc.trim().is_empty();
415415

416-
// Compute one-liner length without allocating
417-
let prefix_len = str_width(&tag_line) + str_width(separator);
416+
// Compute one-liner length without allocating.
417+
// tag_line is always ASCII (@kind + {type} + name), use len() directly.
418+
let prefix_len = tag_line.len() + separator.len();
418419
let one_liner_len = if has_remaining {
419420
prefix_len + str_width(&first_text)
420421
} else if let Some(ds_len) = default_suffix_len {
@@ -605,8 +606,8 @@ impl JsdocFormatter<'_, '_> {
605606
let desc_text = desc_text.trim();
606607

607608
if desc_text.is_empty() {
608-
// Try type wrapping if line is too long
609-
if str_width(&tag_line) > self.wrap_width
609+
// Try type wrapping if line is too long (tag_line is ASCII)
610+
if tag_line.len() > self.wrap_width
610611
&& !normalized_type_str.is_empty()
611612
&& self.wrap_type_expression(&tag_line[..tag_prefix_len], &normalized_type_str, "")
612613
{
@@ -619,15 +620,15 @@ impl JsdocFormatter<'_, '_> {
619620
let desc_text: Cow<'_, str> =
620621
if should_capitalize { capitalize_first(desc_text) } else { Cow::Borrowed(desc_text) };
621622

622-
let prefix_len = str_width(&tag_line) + 1; // tag_line + " "
623+
let prefix_len = tag_line.len() + 1; // tag_line is ASCII, + " "
623624
let one_liner_len = prefix_len + str_width(&desc_text);
624625
if one_liner_len <= self.wrap_width {
625626
let s = self.content_lines.begin_line();
626627
s.push_str(&tag_line);
627628
s.push(' ');
628629
s.push_str(&desc_text);
629630
} else if !normalized_type_str.is_empty()
630-
&& str_width(&tag_line) > self.wrap_width
631+
&& tag_line.len() > self.wrap_width
631632
&& self.wrap_type_expression(&tag_line[..tag_prefix_len], &normalized_type_str, "")
632633
{
633634
// Type was wrapped. Add description as continuation.

0 commit comments

Comments
 (0)