diff options
Diffstat (limited to 'editors')
-rw-r--r-- | editors/code/src/inlay_hints.ts | 358 |
1 files changed, 136 insertions, 222 deletions
diff --git a/editors/code/src/inlay_hints.ts b/editors/code/src/inlay_hints.ts index 161b34037..6d084362d 100644 --- a/editors/code/src/inlay_hints.ts +++ b/editors/code/src/inlay_hints.ts | |||
@@ -2,48 +2,32 @@ import * as lc from "vscode-languageclient"; | |||
2 | import * as vscode from 'vscode'; | 2 | import * as vscode from 'vscode'; |
3 | import * as ra from './rust-analyzer-api'; | 3 | import * as ra from './rust-analyzer-api'; |
4 | 4 | ||
5 | import { Ctx } from './ctx'; | 5 | import { Ctx, Disposable } from './ctx'; |
6 | import { sendRequestWithRetry, assert } from './util'; | 6 | import { sendRequestWithRetry, isRustDocument, RustDocument, RustEditor, log } from './util'; |
7 | 7 | ||
8 | export function activateInlayHints(ctx: Ctx) { | ||
9 | const hintsUpdater = new HintsUpdater(ctx.client); | ||
10 | |||
11 | vscode.window.onDidChangeVisibleTextEditors( | ||
12 | () => hintsUpdater.refreshVisibleRustEditors(), | ||
13 | null, | ||
14 | ctx.subscriptions | ||
15 | ); | ||
16 | |||
17 | vscode.workspace.onDidChangeTextDocument( | ||
18 | ({ contentChanges, document }) => { | ||
19 | if (contentChanges.length === 0) return; | ||
20 | if (!isRustTextDocument(document)) return; | ||
21 | 8 | ||
22 | hintsUpdater.forceRefreshVisibleRustEditors(); | 9 | export function activateInlayHints(ctx: Ctx) { |
10 | const maybeUpdater = { | ||
11 | updater: null as null | HintsUpdater, | ||
12 | onConfigChange() { | ||
13 | if (!ctx.config.displayInlayHints) { | ||
14 | return this.dispose(); | ||
15 | } | ||
16 | if (!this.updater) this.updater = HintsUpdater.create(ctx); | ||
23 | }, | 17 | }, |
24 | null, | 18 | dispose() { |
25 | ctx.subscriptions | 19 | this.updater?.dispose(); |
26 | ); | 20 | this.updater = null; |
21 | } | ||
22 | }; | ||
23 | |||
24 | ctx.pushCleanup(maybeUpdater); | ||
27 | 25 | ||
28 | vscode.workspace.onDidChangeConfiguration( | 26 | vscode.workspace.onDidChangeConfiguration( |
29 | async _ => { | 27 | maybeUpdater.onConfigChange, maybeUpdater, ctx.subscriptions |
30 | // FIXME: ctx.config may have not been refreshed at this point of time, i.e. | ||
31 | // it's on onDidChangeConfiguration() handler may've not executed yet | ||
32 | // (order of invokation is unspecified) | ||
33 | // To fix this we should expose an event emitter from our `Config` itself. | ||
34 | await hintsUpdater.setEnabled(ctx.config.displayInlayHints); | ||
35 | }, | ||
36 | null, | ||
37 | ctx.subscriptions | ||
38 | ); | 28 | ); |
39 | 29 | ||
40 | ctx.pushCleanup({ | 30 | maybeUpdater.onConfigChange(); |
41 | dispose() { | ||
42 | hintsUpdater.clearHints(); | ||
43 | } | ||
44 | }); | ||
45 | |||
46 | hintsUpdater.setEnabled(ctx.config.displayInlayHints); | ||
47 | } | 31 | } |
48 | 32 | ||
49 | 33 | ||
@@ -79,239 +63,169 @@ const paramHints = { | |||
79 | } | 63 | } |
80 | }; | 64 | }; |
81 | 65 | ||
82 | class HintsUpdater { | 66 | class HintsUpdater implements Disposable { |
83 | private sourceFiles = new RustSourceFiles(); | 67 | private sourceFiles = new Map<string, RustSourceFile>(); // map Uri -> RustSourceFile |
84 | private enabled = false; | 68 | private readonly disposables: Disposable[] = []; |
85 | |||
86 | constructor(readonly client: lc.LanguageClient) { } | ||
87 | 69 | ||
88 | setEnabled(enabled: boolean) { | 70 | private constructor(readonly ctx: Ctx) { } |
89 | if (this.enabled === enabled) return; | ||
90 | this.enabled = enabled; | ||
91 | 71 | ||
92 | if (this.enabled) { | 72 | static create(ctx: Ctx) { |
93 | this.refreshVisibleRustEditors(); | 73 | const self = new HintsUpdater(ctx); |
94 | } else { | ||
95 | this.clearHints(); | ||
96 | } | ||
97 | } | ||
98 | |||
99 | clearHints() { | ||
100 | for (const file of this.sourceFiles) { | ||
101 | file.inlaysRequest?.cancel(); | ||
102 | file.renderHints([], this.client.protocol2CodeConverter); | ||
103 | } | ||
104 | } | ||
105 | |||
106 | forceRefreshVisibleRustEditors() { | ||
107 | if (!this.enabled) return; | ||
108 | 74 | ||
109 | for (const file of this.sourceFiles) { | 75 | vscode.window.onDidChangeVisibleTextEditors( |
110 | void file.fetchAndRenderHints(this.client); | 76 | self.onDidChangeVisibleTextEditors, |
111 | } | 77 | self, |
112 | } | 78 | self.disposables |
113 | 79 | ); | |
114 | refreshVisibleRustEditors() { | ||
115 | if (!this.enabled) return; | ||
116 | 80 | ||
117 | const visibleSourceFiles = this.sourceFiles.drainEditors( | 81 | vscode.workspace.onDidChangeTextDocument( |
118 | vscode.window.visibleTextEditors.filter(isRustTextEditor) | 82 | self.onDidChangeTextDocument, |
83 | self, | ||
84 | self.disposables | ||
119 | ); | 85 | ); |
120 | 86 | ||
121 | // Cancel requests for source files whose editors were disposed (leftovers after drain). | 87 | // Set up initial cache shape |
122 | for (const { inlaysRequest } of this.sourceFiles) inlaysRequest?.cancel(); | 88 | ctx.visibleRustEditors.forEach(editor => self.sourceFiles.set( |
89 | editor.document.uri.toString(), { | ||
90 | document: editor.document, | ||
91 | inlaysRequest: null, | ||
92 | cachedDecorations: null | ||
93 | } | ||
94 | )); | ||
123 | 95 | ||
124 | this.sourceFiles = visibleSourceFiles; | 96 | self.syncCacheAndRenderHints(); |
125 | 97 | ||
126 | for (const file of this.sourceFiles) { | 98 | return self; |
127 | if (!file.rerenderHints()) { | ||
128 | void file.fetchAndRenderHints(this.client); | ||
129 | } | ||
130 | } | ||
131 | } | 99 | } |
132 | } | ||
133 | |||
134 | 100 | ||
135 | /** | 101 | dispose() { |
136 | * This class encapsulates a map of file uris to respective inlay hints | 102 | this.sourceFiles.forEach(file => file.inlaysRequest?.cancel()); |
137 | * request cancellation token source (cts) and an array of editors. | 103 | this.ctx.visibleRustEditors.forEach(editor => this.renderDecorations(editor, { param: [], type: [] })); |
138 | * E.g. | 104 | this.disposables.forEach(d => d.dispose()); |
139 | * ``` | 105 | } |
140 | * { | ||
141 | * file1.rs -> (cts, (typeDecor, paramDecor), [editor1, editor2]) | ||
142 | * ^-- there is a cts to cancel the in-flight request | ||
143 | * file2.rs -> (cts, null, [editor3]) | ||
144 | * ^-- no decorations are applied to this source file yet | ||
145 | * file3.rs -> (null, (typeDecor, paramDecor), [editor4]) | ||
146 | * } ^-- there is no inflight request | ||
147 | * ``` | ||
148 | * | ||
149 | * Invariants: each stored source file has at least 1 editor. | ||
150 | */ | ||
151 | class RustSourceFiles { | ||
152 | private files = new Map<string, RustSourceFile>(); | ||
153 | 106 | ||
154 | /** | 107 | onDidChangeTextDocument({contentChanges, document}: vscode.TextDocumentChangeEvent) { |
155 | * Removes `editors` from `this` source files and puts them into a returned | 108 | if (contentChanges.length === 0 || !isRustDocument(document)) return; |
156 | * source files object. cts and decorations are moved to the returned source files. | 109 | log.debug(`[inlays]: changed text doc!`); |
157 | */ | 110 | this.syncCacheAndRenderHints(); |
158 | drainEditors(editors: RustTextEditor[]): RustSourceFiles { | 111 | } |
159 | const result = new RustSourceFiles; | ||
160 | 112 | ||
161 | for (const editor of editors) { | 113 | private syncCacheAndRenderHints() { |
162 | const oldFile = this.removeEditor(editor); | 114 | // FIXME: make inlayHints request pass an array of files? |
163 | const newFile = result.addEditor(editor); | 115 | this.sourceFiles.forEach((file, uri) => this.fetchHints(file).then(hints => { |
116 | if (!hints) return; | ||
164 | 117 | ||
165 | if (oldFile) newFile.stealCacheFrom(oldFile); | 118 | file.cachedDecorations = this.hintsToDecorations(hints); |
166 | } | ||
167 | 119 | ||
168 | return result; | 120 | for (const editor of this.ctx.visibleRustEditors) { |
121 | if (editor.document.uri.toString() === uri) { | ||
122 | this.renderDecorations(editor, file.cachedDecorations); | ||
123 | } | ||
124 | } | ||
125 | })); | ||
169 | } | 126 | } |
170 | 127 | ||
171 | /** | 128 | onDidChangeVisibleTextEditors() { |
172 | * Remove the editor and if it was the only editor for a source file, | 129 | log.debug(`[inlays]: changed visible text editors`); |
173 | * the source file is removed altogether. | 130 | const newSourceFiles = new Map<string, RustSourceFile>(); |
174 | * | ||
175 | * @returns A reference to the source file for this editor or | ||
176 | * null if no such source file was not found. | ||
177 | */ | ||
178 | private removeEditor(editor: RustTextEditor): null | RustSourceFile { | ||
179 | const uri = editor.document.uri.toString(); | ||
180 | 131 | ||
181 | const file = this.files.get(uri); | 132 | // Rerendering all, even up-to-date editors for simplicity |
182 | if (!file) return null; | 133 | this.ctx.visibleRustEditors.forEach(async editor => { |
134 | const uri = editor.document.uri.toString(); | ||
135 | const file = this.sourceFiles.get(uri) ?? { | ||
136 | document: editor.document, | ||
137 | inlaysRequest: null, | ||
138 | cachedDecorations: null | ||
139 | }; | ||
140 | newSourceFiles.set(uri, file); | ||
183 | 141 | ||
184 | const editorIndex = file.editors.findIndex(suspect => areEditorsEqual(suspect, editor)); | 142 | // No text documents changed, so we may try to use the cache |
143 | if (!file.cachedDecorations) { | ||
144 | file.inlaysRequest?.cancel(); | ||
185 | 145 | ||
186 | if (editorIndex >= 0) { | 146 | const hints = await this.fetchHints(file); |
187 | file.editors.splice(editorIndex, 1); | 147 | if (!hints) return; |
188 | 148 | ||
189 | if (file.editors.length === 0) this.files.delete(uri); | 149 | file.cachedDecorations = this.hintsToDecorations(hints); |
190 | } | 150 | } |
191 | |||
192 | return file; | ||
193 | } | ||
194 | |||
195 | /** | ||
196 | * @returns A reference to an existing source file or newly created one for the editor. | ||
197 | */ | ||
198 | private addEditor(editor: RustTextEditor): RustSourceFile { | ||
199 | const uri = editor.document.uri.toString(); | ||
200 | const file = this.files.get(uri); | ||
201 | |||
202 | if (!file) { | ||
203 | const newFile = new RustSourceFile([editor]); | ||
204 | this.files.set(uri, newFile); | ||
205 | return newFile; | ||
206 | } | ||
207 | 151 | ||
208 | if (!file.editors.find(suspect => areEditorsEqual(suspect, editor))) { | 152 | this.renderDecorations(editor, file.cachedDecorations); |
209 | file.editors.push(editor); | 153 | }); |
210 | } | ||
211 | return file; | ||
212 | } | ||
213 | 154 | ||
214 | getSourceFile(uri: string): undefined | RustSourceFile { | 155 | // Cancel requests for no longer visible (disposed) source files |
215 | return this.files.get(uri); | 156 | this.sourceFiles.forEach((file, uri) => { |
216 | } | 157 | if (!newSourceFiles.has(uri)) file.inlaysRequest?.cancel(); |
158 | }); | ||
217 | 159 | ||
218 | [Symbol.iterator](): IterableIterator<RustSourceFile> { | 160 | this.sourceFiles = newSourceFiles; |
219 | return this.files.values(); | ||
220 | } | ||
221 | } | ||
222 | class RustSourceFile { | ||
223 | constructor( | ||
224 | /** | ||
225 | * Editors for this source file (one text document may be opened in multiple editors). | ||
226 | * We keep this just an array, because most of the time we have 1 editor for 1 source file. | ||
227 | */ | ||
228 | readonly editors: RustTextEditor[], | ||
229 | /** | ||
230 | * Source of the token to cancel in-flight inlay hints request if any. | ||
231 | */ | ||
232 | public inlaysRequest: null | vscode.CancellationTokenSource = null, | ||
233 | |||
234 | public decorations: null | { | ||
235 | type: vscode.DecorationOptions[]; | ||
236 | param: vscode.DecorationOptions[]; | ||
237 | } = null | ||
238 | ) { } | ||
239 | |||
240 | stealCacheFrom(other: RustSourceFile) { | ||
241 | if (other.inlaysRequest) this.inlaysRequest = other.inlaysRequest; | ||
242 | if (other.decorations) this.decorations = other.decorations; | ||
243 | |||
244 | other.inlaysRequest = null; | ||
245 | other.decorations = null; | ||
246 | } | 161 | } |
247 | 162 | ||
248 | rerenderHints(): boolean { | 163 | private renderDecorations(editor: RustEditor, decorations: InlaysDecorations) { |
249 | if (!this.decorations) return false; | 164 | editor.setDecorations(typeHints.decorationType, decorations.type); |
250 | 165 | editor.setDecorations(paramHints.decorationType, decorations.param); | |
251 | for (const editor of this.editors) { | ||
252 | editor.setDecorations(typeHints.decorationType, this.decorations.type); | ||
253 | editor.setDecorations(paramHints.decorationType, this.decorations.param); | ||
254 | } | ||
255 | return true; | ||
256 | } | 166 | } |
257 | 167 | ||
258 | renderHints(hints: ra.InlayHint[], conv: lc.Protocol2CodeConverter) { | 168 | private hintsToDecorations(hints: ra.InlayHint[]): InlaysDecorations { |
259 | this.decorations = { type: [], param: [] }; | 169 | const decorations: InlaysDecorations = { type: [], param: [] }; |
170 | const conv = this.ctx.client.protocol2CodeConverter; | ||
260 | 171 | ||
261 | for (const hint of hints) { | 172 | for (const hint of hints) { |
262 | switch (hint.kind) { | 173 | switch (hint.kind) { |
263 | case ra.InlayHint.Kind.TypeHint: { | 174 | case ra.InlayHint.Kind.TypeHint: { |
264 | this.decorations.type.push(typeHints.toDecoration(hint, conv)); | 175 | decorations.type.push(typeHints.toDecoration(hint, conv)); |
265 | continue; | 176 | continue; |
266 | } | 177 | } |
267 | case ra.InlayHint.Kind.ParamHint: { | 178 | case ra.InlayHint.Kind.ParamHint: { |
268 | this.decorations.param.push(paramHints.toDecoration(hint, conv)); | 179 | decorations.param.push(paramHints.toDecoration(hint, conv)); |
269 | continue; | 180 | continue; |
270 | } | 181 | } |
271 | } | 182 | } |
272 | } | 183 | } |
273 | this.rerenderHints(); | 184 | return decorations; |
274 | } | 185 | } |
275 | 186 | ||
276 | async fetchAndRenderHints(client: lc.LanguageClient): Promise<void> { | 187 | lastReqId = 0; |
277 | this.inlaysRequest?.cancel(); | 188 | private async fetchHints(file: RustSourceFile): Promise<null | ra.InlayHint[]> { |
189 | const reqId = ++this.lastReqId; | ||
190 | |||
191 | log.debug(`[inlays]: ${reqId} requesting`); | ||
192 | file.inlaysRequest?.cancel(); | ||
278 | 193 | ||
279 | const tokenSource = new vscode.CancellationTokenSource(); | 194 | const tokenSource = new vscode.CancellationTokenSource(); |
280 | this.inlaysRequest = tokenSource; | 195 | file.inlaysRequest = tokenSource; |
281 | 196 | ||
282 | const request = { textDocument: { uri: this.editors[0].document.uri.toString() } }; | 197 | const request = { textDocument: { uri: file.document.uri.toString() } }; |
283 | 198 | ||
284 | try { | 199 | return sendRequestWithRetry(this.ctx.client, ra.inlayHints, request, tokenSource.token) |
285 | const hints = await sendRequestWithRetry(client, ra.inlayHints, request, tokenSource.token); | 200 | .catch(_ => { |
286 | this.renderHints(hints, client.protocol2CodeConverter); | 201 | log.debug(`[inlays]: ${reqId} err`); |
287 | } catch { | 202 | return null; |
288 | /* ignore */ | 203 | }) |
289 | } finally { | 204 | .finally(() => { |
290 | if (this.inlaysRequest === tokenSource) { | 205 | if (file.inlaysRequest === tokenSource) { |
291 | this.inlaysRequest = null; | 206 | file.inlaysRequest = null; |
292 | } | 207 | log.debug(`[inlays]: ${reqId} got response!`); |
293 | } | 208 | } else { |
209 | log.debug(`[inlays]: ${reqId} cancelled!`); | ||
210 | } | ||
211 | }) | ||
294 | } | 212 | } |
295 | } | 213 | } |
296 | 214 | ||
297 | type RustTextDocument = vscode.TextDocument & { languageId: "rust" }; | 215 | interface InlaysDecorations { |
298 | type RustTextEditor = vscode.TextEditor & { document: RustTextDocument; id: string }; | 216 | type: vscode.DecorationOptions[]; |
299 | 217 | param: vscode.DecorationOptions[]; | |
300 | function areEditorsEqual(a: RustTextEditor, b: RustTextEditor): boolean { | ||
301 | return a.id === b.id; | ||
302 | } | 218 | } |
303 | 219 | ||
304 | function isRustTextEditor(suspect: vscode.TextEditor & { id?: unknown }): suspect is RustTextEditor { | 220 | interface RustSourceFile { |
305 | // Dirty hack, we need to access private vscode editor id, | 221 | /* |
306 | // see https://github.com/microsoft/vscode/issues/91788 | 222 | * Source of the token to cancel in-flight inlay hints request if any. |
307 | assert( | 223 | */ |
308 | typeof suspect.id === "string", | 224 | inlaysRequest: null | vscode.CancellationTokenSource; |
309 | "Private text editor id is no longer available, please update the workaround!" | 225 | /** |
310 | ); | 226 | * Last applied decorations. |
311 | 227 | */ | |
312 | return isRustTextDocument(suspect.document); | 228 | cachedDecorations: null | InlaysDecorations; |
313 | } | ||
314 | 229 | ||
315 | function isRustTextDocument(suspect: vscode.TextDocument): suspect is RustTextDocument { | 230 | document: RustDocument |
316 | return suspect.languageId === "rust"; | ||
317 | } | 231 | } |