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