Skip to content

[clangd] Use Diagnostic.data for stable fix-it cache key#198073

Draft
NSExceptional wants to merge 1 commit into
llvm:mainfrom
NSExceptional:clangd-stable-fixit-key
Draft

[clangd] Use Diagnostic.data for stable fix-it cache key#198073
NSExceptional wants to merge 1 commit into
llvm:mainfrom
NSExceptional:clangd-stable-fixit-key

Conversation

@NSExceptional
Copy link
Copy Markdown

@NSExceptional NSExceptional commented May 16, 2026

Motivation

clangd matches diagnostics in textDocument/codeAction's context.diagnostics against its fix-it cache by (range, message) (ClangdLSPServer.hstruct DiagKey). The header itself flags this as a FIXME:

The caching is a temporary solution to get corresponding clangd diagnostic from a LSP diagnostic. Ideally, ClangdServer can generate an identifier for each diagnostic, emit them via the LSP's data field (which was newly added in LSP 3.16).

The brittleness shows up in practice whenever an LSP client mutates the displayed message for its Problems panel — capitalizing, stripping clangd's (fix available) suffix, etc. — and then echoes that mutated diagnostic back on a code-action request. The (range, message) lookup misses and no quickfixes are returned, even though clangd has them cached.

Concrete example: vscode-swift's DiagnosticsManager strips (fix available) for display. As a result, Objective-C Add method implementation quickfixes for -Wincomplete-implementation never appear in VS Code, despite clang emitting the FixIt and clangd surfacing it correctly. (Tracked at swiftlang/sourcekit-lsp#2186.)

Change

  • Stamp a monotonically-increasing clangdFixId into Diagnostic.data for every published diagnostic that carries at least one Fix-It.
  • Maintain a parallel DiagIdRefMap: file → id → DiagRef.
  • In getDiagRef, look up by id first; fall back to the legacy (range, message) key when the client drops data on round-trip.

Gating on !Fixes.empty() keeps the wire format unchanged for non-fixable diagnostics, which keeps the test churn limited to the tests that already check fix-bearing diagnostic output.

Backwards compatibility

Diagnostic.data is opaque per LSP spec — clients are expected to preserve it untouched between publish and request. The fallback path means clients that don't preserve it still behave exactly as before. The new data payload (one int64 per fix-bearing diagnostic) is small.

Tests

  • New: fixits-codeaction-data-key.test — verifies the id-based lookup returns fix-its even when the client has mutated the diagnostic message, and a control with mutated message + no data returns [].
  • Updated: every lit test that checks fix-bearing publish output now also expects the data field.
  • Local results on macOS arm64: 1383 unit tests passing, all touched lit tests passing.

Marked draft to let CI exercise the build on the other platforms before promoting.


Yes, I authored this with my $200 Claude plan, I hope that's okay — it is the only way I can find the time to contribute features and bug fixes to the projects I love

clangd's existing fix-it cache keys each cached diagnostic by
`(range, message)`. The header has a long-standing FIXME noting this
is brittle, and indeed it breaks down whenever an LSP client mutates
the displayed message for the Problems panel — e.g. capitalizing the
first letter or stripping clangd's "(fix available)" suffix — and
then echoes that mutated diagnostic back in `context.diagnostics` on
a textDocument/codeAction request. The (range, message) lookup
misses and no fixes are returned, even though clangd has them
cached.

LSP 3.16 introduced `Diagnostic.data`, *"preserved between a
textDocument/publishDiagnostics notification and textDocument/
codeAction request"*. Use it.

This patch:
- Assigns a monotonically-increasing `clangdFixId` to each published
  diagnostic that carries at least one Fix-It, stamped into
  `Diagnostic.data["clangdFixId"]`.
- Maintains a parallel `DiagIdRefMap: file -> id -> DiagRef` keyed
  by that id.
- In `getDiagRef`, looks up by id first; falls back to the legacy
  `(range, message)` key for clients that drop `data` on round-trip.

Gating on `!Fixes.empty()` keeps the wire format unchanged for the
common non-fixable diagnostic case and avoids churn in tests that
don't deal with fix-its. Tests that *do* check fix-bearing
publishDiagnostics output have been updated.

A new lit test (`fixits-codeaction-data-key.test`) verifies that:
- The id-based lookup returns fix-its even when the request's
  `context.diagnostics[0].message` has been mutated.
- A control request with mutated message and no data returns `[]`
  (legacy behavior, confirming the new path is what's doing the work).

Local: 1383 unit tests and the touched lit tests all pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant