Skip to content

vscode: redesign inlay hints to be capable of handling multiple editors for one source file #3378

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 7, 2020
12 changes: 5 additions & 7 deletions editors/code/src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as lc from 'vscode-languageclient';

import { Config } from './config';
import { createClient } from './client';
import { isRustDocument } from './util';
import { isRustEditor, RustEditor } from './util';

export class Ctx {
private constructor(
Expand All @@ -22,17 +22,15 @@ export class Ctx {
return res;
}

get activeRustEditor(): vscode.TextEditor | undefined {
get activeRustEditor(): RustEditor | undefined {
const editor = vscode.window.activeTextEditor;
return editor && isRustDocument(editor.document)
return editor && isRustEditor(editor)
? editor
: undefined;
}

get visibleRustEditors(): vscode.TextEditor[] {
return vscode.window.visibleTextEditors.filter(
editor => isRustDocument(editor.document),
);
get visibleRustEditors(): RustEditor[] {
return vscode.window.visibleTextEditors.filter(isRustEditor);
}

registerCommand(name: string, factory: (ctx: Ctx) => Cmd) {
Expand Down
290 changes: 174 additions & 116 deletions editors/code/src/inlay_hints.ts
Original file line number Diff line number Diff line change
@@ -1,156 +1,214 @@
import * as lc from "vscode-languageclient";
import * as vscode from 'vscode';
import * as ra from './rust-analyzer-api';

import { Ctx } from './ctx';
import { log, sendRequestWithRetry, isRustDocument } from './util';
import { Ctx, Disposable } from './ctx';
import { sendRequestWithRetry, isRustDocument, RustDocument, RustEditor } from './util';

export function activateInlayHints(ctx: Ctx) {
const hintsUpdater = new HintsUpdater(ctx);
vscode.window.onDidChangeVisibleTextEditors(
async _ => hintsUpdater.refresh(),
null,
ctx.subscriptions
);

vscode.workspace.onDidChangeTextDocument(
async event => {
if (event.contentChanges.length === 0) return;
if (!isRustDocument(event.document)) return;
await hintsUpdater.refresh();
export function activateInlayHints(ctx: Ctx) {
const maybeUpdater = {
updater: null as null | HintsUpdater,
onConfigChange() {
if (!ctx.config.displayInlayHints) {
return this.dispose();
}
if (!this.updater) this.updater = new HintsUpdater(ctx);
},
null,
ctx.subscriptions
);
dispose() {
this.updater?.dispose();
this.updater = null;
}
};

ctx.pushCleanup(maybeUpdater);

vscode.workspace.onDidChangeConfiguration(
async _ => hintsUpdater.setEnabled(ctx.config.displayInlayHints),
null,
ctx.subscriptions
maybeUpdater.onConfigChange, maybeUpdater, ctx.subscriptions
);

ctx.pushCleanup({
dispose() {
hintsUpdater.clear();
maybeUpdater.onConfigChange();
}


const typeHints = {
decorationType: vscode.window.createTextEditorDecorationType({
after: {
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
fontStyle: "normal",
}
});
}),

// XXX: we don't await this, thus Promise rejections won't be handled, but
// this should never throw in fact...
void hintsUpdater.setEnabled(ctx.config.displayInlayHints);
}
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",
}
}),

const typeHintDecorationType = vscode.window.createTextEditorDecorationType({
after: {
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
fontStyle: "normal",
},
});

const parameterHintDecorationType = vscode.window.createTextEditorDecorationType({
before: {
color: new vscode.ThemeColor('rust_analyzer.inlayHint'),
fontStyle: "normal",
},
});

class HintsUpdater {
private pending = new Map<string, vscode.CancellationTokenSource>();
private ctx: Ctx;
private enabled: boolean;

constructor(ctx: Ctx) {
this.ctx = ctx;
this.enabled = false;
toDecoration(hint: ra.InlayHint.ParamHint, conv: lc.Protocol2CodeConverter): vscode.DecorationOptions {
return {
range: conv.asRange(hint.range),
renderOptions: { before: { contentText: `${hint.label}: ` } }
};
}
};

async setEnabled(enabled: boolean): Promise<void> {
log.debug({ enabled, prev: this.enabled });
class HintsUpdater implements Disposable {
private sourceFiles = new Map<string, RustSourceFile>(); // map Uri -> RustSourceFile
private readonly disposables: Disposable[] = [];

if (this.enabled === enabled) return;
this.enabled = enabled;
constructor(private readonly ctx: Ctx) {
vscode.window.onDidChangeVisibleTextEditors(
this.onDidChangeVisibleTextEditors,
this,
this.disposables
);

if (this.enabled) {
return await this.refresh();
} else {
return this.clear();
}
vscode.workspace.onDidChangeTextDocument(
this.onDidChangeTextDocument,
this,
this.disposables
);

// Set up initial cache shape
ctx.visibleRustEditors.forEach(editor => this.sourceFiles.set(
editor.document.uri.toString(),
{
document: editor.document,
inlaysRequest: null,
cachedDecorations: null
}
));

this.syncCacheAndRenderHints();
}

clear() {
this.ctx.visibleRustEditors.forEach(it => {
this.setTypeDecorations(it, []);
this.setParameterDecorations(it, []);
});
dispose() {
this.sourceFiles.forEach(file => file.inlaysRequest?.cancel());
this.ctx.visibleRustEditors.forEach(editor => this.renderDecorations(editor, { param: [], type: [] }));
this.disposables.forEach(d => d.dispose());
}

onDidChangeTextDocument({ contentChanges, document }: vscode.TextDocumentChangeEvent) {
if (contentChanges.length === 0 || !isRustDocument(document)) return;
this.syncCacheAndRenderHints();
}

async refresh() {
if (!this.enabled) return;
await Promise.all(this.ctx.visibleRustEditors.map(it => this.refreshEditor(it)));
private syncCacheAndRenderHints() {
// FIXME: make inlayHints request pass an array of files?
this.sourceFiles.forEach((file, uri) => this.fetchHints(file).then(hints => {
if (!hints) return;

file.cachedDecorations = this.hintsToDecorations(hints);

for (const editor of this.ctx.visibleRustEditors) {
if (editor.document.uri.toString() === uri) {
this.renderDecorations(editor, file.cachedDecorations);
}
}
}));
}

private async refreshEditor(editor: vscode.TextEditor): Promise<void> {
const newHints = await this.queryHints(editor.document.uri.toString());
if (newHints == null) return;

const newTypeDecorations = newHints
.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
.filter(hint => hint.kind === ra.InlayKind.ParameterHint)
.map(hint => ({
range: this.ctx.client.protocol2CodeConverter.asRange(hint.range),
renderOptions: {
before: {
contentText: `${hint.label}: `,
},
},
}));
this.setParameterDecorations(editor, newParameterDecorations);
onDidChangeVisibleTextEditors() {
const newSourceFiles = new Map<string, RustSourceFile>();

// Rerendering all, even up-to-date editors for simplicity
this.ctx.visibleRustEditors.forEach(async editor => {
const uri = editor.document.uri.toString();
const file = this.sourceFiles.get(uri) ?? {
document: editor.document,
inlaysRequest: null,
cachedDecorations: null
};
newSourceFiles.set(uri, file);

// No text documents changed, so we may try to use the cache
if (!file.cachedDecorations) {
file.inlaysRequest?.cancel();

const hints = await this.fetchHints(file);
if (!hints) return;

file.cachedDecorations = this.hintsToDecorations(hints);
}

this.renderDecorations(editor, file.cachedDecorations);
});

// Cancel requests for no longer visible (disposed) source files
this.sourceFiles.forEach((file, uri) => {
if (!newSourceFiles.has(uri)) file.inlaysRequest?.cancel();
});

this.sourceFiles = newSourceFiles;
}

private setTypeDecorations(
editor: vscode.TextEditor,
decorations: vscode.DecorationOptions[],
) {
editor.setDecorations(
typeHintDecorationType,
this.enabled ? decorations : [],
);
private renderDecorations(editor: RustEditor, decorations: InlaysDecorations) {
editor.setDecorations(typeHints.decorationType, decorations.type);
editor.setDecorations(paramHints.decorationType, decorations.param);
}

private setParameterDecorations(
editor: vscode.TextEditor,
decorations: vscode.DecorationOptions[],
) {
editor.setDecorations(
parameterHintDecorationType,
this.enabled ? decorations : [],
);
private hintsToDecorations(hints: ra.InlayHint[]): InlaysDecorations {
const decorations: InlaysDecorations = { type: [], param: [] };
const conv = this.ctx.client.protocol2CodeConverter;

for (const hint of hints) {
switch (hint.kind) {
case ra.InlayHint.Kind.TypeHint: {
decorations.type.push(typeHints.toDecoration(hint, conv));
continue;
}
case ra.InlayHint.Kind.ParamHint: {
decorations.param.push(paramHints.toDecoration(hint, conv));
continue;
}
}
}
return decorations;
}

private async queryHints(documentUri: string): Promise<ra.InlayHint[] | null> {
this.pending.get(documentUri)?.cancel();
private async fetchHints(file: RustSourceFile): Promise<null | ra.InlayHint[]> {
file.inlaysRequest?.cancel();

const tokenSource = new vscode.CancellationTokenSource();
this.pending.set(documentUri, tokenSource);
file.inlaysRequest = tokenSource;

const request = { textDocument: { uri: documentUri } };
const request = { textDocument: { uri: file.document.uri.toString() } };

return sendRequestWithRetry(this.ctx.client, ra.inlayHints, request, tokenSource.token)
.catch(_ => null)
.finally(() => {
if (!tokenSource.token.isCancellationRequested) {
this.pending.delete(documentUri);
if (file.inlaysRequest === tokenSource) {
file.inlaysRequest = null;
}
});
}
}

interface InlaysDecorations {
type: vscode.DecorationOptions[];
param: vscode.DecorationOptions[];
}

interface RustSourceFile {
/*
* Source of the token to cancel in-flight inlay hints request if any.
*/
inlaysRequest: null | vscode.CancellationTokenSource;
/**
* Last applied decorations.
*/
cachedDecorations: null | InlaysDecorations;

document: RustDocument;
}
22 changes: 14 additions & 8 deletions editors/code/src/rust-analyzer-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,20 @@ export interface Runnable {
export const runnables = request<RunnablesParams, Vec<Runnable>>("runnables");


export const enum InlayKind {
TypeHint = "TypeHint",
ParameterHint = "ParameterHint",
}
export interface InlayHint {
range: lc.Range;
kind: InlayKind;
label: string;

export type InlayHint = InlayHint.TypeHint | InlayHint.ParamHint;

export namespace InlayHint {
export const enum Kind {
TypeHint = "TypeHint",
ParamHint = "ParameterHint",
}
interface Common {
range: lc.Range;
label: string;
}
export type TypeHint = Common & { kind: Kind.TypeHint };
export type ParamHint = Common & { kind: Kind.ParamHint };
}
export interface InlayHintsParams {
textDocument: lc.TextDocumentIdentifier;
Expand Down
Loading