mirror of
https://github.com/rust-lang/rust-analyzer
synced 2024-12-26 13:03:31 +00:00
vscode: redesign inlay hints to be capable of handling multiple editors
This commit is contained in:
parent
0e6d066a29
commit
6441988d84
2 changed files with 272 additions and 105 deletions
|
@ -1,156 +1,323 @@
|
||||||
|
import * as lc from "vscode-languageclient";
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as ra from './rust-analyzer-api';
|
import * as ra from './rust-analyzer-api';
|
||||||
|
|
||||||
import { Ctx } from './ctx';
|
import { Ctx } from './ctx';
|
||||||
import { log, sendRequestWithRetry, isRustDocument } from './util';
|
import { sendRequestWithRetry, assert } from './util';
|
||||||
|
|
||||||
export function activateInlayHints(ctx: Ctx) {
|
export function activateInlayHints(ctx: Ctx) {
|
||||||
const hintsUpdater = new HintsUpdater(ctx);
|
const hintsUpdater = new HintsUpdater(ctx.client);
|
||||||
|
|
||||||
vscode.window.onDidChangeVisibleTextEditors(
|
vscode.window.onDidChangeVisibleTextEditors(
|
||||||
async _ => hintsUpdater.refresh(),
|
visibleEditors => hintsUpdater.refreshVisibleRustEditors(
|
||||||
|
visibleEditors.filter(isRustTextEditor)
|
||||||
|
),
|
||||||
null,
|
null,
|
||||||
ctx.subscriptions
|
ctx.subscriptions
|
||||||
);
|
);
|
||||||
|
|
||||||
vscode.workspace.onDidChangeTextDocument(
|
vscode.workspace.onDidChangeTextDocument(
|
||||||
async event => {
|
({ contentChanges, document }) => {
|
||||||
if (event.contentChanges.length === 0) return;
|
if (contentChanges.length === 0) return;
|
||||||
if (!isRustDocument(event.document)) return;
|
if (!isRustTextDocument(document)) return;
|
||||||
await hintsUpdater.refresh();
|
|
||||||
|
hintsUpdater.refreshRustDocument(document);
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
ctx.subscriptions
|
ctx.subscriptions
|
||||||
);
|
);
|
||||||
|
|
||||||
vscode.workspace.onDidChangeConfiguration(
|
vscode.workspace.onDidChangeConfiguration(
|
||||||
async _ => hintsUpdater.setEnabled(ctx.config.displayInlayHints),
|
async _ => {
|
||||||
|
// FIXME: ctx.config may have not been refreshed at this point of time, i.e.
|
||||||
|
// it's on onDidChangeConfiguration() handler may've not executed yet
|
||||||
|
// (order of invokation is unspecified)
|
||||||
|
// To fix this we should expose an event emitter from our `Config` itself.
|
||||||
|
await hintsUpdater.setEnabled(ctx.config.displayInlayHints);
|
||||||
|
},
|
||||||
null,
|
null,
|
||||||
ctx.subscriptions
|
ctx.subscriptions
|
||||||
);
|
);
|
||||||
|
|
||||||
ctx.pushCleanup({
|
ctx.pushCleanup({
|
||||||
dispose() {
|
dispose() {
|
||||||
hintsUpdater.clear();
|
hintsUpdater.clearHints();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// XXX: we don't await this, thus Promise rejections won't be handled, but
|
hintsUpdater.setEnabled(ctx.config.displayInlayHints);
|
||||||
// this should never throw in fact...
|
|
||||||
void hintsUpdater.setEnabled(ctx.config.displayInlayHints);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeHintDecorationType = vscode.window.createTextEditorDecorationType({
|
|
||||||
after: {
|
|
||||||
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
|
|
||||||
fontStyle: "normal",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const parameterHintDecorationType = vscode.window.createTextEditorDecorationType({
|
const typeHints = {
|
||||||
before: {
|
decorationType: vscode.window.createTextEditorDecorationType({
|
||||||
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
|
after: {
|
||||||
fontStyle: "normal",
|
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
|
||||||
},
|
fontStyle: "normal",
|
||||||
});
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
toDecoration(hint: ra.InlayHint.TypeHint, conv: lc.Protocol2CodeConverter): vscode.DecorationOptions {
|
||||||
|
return {
|
||||||
|
range: conv.asRange(hint.range),
|
||||||
|
renderOptions: { after: { contentText: `: ${hint.label}` } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const paramHints = {
|
||||||
|
decorationType: vscode.window.createTextEditorDecorationType({
|
||||||
|
before: {
|
||||||
|
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
|
||||||
|
fontStyle: "normal",
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
toDecoration(hint: ra.InlayHint.ParamHint, conv: lc.Protocol2CodeConverter): vscode.DecorationOptions {
|
||||||
|
return {
|
||||||
|
range: conv.asRange(hint.range),
|
||||||
|
renderOptions: { before: { contentText: `${hint.label}: ` } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
class HintsUpdater {
|
class HintsUpdater {
|
||||||
private pending = new Map<string, vscode.CancellationTokenSource>();
|
private sourceFiles = new RustSourceFiles();
|
||||||
private ctx: Ctx;
|
private enabled = false;
|
||||||
private enabled: boolean;
|
|
||||||
|
|
||||||
constructor(ctx: Ctx) {
|
constructor(readonly client: lc.LanguageClient) { }
|
||||||
this.ctx = ctx;
|
|
||||||
this.enabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setEnabled(enabled: boolean): Promise<void> {
|
|
||||||
log.debug({ enabled, prev: this.enabled });
|
|
||||||
|
|
||||||
|
setEnabled(enabled: boolean) {
|
||||||
if (this.enabled === enabled) return;
|
if (this.enabled === enabled) return;
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
|
|
||||||
if (this.enabled) {
|
if (this.enabled) {
|
||||||
return await this.refresh();
|
this.refreshVisibleRustEditors(vscode.window.visibleTextEditors.filter(isRustTextEditor));
|
||||||
} else {
|
} else {
|
||||||
return this.clear();
|
this.clearHints();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clearHints() {
|
||||||
this.ctx.visibleRustEditors.forEach(it => {
|
for (const file of this.sourceFiles) {
|
||||||
this.setTypeDecorations(it, []);
|
file.inlaysRequest?.cancel();
|
||||||
this.setParameterDecorations(it, []);
|
this.renderHints(file, []);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
private renderHints(file: RustSourceFile, hints: ra.InlayHint[]) {
|
||||||
|
file.renderHints(hints, this.client.protocol2CodeConverter);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshRustDocument(document: RustTextDocument) {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
await Promise.all(this.ctx.visibleRustEditors.map(it => this.refreshEditor(it)));
|
|
||||||
|
const file = this.sourceFiles.getSourceFile(document.uri.toString());
|
||||||
|
|
||||||
|
assert(!!file, "Document must be opened in some text editor!");
|
||||||
|
|
||||||
|
void file.fetchAndRenderHints(this.client);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshEditor(editor: vscode.TextEditor): Promise<void> {
|
refreshVisibleRustEditors(visibleEditors: RustTextEditor[]) {
|
||||||
const newHints = await this.queryHints(editor.document.uri.toString());
|
if (!this.enabled) return;
|
||||||
if (newHints == null) return;
|
|
||||||
|
|
||||||
const newTypeDecorations = newHints
|
const visibleSourceFiles = this.sourceFiles.drainEditors(visibleEditors);
|
||||||
.filter(hint => hint.kind === ra.InlayKind.TypeHint)
|
|
||||||
.map(hint => ({
|
|
||||||
range: this.ctx.client.protocol2CodeConverter.asRange(hint.range),
|
|
||||||
renderOptions: {
|
|
||||||
after: {
|
|
||||||
contentText: `: ${hint.label}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
this.setTypeDecorations(editor, newTypeDecorations);
|
|
||||||
|
|
||||||
const newParameterDecorations = newHints
|
// Cancel requests for source files whose editors were disposed (leftovers after drain).
|
||||||
.filter(hint => hint.kind === ra.InlayKind.ParameterHint)
|
for (const { inlaysRequest } of this.sourceFiles) inlaysRequest?.cancel();
|
||||||
.map(hint => ({
|
|
||||||
range: this.ctx.client.protocol2CodeConverter.asRange(hint.range),
|
|
||||||
renderOptions: {
|
|
||||||
before: {
|
|
||||||
contentText: `${hint.label}: `,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
this.setParameterDecorations(editor, newParameterDecorations);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setTypeDecorations(
|
this.sourceFiles = visibleSourceFiles;
|
||||||
editor: vscode.TextEditor,
|
|
||||||
decorations: vscode.DecorationOptions[],
|
|
||||||
) {
|
|
||||||
editor.setDecorations(
|
|
||||||
typeHintDecorationType,
|
|
||||||
this.enabled ? decorations : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private setParameterDecorations(
|
for (const file of this.sourceFiles) {
|
||||||
editor: vscode.TextEditor,
|
if (!file.rerenderHints()) {
|
||||||
decorations: vscode.DecorationOptions[],
|
void file.fetchAndRenderHints(this.client);
|
||||||
) {
|
}
|
||||||
editor.setDecorations(
|
}
|
||||||
parameterHintDecorationType,
|
|
||||||
this.enabled ? decorations : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async queryHints(documentUri: string): Promise<ra.InlayHint[] | null> {
|
|
||||||
this.pending.get(documentUri)?.cancel();
|
|
||||||
|
|
||||||
const tokenSource = new vscode.CancellationTokenSource();
|
|
||||||
this.pending.set(documentUri, tokenSource);
|
|
||||||
|
|
||||||
const request = { textDocument: { uri: documentUri } };
|
|
||||||
|
|
||||||
return sendRequestWithRetry(this.ctx.client, ra.inlayHints, request, tokenSource.token)
|
|
||||||
.catch(_ => null)
|
|
||||||
.finally(() => {
|
|
||||||
if (!tokenSource.token.isCancellationRequested) {
|
|
||||||
this.pending.delete(documentUri);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class encapsulates a map of file uris to respective inlay hints
|
||||||
|
* request cancellation token source (cts) and an array of editors.
|
||||||
|
* E.g.
|
||||||
|
* ```
|
||||||
|
* {
|
||||||
|
* file1.rs -> (cts, (typeDecor, paramDecor), [editor1, editor2])
|
||||||
|
* ^-- there is a cts to cancel the in-flight request
|
||||||
|
* file2.rs -> (cts, null, [editor3])
|
||||||
|
* ^-- no decorations are applied to this source file yet
|
||||||
|
* file3.rs -> (null, (typeDecor, paramDecor), [editor4])
|
||||||
|
* } ^-- there is no inflight request
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Invariants: each stored source file has at least 1 editor.
|
||||||
|
*/
|
||||||
|
class RustSourceFiles {
|
||||||
|
private files = new Map<string, RustSourceFile>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes `editors` from `this` source files and puts them into a returned
|
||||||
|
* source files object. cts and decorations are moved to the returned source files.
|
||||||
|
*/
|
||||||
|
drainEditors(editors: RustTextEditor[]): RustSourceFiles {
|
||||||
|
const result = new RustSourceFiles;
|
||||||
|
|
||||||
|
for (const editor of editors) {
|
||||||
|
const oldFile = this.removeEditor(editor);
|
||||||
|
const newFile = result.addEditor(editor);
|
||||||
|
|
||||||
|
if (oldFile) newFile.stealCacheFrom(oldFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the editor and if it was the only editor for a source file,
|
||||||
|
* the source file is removed altogether.
|
||||||
|
*
|
||||||
|
* @returns A reference to the source file for this editor or
|
||||||
|
* null if no such source file was not found.
|
||||||
|
*/
|
||||||
|
private removeEditor(editor: RustTextEditor): null | RustSourceFile {
|
||||||
|
const uri = editor.document.uri.toString();
|
||||||
|
|
||||||
|
const file = this.files.get(uri);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
|
const editorIndex = file.editors.findIndex(suspect => areEditorsEqual(suspect, editor));
|
||||||
|
|
||||||
|
if (editorIndex >= 0) {
|
||||||
|
file.editors.splice(editorIndex, 1);
|
||||||
|
|
||||||
|
if (file.editors.length === 0) this.files.delete(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns A reference to an existing source file or newly created one for the editor.
|
||||||
|
*/
|
||||||
|
private addEditor(editor: RustTextEditor): RustSourceFile {
|
||||||
|
const uri = editor.document.uri.toString();
|
||||||
|
const file = this.files.get(uri);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
const newFile = new RustSourceFile([editor]);
|
||||||
|
this.files.set(uri, newFile);
|
||||||
|
return newFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.editors.find(suspect => areEditorsEqual(suspect, editor))) {
|
||||||
|
file.editors.push(editor);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSourceFile(uri: string): undefined | RustSourceFile {
|
||||||
|
return this.files.get(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator](): IterableIterator<RustSourceFile> {
|
||||||
|
return this.files.values();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class RustSourceFile {
|
||||||
|
constructor(
|
||||||
|
/**
|
||||||
|
* Editors for this source file (one text document may be opened in multiple editors).
|
||||||
|
* We keep this just an array, because most of the time we have 1 editor for 1 source file.
|
||||||
|
*/
|
||||||
|
readonly editors: RustTextEditor[],
|
||||||
|
/**
|
||||||
|
* Source of the token to cancel in-flight inlay hints request if any.
|
||||||
|
*/
|
||||||
|
public inlaysRequest: null | vscode.CancellationTokenSource = null,
|
||||||
|
|
||||||
|
public decorations: null | {
|
||||||
|
type: vscode.DecorationOptions[];
|
||||||
|
param: vscode.DecorationOptions[];
|
||||||
|
} = null
|
||||||
|
) { }
|
||||||
|
|
||||||
|
stealCacheFrom(other: RustSourceFile) {
|
||||||
|
if (other.inlaysRequest) this.inlaysRequest = other.inlaysRequest;
|
||||||
|
if (other.decorations) this.decorations = other.decorations;
|
||||||
|
|
||||||
|
other.inlaysRequest = null;
|
||||||
|
other.decorations = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rerenderHints(): boolean {
|
||||||
|
if (!this.decorations) return false;
|
||||||
|
|
||||||
|
for (const editor of this.editors) {
|
||||||
|
editor.setDecorations(typeHints.decorationType, this.decorations.type);
|
||||||
|
editor.setDecorations(paramHints.decorationType, this.decorations.param);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHints(hints: ra.InlayHint[], conv: lc.Protocol2CodeConverter) {
|
||||||
|
this.decorations = { type: [], param: [] };
|
||||||
|
|
||||||
|
for (const hint of hints) {
|
||||||
|
switch (hint.kind) {
|
||||||
|
case ra.InlayHint.Kind.TypeHint: {
|
||||||
|
this.decorations.type.push(typeHints.toDecoration(hint, conv));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
case ra.InlayHint.Kind.ParamHint: {
|
||||||
|
this.decorations.param.push(paramHints.toDecoration(hint, conv));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.rerenderHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchAndRenderHints(client: lc.LanguageClient): Promise<void> {
|
||||||
|
this.inlaysRequest?.cancel();
|
||||||
|
|
||||||
|
const tokenSource = new vscode.CancellationTokenSource();
|
||||||
|
this.inlaysRequest = tokenSource;
|
||||||
|
|
||||||
|
const request = { textDocument: { uri: this.editors[0].document.uri.toString() } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hints = await sendRequestWithRetry(client, ra.inlayHints, request, tokenSource.token);
|
||||||
|
this.renderHints(hints, client.protocol2CodeConverter);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
if (this.inlaysRequest === tokenSource) {
|
||||||
|
this.inlaysRequest = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type RustTextDocument = vscode.TextDocument & { languageId: "rust" };
|
||||||
|
type RustTextEditor = vscode.TextEditor & { document: RustTextDocument; id: string };
|
||||||
|
|
||||||
|
function areEditorsEqual(a: RustTextEditor, b: RustTextEditor): boolean {
|
||||||
|
return a.id === b.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRustTextEditor(suspect: vscode.TextEditor & { id?: unknown }): suspect is RustTextEditor {
|
||||||
|
// Dirty hack, we need to access private vscode editor id,
|
||||||
|
// see https://github.com/microsoft/vscode/issues/91788
|
||||||
|
assert(
|
||||||
|
typeof suspect.id === "string",
|
||||||
|
"Private text editor id is no longer available, please update the workaround!"
|
||||||
|
);
|
||||||
|
|
||||||
|
return isRustTextDocument(suspect.document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRustTextDocument(suspect: vscode.TextDocument): suspect is RustTextDocument {
|
||||||
|
return suspect.languageId === "rust";
|
||||||
|
}
|
||||||
|
|
|
@ -98,8 +98,8 @@ export namespace InlayHint {
|
||||||
range: lc.Range;
|
range: lc.Range;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
export type TypeHint = Common & { kind: Kind.TypeHint; }
|
export type TypeHint = Common & { kind: Kind.TypeHint };
|
||||||
export type ParamHint = Common & { kind: Kind.ParamHint; }
|
export type ParamHint = Common & { kind: Kind.ParamHint };
|
||||||
}
|
}
|
||||||
export interface InlayHintsParams {
|
export interface InlayHintsParams {
|
||||||
textDocument: lc.TextDocumentIdentifier;
|
textDocument: lc.TextDocumentIdentifier;
|
||||||
|
|
Loading…
Reference in a new issue