aboutsummaryrefslogtreecommitdiff
path: root/editors/code/src/inlay_hints.ts
diff options
context:
space:
mode:
authorVeetaha <[email protected]>2020-02-29 17:28:26 +0000
committerVeetaha <[email protected]>2020-03-07 12:08:35 +0000
commit6441988d84cc1f9d347d72a48d2b67b19dcb8cc9 (patch)
treeca960be502b6ac23a11a2a547cf2f1896d297f5a /editors/code/src/inlay_hints.ts
parent0e6d066a2940c65af9171dff52304590cac4b95e (diff)
vscode: redesign inlay hints to be capable of handling multiple editors
Diffstat (limited to 'editors/code/src/inlay_hints.ts')
-rw-r--r--editors/code/src/inlay_hints.ts375
1 files changed, 271 insertions, 104 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 @@
1import * as lc from "vscode-languageclient";
1import * as vscode from 'vscode'; 2import * as vscode from 'vscode';
2import * as ra from './rust-analyzer-api'; 3import * as ra from './rust-analyzer-api';
3 4
4import { Ctx } from './ctx'; 5import { Ctx } from './ctx';
5import { log, sendRequestWithRetry, isRustDocument } from './util'; 6import { sendRequestWithRetry, assert } from './util';
6 7
7export function activateInlayHints(ctx: Ctx) { 8export 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
42const typeHintDecorationType = vscode.window.createTextEditorDecorationType({
43 after: {
44 color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
45 fontStyle: "normal",
46 },
47});
48 51
49const parameterHintDecorationType = vscode.window.createTextEditorDecorationType({ 52const 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
56class 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
68const 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
84class 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 */
157class 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}
228class 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
303type RustTextDocument = vscode.TextDocument & { languageId: "rust" };
304type RustTextEditor = vscode.TextEditor & { document: RustTextDocument; id: string };
305
306function areEditorsEqual(a: RustTextEditor, b: RustTextEditor): boolean {
307 return a.id === b.id;
308}
309
310function 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
321function isRustTextDocument(suspect: vscode.TextDocument): suspect is RustTextDocument {
322 return suspect.languageId === "rust";
323}