Skip to content

Commit c08ac44

Browse files
committed
feat: Add LSP document highlights
1 parent 60c4d92 commit c08ac44

File tree

6 files changed

+345
-0
lines changed

6 files changed

+345
-0
lines changed

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cm/lsp/clientManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import lspStatusBar from "components/lspStatusBar";
1818
import NotificationManager from "lib/notificationManager";
1919
import Uri from "utils/Uri";
2020
import { clearDiagnosticsEffect } from "./diagnostics";
21+
import { documentHighlightsExtension } from "./documentHighlights";
2122
import { inlayHintsExtension } from "./inlayHints";
2223
import { ensureServerRunning } from "./serverLauncher";
2324
import serverRegistry from "./serverRegistry";
@@ -81,6 +82,7 @@ function buildBuiltinExtensions(
8182
keymaps: includeKeymaps = true,
8283
diagnostics: includeDiagnostics = true,
8384
inlayHints: includeInlayHints = true,
85+
documentHighlights: includeDocumentHighlights = true,
8486
} = config;
8587

8688
const extensions: Extension[] = [];
@@ -99,6 +101,10 @@ function buildBuiltinExtensions(
99101
const hintsExt = inlayHintsExtension();
100102
extensions.push(hintsExt as LSPClientExtension as Extension);
101103
}
104+
if (includeDocumentHighlights) {
105+
const highlightsExt = documentHighlightsExtension();
106+
extensions.push(highlightsExt as LSPClientExtension as Extension);
107+
}
102108

103109
return { extensions, diagnosticsExtension };
104110
}
@@ -385,6 +391,7 @@ export class LspClientManager {
385391
keymaps: builtinConfig.keymaps !== false,
386392
diagnostics: builtinConfig.diagnostics !== false,
387393
inlayHints: builtinConfig.inlayHints !== false,
394+
documentHighlights: builtinConfig.documentHighlights !== false,
388395
})
389396
: { extensions: [], diagnosticsExtension: null };
390397

src/cm/lsp/documentHighlights.ts

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
/**
2+
* LSP Document Highlights Extension for CodeMirror
3+
*
4+
* Highlights all occurrences of the word under cursor using LSP documentHighlight request.
5+
* Supports read/write distinction for variables (e.g., assignments vs. references).
6+
*/
7+
8+
import type { LSPClient, LSPClientExtension } from "@codemirror/lsp-client";
9+
import { LSPPlugin } from "@codemirror/lsp-client";
10+
import type { Extension, Range } from "@codemirror/state";
11+
import { RangeSet, StateEffect, StateField } from "@codemirror/state";
12+
import {
13+
Decoration,
14+
type DecorationSet,
15+
EditorView,
16+
ViewPlugin,
17+
type ViewUpdate,
18+
} from "@codemirror/view";
19+
import type {
20+
DocumentHighlight,
21+
DocumentHighlightKind,
22+
Position,
23+
} from "vscode-languageserver-types";
24+
import type { LSPPluginAPI } from "./types";
25+
26+
/**
27+
* LSP DocumentHighlightKind
28+
* 1 = Text (general highlight)
29+
* 2 = Read (read access of a symbol)
30+
* 3 = Write (write access of a symbol)
31+
*/
32+
33+
interface DocumentHighlightParams {
34+
textDocument: { uri: string };
35+
position: Position;
36+
}
37+
38+
interface ProcessedHighlight {
39+
from: number;
40+
to: number;
41+
kind: DocumentHighlightKind;
42+
}
43+
44+
export interface DocumentHighlightsConfig {
45+
/** Whether to enable document highlights. Default: true */
46+
enabled?: boolean;
47+
/** Debounce delay in milliseconds. Default: 150ms */
48+
debounceMs?: number;
49+
/** Show different colors for read vs write. Default: true */
50+
distinguishReadWrite?: boolean;
51+
}
52+
53+
// DocumentHighlightKind constants
54+
const HIGHLIGHT_TEXT = 1 as const;
55+
const HIGHLIGHT_READ = 2 as const;
56+
const HIGHLIGHT_WRITE = 3 as const;
57+
58+
const setHighlights = StateEffect.define<ProcessedHighlight[]>();
59+
60+
const highlightsField = StateField.define<ProcessedHighlight[]>({
61+
create: () => [],
62+
update(highlights, tr) {
63+
for (const e of tr.effects) {
64+
if (e.is(setHighlights)) return e.value;
65+
}
66+
// Clear highlights on doc change (will be refreshed by plugin)
67+
if (tr.docChanged) return [];
68+
return highlights;
69+
},
70+
});
71+
72+
const textMark = Decoration.mark({ class: "cm-lsp-highlight" });
73+
const readMark = Decoration.mark({
74+
class: "cm-lsp-highlight cm-lsp-highlight-read",
75+
});
76+
const writeMark = Decoration.mark({
77+
class: "cm-lsp-highlight cm-lsp-highlight-write",
78+
});
79+
80+
function getMarkForKind(
81+
kind: DocumentHighlightKind,
82+
distinguishReadWrite: boolean,
83+
): typeof textMark {
84+
if (!distinguishReadWrite) return textMark;
85+
switch (kind) {
86+
case HIGHLIGHT_READ:
87+
return readMark;
88+
case HIGHLIGHT_WRITE:
89+
return writeMark;
90+
default:
91+
return textMark;
92+
}
93+
}
94+
95+
function buildDecos(
96+
highlights: ProcessedHighlight[],
97+
docLen: number,
98+
distinguishReadWrite: boolean,
99+
): DecorationSet {
100+
if (!highlights.length) return Decoration.none;
101+
102+
const decos: Range<Decoration>[] = [];
103+
for (const h of highlights) {
104+
if (h.from < 0 || h.to > docLen || h.from >= h.to) continue;
105+
decos.push(
106+
getMarkForKind(h.kind, distinguishReadWrite).range(h.from, h.to),
107+
);
108+
}
109+
// Sort by position for RangeSet
110+
decos.sort((a, b) => a.from - b.from || a.to - b.to);
111+
return RangeSet.of(decos);
112+
}
113+
114+
function createPlugin(config: DocumentHighlightsConfig) {
115+
const delay = config.debounceMs ?? 150;
116+
const distinguishReadWrite = config.distinguishReadWrite !== false;
117+
118+
return ViewPlugin.fromClass(
119+
class {
120+
decorations: DecorationSet = Decoration.none;
121+
timer: ReturnType<typeof setTimeout> | null = null;
122+
reqId = 0;
123+
lastPos = -1;
124+
125+
constructor(private view: EditorView) {}
126+
127+
update(update: ViewUpdate): void {
128+
// Rebuild decorations if highlights changed
129+
if (
130+
update.transactions.some((t) =>
131+
t.effects.some((e) => e.is(setHighlights)),
132+
)
133+
) {
134+
this.decorations = buildDecos(
135+
update.state.field(highlightsField, false) ?? [],
136+
update.state.doc.length,
137+
distinguishReadWrite,
138+
);
139+
}
140+
141+
// Schedule fetch on selection or doc change
142+
if (update.docChanged || update.selectionSet) {
143+
this.schedule();
144+
}
145+
}
146+
147+
schedule(): void {
148+
if (this.timer) clearTimeout(this.timer);
149+
this.timer = setTimeout(() => {
150+
this.timer = null;
151+
this.fetch();
152+
}, delay);
153+
}
154+
155+
async fetch(): Promise<void> {
156+
const lsp = LSPPlugin.get(this.view) as LSPPluginAPI | null;
157+
if (!lsp?.client.connected) {
158+
this.clear();
159+
return;
160+
}
161+
162+
const caps = lsp.client.serverCapabilities;
163+
if (!caps?.documentHighlightProvider) {
164+
this.clear();
165+
return;
166+
}
167+
168+
// Get current cursor position
169+
const selection = this.view.state.selection.main;
170+
const pos = selection.head;
171+
172+
// Skip if position hasn't changed (and no doc changes)
173+
if (pos === this.lastPos) return;
174+
this.lastPos = pos;
175+
176+
// Don't highlight if there's a selection range
177+
if (!selection.empty) {
178+
this.clear();
179+
return;
180+
}
181+
182+
lsp.client.sync();
183+
const id = ++this.reqId;
184+
185+
try {
186+
const highlights = await lsp.client.request<
187+
DocumentHighlightParams,
188+
DocumentHighlight[] | null
189+
>("textDocument/documentHighlight", {
190+
textDocument: { uri: lsp.uri },
191+
position: lsp.toPosition(pos),
192+
});
193+
194+
// Stale request check
195+
if (id !== this.reqId) return;
196+
197+
if (!highlights || !highlights.length) {
198+
this.clear();
199+
return;
200+
}
201+
202+
const processed = this.process(lsp, highlights);
203+
this.view.dispatch({ effects: setHighlights.of(processed) });
204+
} catch {
205+
// Non-critical - silently ignore
206+
this.clear();
207+
}
208+
}
209+
210+
process(
211+
lsp: LSPPluginAPI,
212+
highlights: DocumentHighlight[],
213+
): ProcessedHighlight[] {
214+
const result: ProcessedHighlight[] = [];
215+
const doc = this.view.state.doc;
216+
217+
for (const h of highlights) {
218+
let from: number;
219+
let to: number;
220+
try {
221+
from = lsp.fromPosition(h.range.start, lsp.syncedDoc);
222+
to = lsp.fromPosition(h.range.end, lsp.syncedDoc);
223+
224+
// Map through unsynced changes
225+
const mappedFrom = lsp.unsyncedChanges.mapPos(from);
226+
const mappedTo = lsp.unsyncedChanges.mapPos(to);
227+
if (mappedFrom === null || mappedTo === null) continue;
228+
from = mappedFrom;
229+
to = mappedTo;
230+
} catch {
231+
continue;
232+
}
233+
234+
if (from < 0 || to > doc.length || from >= to) continue;
235+
236+
result.push({
237+
from,
238+
to,
239+
kind: h.kind ?? HIGHLIGHT_TEXT,
240+
});
241+
}
242+
243+
return result.sort((a, b) => a.from - b.from);
244+
}
245+
246+
clear(): void {
247+
const current = this.view.state.field(highlightsField, false);
248+
if (current && current.length > 0) {
249+
this.view.dispatch({ effects: setHighlights.of([]) });
250+
}
251+
}
252+
253+
destroy(): void {
254+
if (this.timer) clearTimeout(this.timer);
255+
}
256+
},
257+
{ decorations: (v) => v.decorations },
258+
);
259+
}
260+
261+
const styles = EditorView.baseTheme({
262+
// Base highlight style (for text/unspecified kind)
263+
".cm-lsp-highlight": {
264+
backgroundColor: "rgba(150, 150, 150, 0.2)",
265+
borderRadius: "2px",
266+
},
267+
// Read access highlight (slightly lighter)
268+
"&light .cm-lsp-highlight-read": {
269+
backgroundColor: "rgba(121, 196, 142, 0.25)",
270+
},
271+
"&dark .cm-lsp-highlight-read": {
272+
backgroundColor: "rgba(121, 196, 142, 0.15)",
273+
},
274+
// Write access highlight (more prominent)
275+
"&light .cm-lsp-highlight-write": {
276+
backgroundColor: "rgba(196, 121, 121, 0.25)",
277+
},
278+
"&dark .cm-lsp-highlight-write": {
279+
backgroundColor: "rgba(196, 121, 121, 0.15)",
280+
},
281+
});
282+
283+
/**
284+
* Client extension that adds documentHighlight capabilities to the LSP client.
285+
*/
286+
export function documentHighlightsClientExtension(): LSPClientExtension {
287+
return {
288+
clientCapabilities: {
289+
textDocument: {
290+
documentHighlight: {
291+
dynamicRegistration: true,
292+
},
293+
},
294+
},
295+
};
296+
}
297+
298+
/**
299+
* Editor extension that handles document highlights display.
300+
*/
301+
export function documentHighlightsEditorExtension(
302+
config: DocumentHighlightsConfig = {},
303+
): Extension {
304+
if (config.enabled === false) return [];
305+
return [highlightsField, createPlugin(config), styles];
306+
}
307+
308+
/**
309+
* Combined extension for document highlights.
310+
*/
311+
export function documentHighlightsExtension(
312+
config: DocumentHighlightsConfig = {},
313+
): LSPClientExtension & { editorExtension: Extension } {
314+
return {
315+
...documentHighlightsClientExtension(),
316+
editorExtension: documentHighlightsEditorExtension(config),
317+
};
318+
}
319+
320+
export default documentHighlightsExtension;

src/cm/lsp/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export {
77
lspDiagnosticsExtension,
88
lspDiagnosticsUiExtension,
99
} from "./diagnostics";
10+
export type { DocumentHighlightsConfig } from "./documentHighlights";
11+
export {
12+
documentHighlightsClientExtension,
13+
documentHighlightsEditorExtension,
14+
documentHighlightsExtension,
15+
} from "./documentHighlights";
1016
export { registerLspFormatter } from "./formatter";
1117
export type { InlayHintsConfig } from "./inlayHints";
1218
export {

src/cm/lsp/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export interface BuiltinExtensionsConfig {
115115
keymaps?: boolean;
116116
diagnostics?: boolean;
117117
inlayHints?: boolean;
118+
documentHighlights?: boolean;
118119
}
119120

120121
export interface AcodeClientConfig {

0 commit comments

Comments
 (0)