]> git.lizzy.rs Git - rust.git/blobdiff - src/tools/rust-analyzer/editors/code/src/ctx.ts
:arrow_up: rust-analyzer
[rust.git] / src / tools / rust-analyzer / editors / code / src / ctx.ts
index 26510011d43970cd415b8f13a731a0af1cc0a120..3e366525ee295392fcdef55b019aa6d511ba5bea 100644 (file)
@@ -2,12 +2,19 @@ import * as vscode from "vscode";
 import * as lc from "vscode-languageclient/node";
 import * as ra from "./lsp_ext";
 
-import { Config } from "./config";
+import { Config, substituteVariablesInEnv, substituteVSCodeVariables } from "./config";
 import { createClient } from "./client";
-import { isRustEditor, RustEditor } from "./util";
+import { isRustDocument, isRustEditor, log, RustEditor } from "./util";
 import { ServerStatusParams } from "./lsp_ext";
+import { PersistentState } from "./persistent_state";
+import { bootstrap } from "./bootstrap";
+
+// We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if
+// only those are in use. We use "Empty" to represent these scenarios
+// (r-a still somewhat works with Live Share, because commands are tunneled to the host)
 
 export type Workspace =
+    | { kind: "Empty" }
     | {
           kind: "Workspace Folder";
       }
@@ -16,73 +23,267 @@ export type Workspace =
           files: vscode.TextDocument[];
       };
 
+export function fetchWorkspace(): Workspace {
+    const folders = (vscode.workspace.workspaceFolders || []).filter(
+        (folder) => folder.uri.scheme === "file"
+    );
+    const rustDocuments = vscode.workspace.textDocuments.filter((document) =>
+        isRustDocument(document)
+    );
+
+    return folders.length === 0
+        ? rustDocuments.length === 0
+            ? { kind: "Empty" }
+            : {
+                  kind: "Detached Files",
+                  files: rustDocuments,
+              }
+        : { kind: "Workspace Folder" };
+}
+
+export type CommandFactory = {
+    enabled: (ctx: CtxInit) => Cmd;
+    disabled?: (ctx: Ctx) => Cmd;
+};
+
+export type CtxInit = Ctx & {
+    readonly client: lc.LanguageClient;
+};
+
 export class Ctx {
-    private constructor(
-        readonly config: Config,
-        private readonly extCtx: vscode.ExtensionContext,
-        readonly client: lc.LanguageClient,
-        readonly serverPath: string,
-        readonly statusBar: vscode.StatusBarItem
-    ) {}
-
-    static async create(
-        config: Config,
-        extCtx: vscode.ExtensionContext,
-        serverPath: string,
+    readonly statusBar: vscode.StatusBarItem;
+    readonly config: Config;
+    readonly workspace: Workspace;
+
+    private _client: lc.LanguageClient | undefined;
+    private _serverPath: string | undefined;
+    private traceOutputChannel: vscode.OutputChannel | undefined;
+    private outputChannel: vscode.OutputChannel | undefined;
+    private clientSubscriptions: Disposable[];
+    private state: PersistentState;
+    private commandFactories: Record<string, CommandFactory>;
+    private commandDisposables: Disposable[];
+
+    get client() {
+        return this._client;
+    }
+
+    constructor(
+        readonly extCtx: vscode.ExtensionContext,
+        commandFactories: Record<string, CommandFactory>,
         workspace: Workspace
-    ): Promise<Ctx> {
-        const client = await createClient(serverPath, workspace, config.serverExtraEnv);
+    ) {
+        extCtx.subscriptions.push(this);
+        this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
+        this.statusBar.show();
+        this.workspace = workspace;
+        this.clientSubscriptions = [];
+        this.commandDisposables = [];
+        this.commandFactories = commandFactories;
 
-        const statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
-        extCtx.subscriptions.push(statusBar);
-        statusBar.text = "rust-analyzer";
-        statusBar.tooltip = "ready";
-        statusBar.command = "rust-analyzer.analyzerStatus";
-        statusBar.show();
+        this.state = new PersistentState(extCtx.globalState);
+        this.config = new Config(extCtx);
 
-        const res = new Ctx(config, extCtx, client, serverPath, statusBar);
+        this.updateCommands("disable");
+        this.setServerStatus({
+            health: "stopped",
+        });
+    }
 
-        res.pushCleanup(client.start());
-        await client.onReady();
-        client.onNotification(ra.serverStatus, (params) => res.setServerStatus(params));
-        return res;
+    dispose() {
+        this.config.dispose();
+        this.statusBar.dispose();
+        void this.disposeClient();
+        this.commandDisposables.forEach((disposable) => disposable.dispose());
     }
 
-    get activeRustEditor(): RustEditor | undefined {
-        const editor = vscode.window.activeTextEditor;
-        return editor && isRustEditor(editor) ? editor : undefined;
+    async onWorkspaceFolderChanges() {
+        const workspace = fetchWorkspace();
+        if (workspace.kind === "Detached Files" && this.workspace.kind === "Detached Files") {
+            if (workspace.files !== this.workspace.files) {
+                if (this.client?.isRunning()) {
+                    // Ideally we wouldn't need to tear down the server here, but currently detached files
+                    // are only specified at server start
+                    await this.stopAndDispose();
+                    await this.start();
+                }
+                return;
+            }
+        }
+        if (workspace.kind === "Workspace Folder" && this.workspace.kind === "Workspace Folder") {
+            return;
+        }
+        if (workspace.kind === "Empty") {
+            await this.stopAndDispose();
+            return;
+        }
+        if (this.client?.isRunning()) {
+            await this.restart();
+        }
+    }
+
+    private async getOrCreateClient() {
+        if (this.workspace.kind === "Empty") {
+            return;
+        }
+
+        if (!this.traceOutputChannel) {
+            this.traceOutputChannel = vscode.window.createOutputChannel(
+                "Rust Analyzer Language Server Trace"
+            );
+            this.pushExtCleanup(this.traceOutputChannel);
+        }
+        if (!this.outputChannel) {
+            this.outputChannel = vscode.window.createOutputChannel("Rust Analyzer Language Server");
+            this.pushExtCleanup(this.outputChannel);
+        }
+
+        if (!this._client) {
+            this._serverPath = await bootstrap(this.extCtx, this.config, this.state).catch(
+                (err) => {
+                    let message = "bootstrap error. ";
+
+                    message +=
+                        'See the logs in "OUTPUT > Rust Analyzer Client" (should open automatically). ';
+                    message +=
+                        'To enable verbose logs use { "rust-analyzer.trace.extension": true }';
+
+                    log.error("Bootstrap error", err);
+                    throw new Error(message);
+                }
+            );
+            const newEnv = substituteVariablesInEnv(
+                Object.assign({}, process.env, this.config.serverExtraEnv)
+            );
+            const run: lc.Executable = {
+                command: this._serverPath,
+                options: { env: newEnv },
+            };
+            const serverOptions = {
+                run,
+                debug: run,
+            };
+
+            let rawInitializationOptions = vscode.workspace.getConfiguration("rust-analyzer");
+
+            if (this.workspace.kind === "Detached Files") {
+                rawInitializationOptions = {
+                    detachedFiles: this.workspace.files.map((file) => file.uri.fsPath),
+                    ...rawInitializationOptions,
+                };
+            }
+
+            const initializationOptions = substituteVSCodeVariables(rawInitializationOptions);
+
+            this._client = await createClient(
+                this.traceOutputChannel,
+                this.outputChannel,
+                initializationOptions,
+                serverOptions
+            );
+            this.pushClientCleanup(
+                this._client.onNotification(ra.serverStatus, (params) =>
+                    this.setServerStatus(params)
+                )
+            );
+        }
+        return this._client;
     }
 
-    get visibleRustEditors(): RustEditor[] {
-        return vscode.window.visibleTextEditors.filter(isRustEditor);
+    async start() {
+        log.info("Starting language client");
+        const client = await this.getOrCreateClient();
+        if (!client) {
+            return;
+        }
+        await client.start();
+        this.updateCommands();
     }
 
-    registerCommand(name: string, factory: (ctx: Ctx) => Cmd) {
-        const fullName = `rust-analyzer.${name}`;
-        const cmd = factory(this);
-        const d = vscode.commands.registerCommand(fullName, cmd);
-        this.pushCleanup(d);
+    async restart() {
+        // FIXME: We should re-use the client, that is ctx.deactivate() if none of the configs have changed
+        await this.stopAndDispose();
+        await this.start();
     }
 
-    get extensionPath(): string {
-        return this.extCtx.extensionPath;
+    async stop() {
+        if (!this._client) {
+            return;
+        }
+        log.info("Stopping language client");
+        this.updateCommands("disable");
+        await this._client.stop();
+    }
+
+    async stopAndDispose() {
+        if (!this._client) {
+            return;
+        }
+        log.info("Disposing language client");
+        this.updateCommands("disable");
+        await this.disposeClient();
+    }
+
+    private async disposeClient() {
+        this.clientSubscriptions?.forEach((disposable) => disposable.dispose());
+        this.clientSubscriptions = [];
+        await this._client?.dispose();
+        this._serverPath = undefined;
+        this._client = undefined;
+    }
+
+    get activeRustEditor(): RustEditor | undefined {
+        const editor = vscode.window.activeTextEditor;
+        return editor && isRustEditor(editor) ? editor : undefined;
     }
 
-    get globalState(): vscode.Memento {
-        return this.extCtx.globalState;
+    get extensionPath(): string {
+        return this.extCtx.extensionPath;
     }
 
     get subscriptions(): Disposable[] {
         return this.extCtx.subscriptions;
     }
 
-    setServerStatus(status: ServerStatusParams) {
+    get serverPath(): string | undefined {
+        return this._serverPath;
+    }
+
+    private updateCommands(forceDisable?: "disable") {
+        this.commandDisposables.forEach((disposable) => disposable.dispose());
+        this.commandDisposables = [];
+
+        const clientRunning = (!forceDisable && this._client?.isRunning()) ?? false;
+        const isClientRunning = function (_ctx: Ctx): _ctx is CtxInit {
+            return clientRunning;
+        };
+
+        for (const [name, factory] of Object.entries(this.commandFactories)) {
+            const fullName = `rust-analyzer.${name}`;
+            let callback;
+            if (isClientRunning(this)) {
+                // we asserted that `client` is defined
+                callback = factory.enabled(this);
+            } else if (factory.disabled) {
+                callback = factory.disabled(this);
+            } else {
+                callback = () =>
+                    vscode.window.showErrorMessage(
+                        `command ${fullName} failed: rust-analyzer server is not running`
+                    );
+            }
+
+            this.commandDisposables.push(vscode.commands.registerCommand(fullName, callback));
+        }
+    }
+
+    setServerStatus(status: ServerStatusParams | { health: "stopped" }) {
         let icon = "";
         const statusBar = this.statusBar;
         switch (status.health) {
             case "ok":
-                statusBar.tooltip = status.message ?? "Ready";
-                statusBar.command = undefined;
+                statusBar.tooltip = (status.message ?? "Ready") + "\nClick to stop server.";
+                statusBar.command = "rust-analyzer.stopServer";
                 statusBar.color = undefined;
                 statusBar.backgroundColor = undefined;
                 break;
@@ -106,14 +307,25 @@ export class Ctx {
                 statusBar.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground");
                 icon = "$(error) ";
                 break;
+            case "stopped":
+                statusBar.tooltip = "Server is stopped.\nClick to start.";
+                statusBar.command = "rust-analyzer.startServer";
+                statusBar.color = undefined;
+                statusBar.backgroundColor = undefined;
+                statusBar.text = `$(stop-circle) rust-analyzer`;
+                return;
         }
         if (!status.quiescent) icon = "$(sync~spin) ";
         statusBar.text = `${icon}rust-analyzer`;
     }
 
-    pushCleanup(d: Disposable) {
+    pushExtCleanup(d: Disposable) {
         this.extCtx.subscriptions.push(d);
     }
+
+    private pushClientCleanup(d: Disposable) {
+        this.clientSubscriptions.push(d);
+    }
 }
 
 export interface Disposable {