• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import * as vscode from "vscode";
2import * as lc from "vscode-languageclient/node";
3import * as ra from "./lsp_ext";
4import * as path from "path";
5
6import { Config, prepareVSCodeConfig } from "./config";
7import { createClient } from "./client";
8import {
9    executeDiscoverProject,
10    isDocumentInWorkspace,
11    isRustDocument,
12    isRustEditor,
13    LazyOutputChannel,
14    log,
15    RustEditor,
16} from "./util";
17import { ServerStatusParams } from "./lsp_ext";
18import {
19    Dependency,
20    DependencyFile,
21    RustDependenciesProvider,
22    DependencyId,
23} from "./dependencies_provider";
24import { execRevealDependency } from "./commands";
25import { PersistentState } from "./persistent_state";
26import { bootstrap } from "./bootstrap";
27import { ExecOptions } from "child_process";
28
29// We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if
30// only those are in use. We use "Empty" to represent these scenarios
31// (r-a still somewhat works with Live Share, because commands are tunneled to the host)
32
33export type Workspace =
34    | { kind: "Empty" }
35    | {
36          kind: "Workspace Folder";
37      }
38    | {
39          kind: "Detached Files";
40          files: vscode.TextDocument[];
41      };
42
43export function fetchWorkspace(): Workspace {
44    const folders = (vscode.workspace.workspaceFolders || []).filter(
45        (folder) => folder.uri.scheme === "file"
46    );
47    const rustDocuments = vscode.workspace.textDocuments.filter((document) =>
48        isRustDocument(document)
49    );
50
51    return folders.length === 0
52        ? rustDocuments.length === 0
53            ? { kind: "Empty" }
54            : {
55                  kind: "Detached Files",
56                  files: rustDocuments,
57              }
58        : { kind: "Workspace Folder" };
59}
60
61export async function discoverWorkspace(
62    files: readonly vscode.TextDocument[],
63    command: string[],
64    options: ExecOptions
65): Promise<JsonProject> {
66    const paths = files.map((f) => `"${f.uri.fsPath}"`).join(" ");
67    const joinedCommand = command.join(" ");
68    const data = await executeDiscoverProject(`${joinedCommand} ${paths}`, options);
69    return JSON.parse(data) as JsonProject;
70}
71
72export type CommandFactory = {
73    enabled: (ctx: CtxInit) => Cmd;
74    disabled?: (ctx: Ctx) => Cmd;
75};
76
77export type CtxInit = Ctx & {
78    readonly client: lc.LanguageClient;
79};
80
81export class Ctx {
82    readonly statusBar: vscode.StatusBarItem;
83    config: Config;
84    readonly workspace: Workspace;
85
86    private _client: lc.LanguageClient | undefined;
87    private _serverPath: string | undefined;
88    private traceOutputChannel: vscode.OutputChannel | undefined;
89    private outputChannel: vscode.OutputChannel | undefined;
90    private clientSubscriptions: Disposable[];
91    private state: PersistentState;
92    private commandFactories: Record<string, CommandFactory>;
93    private commandDisposables: Disposable[];
94    private unlinkedFiles: vscode.Uri[];
95    private _dependencies: RustDependenciesProvider | undefined;
96    private _treeView: vscode.TreeView<Dependency | DependencyFile | DependencyId> | undefined;
97
98    get client() {
99        return this._client;
100    }
101
102    get treeView() {
103        return this._treeView;
104    }
105
106    get dependencies() {
107        return this._dependencies;
108    }
109
110    constructor(
111        readonly extCtx: vscode.ExtensionContext,
112        commandFactories: Record<string, CommandFactory>,
113        workspace: Workspace
114    ) {
115        extCtx.subscriptions.push(this);
116        this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
117        this.workspace = workspace;
118        this.clientSubscriptions = [];
119        this.commandDisposables = [];
120        this.commandFactories = commandFactories;
121        this.unlinkedFiles = [];
122        this.state = new PersistentState(extCtx.globalState);
123        this.config = new Config(extCtx);
124
125        this.updateCommands("disable");
126        this.setServerStatus({
127            health: "stopped",
128        });
129    }
130
131    dispose() {
132        this.config.dispose();
133        this.statusBar.dispose();
134        void this.disposeClient();
135        this.commandDisposables.forEach((disposable) => disposable.dispose());
136    }
137
138    async onWorkspaceFolderChanges() {
139        const workspace = fetchWorkspace();
140        if (workspace.kind === "Detached Files" && this.workspace.kind === "Detached Files") {
141            if (workspace.files !== this.workspace.files) {
142                if (this.client?.isRunning()) {
143                    // Ideally we wouldn't need to tear down the server here, but currently detached files
144                    // are only specified at server start
145                    await this.stopAndDispose();
146                    await this.start();
147                }
148                return;
149            }
150        }
151        if (workspace.kind === "Workspace Folder" && this.workspace.kind === "Workspace Folder") {
152            return;
153        }
154        if (workspace.kind === "Empty") {
155            await this.stopAndDispose();
156            return;
157        }
158        if (this.client?.isRunning()) {
159            await this.restart();
160        }
161    }
162
163    private async getOrCreateClient() {
164        if (this.workspace.kind === "Empty") {
165            return;
166        }
167
168        if (!this.traceOutputChannel) {
169            this.traceOutputChannel = new LazyOutputChannel("Rust Analyzer Language Server Trace");
170            this.pushExtCleanup(this.traceOutputChannel);
171        }
172        if (!this.outputChannel) {
173            this.outputChannel = vscode.window.createOutputChannel("Rust Analyzer Language Server");
174            this.pushExtCleanup(this.outputChannel);
175        }
176
177        if (!this._client) {
178            this._serverPath = await bootstrap(this.extCtx, this.config, this.state).catch(
179                (err) => {
180                    let message = "bootstrap error. ";
181
182                    message +=
183                        'See the logs in "OUTPUT > Rust Analyzer Client" (should open automatically). ';
184                    message +=
185                        'To enable verbose logs use { "rust-analyzer.trace.extension": true }';
186
187                    log.error("Bootstrap error", err);
188                    throw new Error(message);
189                }
190            );
191            const newEnv = Object.assign({}, process.env, this.config.serverExtraEnv);
192            const run: lc.Executable = {
193                command: this._serverPath,
194                options: { env: newEnv },
195            };
196            const serverOptions = {
197                run,
198                debug: run,
199            };
200
201            let rawInitializationOptions = vscode.workspace.getConfiguration("rust-analyzer");
202
203            if (this.workspace.kind === "Detached Files") {
204                rawInitializationOptions = {
205                    detachedFiles: this.workspace.files.map((file) => file.uri.fsPath),
206                    ...rawInitializationOptions,
207                };
208            }
209
210            const discoverProjectCommand = this.config.discoverProjectCommand;
211            if (discoverProjectCommand) {
212                const workspaces: JsonProject[] = await Promise.all(
213                    vscode.workspace.textDocuments
214                        .filter(isRustDocument)
215                        .map(async (file): Promise<JsonProject> => {
216                            return discoverWorkspace([file], discoverProjectCommand, {
217                                cwd: path.dirname(file.uri.fsPath),
218                            });
219                        })
220                );
221
222                this.addToDiscoveredWorkspaces(workspaces);
223            }
224
225            const initializationOptions = prepareVSCodeConfig(
226                rawInitializationOptions,
227                (key, obj) => {
228                    // we only want to set discovered workspaces on the right key
229                    // and if a workspace has been discovered.
230                    if (key === "linkedProjects" && this.config.discoveredWorkspaces.length > 0) {
231                        obj["linkedProjects"] = this.config.discoveredWorkspaces;
232                    }
233                }
234            );
235
236            this._client = await createClient(
237                this.traceOutputChannel,
238                this.outputChannel,
239                initializationOptions,
240                serverOptions,
241                this.config,
242                this.unlinkedFiles
243            );
244            this.pushClientCleanup(
245                this._client.onNotification(ra.serverStatus, (params) =>
246                    this.setServerStatus(params)
247                )
248            );
249            this.pushClientCleanup(
250                this._client.onNotification(ra.openServerLogs, () => {
251                    this.outputChannel!.show();
252                })
253            );
254        }
255        return this._client;
256    }
257
258    async start() {
259        log.info("Starting language client");
260        const client = await this.getOrCreateClient();
261        if (!client) {
262            return;
263        }
264        await client.start();
265        this.updateCommands();
266
267        if (this.config.showDependenciesExplorer) {
268            this.prepareTreeDependenciesView(client);
269        }
270    }
271
272    private prepareTreeDependenciesView(client: lc.LanguageClient) {
273        const ctxInit: CtxInit = {
274            ...this,
275            client: client,
276        };
277        this._dependencies = new RustDependenciesProvider(ctxInit);
278        this._treeView = vscode.window.createTreeView("rustDependencies", {
279            treeDataProvider: this._dependencies,
280            showCollapseAll: true,
281        });
282
283        this.pushExtCleanup(this._treeView);
284        vscode.window.onDidChangeActiveTextEditor(async (e) => {
285            // we should skip documents that belong to the current workspace
286            if (this.shouldRevealDependency(e)) {
287                try {
288                    await execRevealDependency(e);
289                } catch (reason) {
290                    await vscode.window.showErrorMessage(`Dependency error: ${reason}`);
291                }
292            }
293        });
294
295        this.treeView?.onDidChangeVisibility(async (e) => {
296            if (e.visible) {
297                const activeEditor = vscode.window.activeTextEditor;
298                if (this.shouldRevealDependency(activeEditor)) {
299                    try {
300                        await execRevealDependency(activeEditor);
301                    } catch (reason) {
302                        await vscode.window.showErrorMessage(`Dependency error: ${reason}`);
303                    }
304                }
305            }
306        });
307    }
308
309    private shouldRevealDependency(e: vscode.TextEditor | undefined): e is RustEditor {
310        return (
311            e !== undefined &&
312            isRustEditor(e) &&
313            !isDocumentInWorkspace(e.document) &&
314            (this.treeView?.visible || false)
315        );
316    }
317
318    async restart() {
319        // FIXME: We should re-use the client, that is ctx.deactivate() if none of the configs have changed
320        await this.stopAndDispose();
321        await this.start();
322    }
323
324    async stop() {
325        if (!this._client) {
326            return;
327        }
328        log.info("Stopping language client");
329        this.updateCommands("disable");
330        await this._client.stop();
331    }
332
333    async stopAndDispose() {
334        if (!this._client) {
335            return;
336        }
337        log.info("Disposing language client");
338        this.updateCommands("disable");
339        await this.disposeClient();
340    }
341
342    private async disposeClient() {
343        this.clientSubscriptions?.forEach((disposable) => disposable.dispose());
344        this.clientSubscriptions = [];
345        await this._client?.dispose();
346        this._serverPath = undefined;
347        this._client = undefined;
348    }
349
350    get activeRustEditor(): RustEditor | undefined {
351        const editor = vscode.window.activeTextEditor;
352        return editor && isRustEditor(editor) ? editor : undefined;
353    }
354
355    get extensionPath(): string {
356        return this.extCtx.extensionPath;
357    }
358
359    get subscriptions(): Disposable[] {
360        return this.extCtx.subscriptions;
361    }
362
363    get serverPath(): string | undefined {
364        return this._serverPath;
365    }
366
367    addToDiscoveredWorkspaces(workspaces: JsonProject[]) {
368        for (const workspace of workspaces) {
369            const index = this.config.discoveredWorkspaces.indexOf(workspace);
370            if (~index) {
371                this.config.discoveredWorkspaces[index] = workspace;
372            } else {
373                this.config.discoveredWorkspaces.push(workspace);
374            }
375        }
376    }
377
378    private updateCommands(forceDisable?: "disable") {
379        this.commandDisposables.forEach((disposable) => disposable.dispose());
380        this.commandDisposables = [];
381
382        const clientRunning = (!forceDisable && this._client?.isRunning()) ?? false;
383        const isClientRunning = function (_ctx: Ctx): _ctx is CtxInit {
384            return clientRunning;
385        };
386
387        for (const [name, factory] of Object.entries(this.commandFactories)) {
388            const fullName = `rust-analyzer.${name}`;
389            let callback;
390            if (isClientRunning(this)) {
391                // we asserted that `client` is defined
392                callback = factory.enabled(this);
393            } else if (factory.disabled) {
394                callback = factory.disabled(this);
395            } else {
396                callback = () =>
397                    vscode.window.showErrorMessage(
398                        `command ${fullName} failed: rust-analyzer server is not running`
399                    );
400            }
401
402            this.commandDisposables.push(vscode.commands.registerCommand(fullName, callback));
403        }
404    }
405
406    setServerStatus(status: ServerStatusParams | { health: "stopped" }) {
407        let icon = "";
408        const statusBar = this.statusBar;
409        statusBar.show();
410        statusBar.tooltip = new vscode.MarkdownString("", true);
411        statusBar.tooltip.isTrusted = true;
412        switch (status.health) {
413            case "ok":
414                statusBar.tooltip.appendText(status.message ?? "Ready");
415                statusBar.color = undefined;
416                statusBar.backgroundColor = undefined;
417                statusBar.command = "rust-analyzer.stopServer";
418                this.dependencies?.refresh();
419                break;
420            case "warning":
421                if (status.message) {
422                    statusBar.tooltip.appendText(status.message);
423                }
424                statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground");
425                statusBar.backgroundColor = new vscode.ThemeColor(
426                    "statusBarItem.warningBackground"
427                );
428                statusBar.command = "rust-analyzer.openLogs";
429                icon = "$(warning) ";
430                break;
431            case "error":
432                if (status.message) {
433                    statusBar.tooltip.appendText(status.message);
434                }
435                statusBar.color = new vscode.ThemeColor("statusBarItem.errorForeground");
436                statusBar.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground");
437                statusBar.command = "rust-analyzer.openLogs";
438                icon = "$(error) ";
439                break;
440            case "stopped":
441                statusBar.tooltip.appendText("Server is stopped");
442                statusBar.tooltip.appendMarkdown(
443                    "\n\n[Start server](command:rust-analyzer.startServer)"
444                );
445                statusBar.color = undefined;
446                statusBar.backgroundColor = undefined;
447                statusBar.command = "rust-analyzer.startServer";
448                statusBar.text = `$(stop-circle) rust-analyzer`;
449                return;
450        }
451        if (statusBar.tooltip.value) {
452            statusBar.tooltip.appendText("\n\n");
453        }
454        statusBar.tooltip.appendMarkdown("\n\n[Open logs](command:rust-analyzer.openLogs)");
455        statusBar.tooltip.appendMarkdown(
456            "\n\n[Reload Workspace](command:rust-analyzer.reloadWorkspace)"
457        );
458        statusBar.tooltip.appendMarkdown(
459            "\n\n[Rebuild Proc Macros](command:rust-analyzer.rebuildProcMacros)"
460        );
461        statusBar.tooltip.appendMarkdown(
462            "\n\n[Restart server](command:rust-analyzer.restartServer)"
463        );
464        statusBar.tooltip.appendMarkdown("\n\n[Stop server](command:rust-analyzer.stopServer)");
465        if (!status.quiescent) icon = "$(sync~spin) ";
466        statusBar.text = `${icon}rust-analyzer`;
467    }
468
469    pushExtCleanup(d: Disposable) {
470        this.extCtx.subscriptions.push(d);
471    }
472
473    private pushClientCleanup(d: Disposable) {
474        this.clientSubscriptions.push(d);
475    }
476}
477
478export interface Disposable {
479    dispose(): void;
480}
481
482export type Cmd = (...args: any[]) => unknown;
483