perf(semantic)!: return borrowed JSDoc so the parse cache works (#25186)

Generated with Claude Code, Opus 5. Reviewed and tested by me to confirm
the finding here is correct and the fix works as intended.

## Problem

`JSDoc` parses lazily into a `OnceCell`, but `JSDocFinder` handed out
clones:

```rust
pub fn get_all_by_span<'b>(&'b self, span: Span) -> Option<Vec<JSDoc<'a>>> {
    self.attached.get(&span.start).cloned()
}
```

Every caller parsed into its own clone and then dropped it, so the
`OnceCell` held in the map was never the one that got initialized. Each
subsequent lookup cloned that still-uninitialized cell and parsed again.
(`OnceCell::clone` does copy an initialized value — the problem is that
the cell in the map never reaches that state, because the write always
lands in a temporary.) A JSDoc comment was therefore parsed once per
*rule* that asked for it, rather than once overall.

Confirmed with a temporary counter in `parse_jsdoc`: **5 rule accesses
to a single JSDoc comment produced 5 calls to `parse_jsdoc`.**

## Fix

Return borrows (`Option<&[JSDoc]>` / `Option<&JSDoc>`) so the cache
lives in the map and is shared across callers. All 22 `jsdoc/*` rule
files were already iteration-based and needed no changes; the only
rule-side edits are 5 now-redundant borrows that clippy autofixed.

## Results

vscode `src` (8006 files, 862k lines, 36k JSDoc comments), `oxlint
--threads 1` with all 22 jsdoc
rules enabled:

| | mean |
|---|---|
| before | 767.4 ms |
| after | 761.6 ms |

**5.8 ms / 0.75%**, stable across independent 12-run and 40-run
hyperfine sets. Diagnostics are byte-identical: 13113 warnings before
and after.

The wall-clock win is small, but the change is algorithmically strictly
better with no downside: parses per comment go from O(rules that touch
it) to 1. A symbolicated profile puts ~67 ms (5.1% of worker time) in
JSDoc-containing stacks, of which ~12.5 ms is the parse itself — this PR
removes the redundant share of that.

## Notes

- **Breaking:** `get_one_by_node`, `get_all_by_node` and
`get_all_by_span` now return borrows instead of owned values.
- Sound with respect to threading: `iter_all()` already handed out
`&JSDoc`, so shared cells were not new; `OnceCell` makes `Semantic`
`!Sync`, so the compiler already forbids sharing `&Semantic` across
threads, and the newly-shared cell writes cannot race.
- Only affects runs with the `jsdoc` plugin explicitly enabled — it is
not in oxlint's default plugin set. The always-on `JSDocBuilder` path is
unaffected (I measured it separately at 690.0 ms -> 690.1 ms when
stubbed out entirely, i.e. free).
- We could remove the `#[derive(Clone)]` from JSDoc as nothing uses it
here anymore, but it'd also be a breaking change, so I didn't do it yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Cameron <cameron.clark@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Cameron <cameron.clark@hey.com>
This commit is contained in:
authored and GitHub committed 2026-08-03 11:52:30 +01:00
1 parent 253075e0cb
commit 4120da068c
3 files changed
+33 -11

No files matched your search

@@ -201,7 +201,7 @@ impl Rule for RequireParam {
}
// Collected JSDoc `@param` tags
let tags_to_check = collect_tags(&jsdocs, settings.resolve_tag_name("param"));
let tags_to_check = collect_tags(jsdocs, settings.resolve_tag_name("param"));
if config.ignore_when_all_params_missing && tags_to_check.is_empty() {
return;
@@ -186,10 +186,10 @@ impl Rule for RequireReturns {
let config = &self.0;
let settings = &ctx.settings().jsdoc;
// If JSDoc is found but safely ignored, skip
if should_ignore_as_custom_skip(&jsdoc)
|| should_ignore_as_avoid(&jsdoc, settings, &config.exempted_by)
|| should_ignore_as_private(&jsdoc, settings)
|| should_ignore_as_internal(&jsdoc, settings)
if should_ignore_as_custom_skip(jsdoc)
|| should_ignore_as_avoid(jsdoc, settings, &config.exempted_by)
|| should_ignore_as_private(jsdoc, settings)
|| should_ignore_as_internal(jsdoc, settings)
{
continue;
}
+28 -6
View File
@@ -21,19 +21,19 @@ impl<'a> JSDocFinder<'a> {
&'b self,
nodes: &AstNodes<'a>,
node: &AstNode<'a>,
) -> Option<JSDoc<'a>> {
) -> Option<&'b JSDoc<'a>> {
let jsdocs = self.get_all_by_node(nodes, node)?;
// If flagged, at least 1 JSDoc is attached
// If multiple JSDocs are attached, return the last = nearest
jsdocs.last().cloned()
jsdocs.last()
}
pub fn get_all_by_node<'b>(
&'b self,
nodes: &AstNodes<'a>,
node: &AstNode<'a>,
) -> Option<Vec<JSDoc<'a>>> {
) -> Option<&'b [JSDoc<'a>]> {
if !nodes.flags(node.id()).has_jsdoc() {
return None;
}
@@ -42,8 +42,8 @@ impl<'a> JSDocFinder<'a> {
self.get_all_by_span(span)
}
pub fn get_all_by_span<'b>(&'b self, span: Span) -> Option<Vec<JSDoc<'a>>> {
self.attached.get(&span.start).cloned()
pub fn get_all_by_span<'b>(&'b self, span: Span) -> Option<&'b [JSDoc<'a>]> {
self.attached.get(&span.start).map(Vec::as_slice)
}
pub fn iter_all<'b>(&'b self) -> impl Iterator<Item = &'b JSDoc<'a>> + 'b {
@@ -83,7 +83,7 @@ mod test {
let semantic = build_semantic(allocator, source_text, source_type);
let start = u32::try_from(source_text.find(symbol).unwrap_or(0)).unwrap();
let span = Span::sized(start, u32::try_from(symbol.len()).unwrap());
semantic.jsdoc().get_all_by_span(span)
semantic.jsdoc().get_all_by_span(span).map(<[JSDoc]>::to_vec)
}
fn test_jsdoc_found(source_text: &str, symbol: &str, source_type: Option<SourceType>) {
@@ -227,6 +227,28 @@ mod test {
}
}
// Lookups must borrow, not clone: a clone parses into the temporary and leaves the cached
// `OnceCell` in the map uninitialized, so every lookup reparses.
#[test]
fn parse_cache_is_shared_across_lookups() {
let allocator = Allocator::default();
let source_text = "/** @param {number} a */ function f(a) {}";
let symbol = "function f(a) {}";
let semantic = build_semantic(&allocator, source_text, None);
let start = u32::try_from(source_text.find(symbol).unwrap()).unwrap();
let span = Span::sized(start, u32::try_from(symbol.len()).unwrap());
let first = semantic.jsdoc().get_all_by_span(span).unwrap()[0].tags();
assert_eq!(first.len(), 1);
let second = semantic.jsdoc().get_all_by_span(span).unwrap()[0].tags();
assert_eq!(
first.as_ptr(),
second.as_ptr(),
"second lookup reparsed instead of reusing the cache"
);
}
#[test]
fn get_all_by_span_order() {
let allocator = Allocator::default();