| import * as vscode from "vscode"; | |
| import * as lc from "vscode-languageclient/node"; | |
| import * as ra from "./lsp_ext"; | |
| import { Config, prepareVSCodeConfig } from "./config"; | |
| import { createClient } from "./client"; | |
| import { | |
| findRustToolchainFiles, | |
| isCargoTomlEditor, | |
| isDocumentInWorkspace, | |
| isRustDocument, | |
| isRustEditor, | |
| LazyOutputChannel, | |
| log, | |
| type RustEditor, | |
| } from "./util"; | |
| import type { ServerStatusParams } from "./lsp_ext"; | |
| import { | |
| type Dependency, | |
| type DependencyFile, | |
| RustDependenciesProvider, | |
| type DependencyId, | |
| } from "./dependencies_provider"; | |
| import { SyntaxTreeProvider, type SyntaxElement } from "./syntax_tree_provider"; | |
| import { execRevealDependency } from "./commands"; | |
| import { PersistentState } from "./persistent_state"; | |
| import { bootstrap } from "./bootstrap"; | |
| import { prepareTestExplorer } from "./test_explorer"; | |
| import { spawn } from "node:child_process"; | |
| import { text } from "node:stream/consumers"; | |
| import type { RustAnalyzerExtensionApi } from "./main"; | |
| // 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" } | |
| | { kind: "Detached Files"; 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 implements RustAnalyzerExtensionApi { | |
| readonly statusBar: vscode.StatusBarItem; | |
| readonly config: Config; | |
| readonly workspace: Workspace; | |
| readonly version: string; | |
| private _client: lc.LanguageClient | undefined; | |
| private _serverPath: string | undefined; | |
| private traceOutputChannel: vscode.OutputChannel | undefined; | |
| private testController: vscode.TestController | undefined; | |
| private outputChannel: vscode.OutputChannel | undefined; | |
| private clientSubscriptions: Disposable[]; | |
| private state: PersistentState; | |
| private commandFactories: Record<string, CommandFactory>; | |
| private commandDisposables: Disposable[]; | |
| private unlinkedFiles: vscode.Uri[]; | |
| private _dependenciesProvider: RustDependenciesProvider | undefined; | |
| private _dependencyTreeView: | |
| | vscode.TreeView<Dependency | DependencyFile | DependencyId> | |
| | undefined; | |
| private _syntaxTreeProvider: SyntaxTreeProvider | undefined; | |
| private _syntaxTreeView: vscode.TreeView<SyntaxElement> | undefined; | |
| private lastStatus: ServerStatusParams | { health: "stopped" } = { health: "stopped" }; | |
| private _serverVersion: string; | |
| private statusBarActiveEditorListener: Disposable; | |
| get serverPath(): string | undefined { | |
| return this._serverPath; | |
| } | |
| get serverVersion(): string | undefined { | |
| return this._serverVersion; | |
| } | |
| get client() { | |
| return this._client; | |
| } | |
| get dependencyTreeView() { | |
| return this._dependencyTreeView; | |
| } | |
| get dependenciesProvider() { | |
| return this._dependenciesProvider; | |
| } | |
| get syntaxTreeView() { | |
| return this._syntaxTreeView; | |
| } | |
| get syntaxTreeProvider() { | |
| return this._syntaxTreeProvider; | |
| } | |
| constructor( | |
| readonly extCtx: vscode.ExtensionContext, | |
| commandFactories: Record<string, CommandFactory>, | |
| workspace: Workspace, | |
| ) { | |
| extCtx.subscriptions.push(this); | |
| this.version = extCtx.extension.packageJSON.version ?? "<unknown>"; | |
| this._serverVersion = "<not running>"; | |
| this.config = new Config(extCtx); | |
| this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); | |
| this.updateStatusBarVisibility(vscode.window.activeTextEditor); | |
| this.statusBarActiveEditorListener = vscode.window.onDidChangeActiveTextEditor((editor) => | |
| this.updateStatusBarVisibility(editor), | |
| ); | |
| if (this.config.testExplorer) { | |
| this.testController = vscode.tests.createTestController( | |
| "rustAnalyzerTestController", | |
| "Rust Analyzer test controller", | |
| ); | |
| } | |
| this.workspace = workspace; | |
| this.clientSubscriptions = []; | |
| this.commandDisposables = []; | |
| this.commandFactories = commandFactories; | |
| this.unlinkedFiles = []; | |
| this.state = new PersistentState(extCtx.globalState); | |
| this.updateCommands("disable"); | |
| this.setServerStatus({ | |
| health: "stopped", | |
| }); | |
| } | |
| async addConfiguration( | |
| extensionId: string, | |
| configuration: Record<string, unknown>, | |
| ): Promise<void> { | |
| await this.config.addExtensionConfiguration(extensionId, configuration); | |
| } | |
| dispose() { | |
| this.config.dispose(); | |
| this.statusBar.dispose(); | |
| this.statusBarActiveEditorListener.dispose(); | |
| this.testController?.dispose(); | |
| void this.disposeClient(); | |
| this.commandDisposables.forEach((disposable) => disposable.dispose()); | |
| } | |
| 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 = new LazyOutputChannel("rust-analyzer LSP 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 this.bootstrap(); | |
| text(spawn(this._serverPath, ["--version"]).stdout.setEncoding("utf-8")).then( | |
| (data) => { | |
| const prefix = `rust-analyzer `; | |
| this._serverVersion = data | |
| .slice(data.startsWith(prefix) ? prefix.length : 0) | |
| .trim(); | |
| this.refreshServerStatus(); | |
| }, | |
| (_) => { | |
| this._serverVersion = "<unknown>"; | |
| this.refreshServerStatus(); | |
| }, | |
| ); | |
| const newEnv = { ...process.env }; | |
| for (const [k, v] of Object.entries(this.config.serverExtraEnv)) { | |
| if (v) { | |
| newEnv[k] = v; | |
| } else if (k in newEnv) { | |
| delete newEnv[k]; | |
| } | |
| } | |
| const run: lc.Executable = { | |
| command: this._serverPath, | |
| options: { env: newEnv }, | |
| }; | |
| const serverOptions = { | |
| run, | |
| debug: run, | |
| }; | |
| let rawInitializationOptions = this.config.cfg; | |
| if (this.workspace.kind === "Detached Files") { | |
| rawInitializationOptions = { | |
| detachedFiles: this.workspace.files.map((file) => file.uri.fsPath), | |
| ...rawInitializationOptions, | |
| }; | |
| } | |
| const initializationOptions = prepareVSCodeConfig(rawInitializationOptions); | |
| this._client = await createClient( | |
| this.traceOutputChannel, | |
| this.outputChannel, | |
| initializationOptions, | |
| serverOptions, | |
| this.config, | |
| this.unlinkedFiles, | |
| ); | |
| this.pushClientCleanup( | |
| this._client.onNotification(ra.serverStatus, (params) => | |
| this.setServerStatus(params), | |
| ), | |
| ); | |
| this.pushClientCleanup( | |
| this._client.onNotification(ra.openServerLogs, () => { | |
| this.outputChannel!.show(); | |
| }), | |
| ); | |
| this.pushClientCleanup( | |
| this._client.onNotification( | |
| lc.ShowMessageNotification.type, | |
| async (params: lc.ShowMessageParams) => { | |
| // When an MSRV warning is detected and a rust-toolchain file exists, | |
| // show an additional message with actionable guidance about adding | |
| // the rust-analyzer component. | |
| await handleMsrvWarning(params.message); | |
| }, | |
| ), | |
| ); | |
| } | |
| return this._client; | |
| } | |
| private async bootstrap(): Promise<string> { | |
| return bootstrap(this.extCtx, this.config, this.state).catch((err) => { | |
| let message = "bootstrap error. "; | |
| message += | |
| 'See the logs in "OUTPUT > rust-analyzer Extension" (should open automatically).'; | |
| message += | |
| ' To enable verbose logs, click the gear icon in the "OUTPUT" tab and select "Debug".'; | |
| log.error("Bootstrap error", err); | |
| throw new Error(message); | |
| }); | |
| } | |
| async start() { | |
| log.info("Starting language client"); | |
| const client = await this.getOrCreateClient(); | |
| if (!client) { | |
| return; | |
| } | |
| await client.start(); | |
| this.updateCommands(); | |
| if (this.testController) { | |
| prepareTestExplorer(this, this.testController, client); | |
| } | |
| if (this.config.showDependenciesExplorer) { | |
| this.prepareTreeDependenciesView(client); | |
| } | |
| if (this.config.showSyntaxTree) { | |
| this.prepareSyntaxTreeView(client); | |
| } | |
| } | |
| private prepareTreeDependenciesView(client: lc.LanguageClient) { | |
| const ctxInit: CtxInit = { | |
| ...this, | |
| client: client, | |
| }; | |
| this._dependenciesProvider = new RustDependenciesProvider(ctxInit); | |
| this._dependencyTreeView = vscode.window.createTreeView("rustDependencies", { | |
| treeDataProvider: this._dependenciesProvider, | |
| showCollapseAll: true, | |
| }); | |
| this.pushExtCleanup(this._dependencyTreeView); | |
| vscode.window.onDidChangeActiveTextEditor(async (e) => { | |
| // we should skip documents that belong to the current workspace | |
| if (this.shouldRevealDependency(e)) { | |
| try { | |
| await execRevealDependency(e); | |
| } catch (reason) { | |
| await vscode.window.showErrorMessage(`Dependency error: ${reason}`); | |
| } | |
| } | |
| }); | |
| this.dependencyTreeView?.onDidChangeVisibility(async (e) => { | |
| if (e.visible) { | |
| const activeEditor = vscode.window.activeTextEditor; | |
| if (this.shouldRevealDependency(activeEditor)) { | |
| try { | |
| await execRevealDependency(activeEditor); | |
| } catch (reason) { | |
| await vscode.window.showErrorMessage(`Dependency error: ${reason}`); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| private shouldRevealDependency(e: vscode.TextEditor | undefined): e is RustEditor { | |
| return ( | |
| e !== undefined && | |
| isRustEditor(e) && | |
| !isDocumentInWorkspace(e.document) && | |
| (this.dependencyTreeView?.visible || false) | |
| ); | |
| } | |
| private prepareSyntaxTreeView(client: lc.LanguageClient) { | |
| const ctxInit: CtxInit = { | |
| ...this, | |
| client: client, | |
| }; | |
| this._syntaxTreeProvider = new SyntaxTreeProvider(ctxInit); | |
| this._syntaxTreeView = vscode.window.createTreeView("rustSyntaxTree", { | |
| treeDataProvider: this._syntaxTreeProvider, | |
| showCollapseAll: true, | |
| }); | |
| this.pushExtCleanup(this._syntaxTreeView); | |
| vscode.window.onDidChangeActiveTextEditor(async () => { | |
| if (this.syntaxTreeView?.visible) { | |
| await this.syntaxTreeProvider?.refresh(); | |
| } | |
| }); | |
| vscode.workspace.onDidChangeTextDocument(async (e) => { | |
| if ( | |
| vscode.window.activeTextEditor?.document !== e.document || | |
| e.contentChanges.length === 0 | |
| ) { | |
| return; | |
| } | |
| if (this.syntaxTreeView?.visible) { | |
| await this.syntaxTreeProvider?.refresh(); | |
| } | |
| }); | |
| vscode.window.onDidChangeTextEditorSelection(async (e) => { | |
| if (!this.syntaxTreeView?.visible || !isRustEditor(e.textEditor)) { | |
| return; | |
| } | |
| const selection = e.selections[0]; | |
| if (selection === undefined) { | |
| return; | |
| } | |
| const result = this.syntaxTreeProvider?.getElementByRange(selection); | |
| if (result !== undefined) { | |
| await this.syntaxTreeView?.reveal(result); | |
| } | |
| }); | |
| this._syntaxTreeView.onDidChangeVisibility(async (e) => { | |
| if (e.visible) { | |
| await this.syntaxTreeProvider?.refresh(); | |
| } | |
| }); | |
| } | |
| 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(); | |
| } | |
| 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"); | |
| // we give the server 100ms to stop gracefully | |
| await this.client?.stop(100).catch((_) => {}); | |
| 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 activeCargoTomlEditor(): RustEditor | undefined { | |
| const editor = vscode.window.activeTextEditor; | |
| return editor && isCargoTomlEditor(editor) ? editor : undefined; | |
| } | |
| get extensionPath(): string { | |
| return this.extCtx.extensionPath; | |
| } | |
| get subscriptions(): Disposable[] { | |
| return this.extCtx.subscriptions; | |
| } | |
| 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" }) { | |
| this.lastStatus = status; | |
| this.updateStatusBarItem(); | |
| } | |
| refreshServerStatus() { | |
| this.updateStatusBarItem(); | |
| } | |
| private updateStatusBarItem() { | |
| let icon = ""; | |
| const status = this.lastStatus; | |
| const statusBar = this.statusBar; | |
| statusBar.tooltip = new vscode.MarkdownString("", true); | |
| statusBar.tooltip.isTrusted = true; | |
| switch (status.health) { | |
| case "ok": | |
| statusBar.color = undefined; | |
| statusBar.backgroundColor = undefined; | |
| if (this.config.statusBarClickAction === "stopServer") { | |
| statusBar.command = "rust-analyzer.stopServer"; | |
| } else { | |
| statusBar.command = "rust-analyzer.openLogs"; | |
| } | |
| this.dependenciesProvider?.refresh(); | |
| void this.syntaxTreeProvider?.refresh(); | |
| break; | |
| case "warning": | |
| statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); | |
| statusBar.backgroundColor = new vscode.ThemeColor( | |
| "statusBarItem.warningBackground", | |
| ); | |
| statusBar.command = "rust-analyzer.openLogs"; | |
| icon = "$(warning) "; | |
| break; | |
| case "error": | |
| statusBar.color = new vscode.ThemeColor("statusBarItem.errorForeground"); | |
| statusBar.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground"); | |
| statusBar.command = "rust-analyzer.openLogs"; | |
| icon = "$(error) "; | |
| break; | |
| case "stopped": | |
| statusBar.tooltip.appendText("Server is stopped"); | |
| statusBar.tooltip.appendMarkdown( | |
| "\n\n[Start server](command:rust-analyzer.startServer)", | |
| ); | |
| statusBar.color = new vscode.ThemeColor("statusBarItem.warningForeground"); | |
| statusBar.backgroundColor = new vscode.ThemeColor( | |
| "statusBarItem.warningBackground", | |
| ); | |
| statusBar.command = "rust-analyzer.startServer"; | |
| statusBar.text = "$(stop-circle) rust-analyzer"; | |
| return; | |
| } | |
| if (status.message) { | |
| statusBar.tooltip.appendMarkdown(status.message); | |
| } | |
| if (statusBar.tooltip.value) { | |
| statusBar.tooltip.appendMarkdown("\n\n---\n\n"); | |
| } | |
| const toggleCheckOnSave = this.config.checkOnSave ? "Disable" : "Enable"; | |
| statusBar.tooltip.appendMarkdown( | |
| `[Extension Info](command:rust-analyzer.serverVersion "Show version and server binary info"): Version ${this.version}, Server Version ${this._serverVersion}\n\n` + | |
| `---\n\n` + | |
| `[$(terminal) Open Logs](command:rust-analyzer.openLogs "Open the server logs")\n\n` + | |
| `[$(settings) ${toggleCheckOnSave} Check on Save](command:rust-analyzer.toggleCheckOnSave "Temporarily ${toggleCheckOnSave.toLowerCase()} check on save functionality")\n\n` + | |
| `[$(refresh) Reload Workspace](command:rust-analyzer.reloadWorkspace "Reload and rediscover workspaces")\n\n` + | |
| `[$(symbol-property) Rebuild Build Dependencies](command:rust-analyzer.rebuildProcMacros "Rebuild build scripts and proc-macros")\n\n` + | |
| `[$(stop-circle) Stop server](command:rust-analyzer.stopServer "Stop the server")\n\n` + | |
| `[$(debug-restart) Restart server](command:rust-analyzer.restartServer "Restart the server")`, | |
| ); | |
| if (!status.quiescent) icon = "$(loading~spin) "; | |
| statusBar.text = `${icon}rust-analyzer`; | |
| } | |
| private updateStatusBarVisibility(editor: vscode.TextEditor | undefined) { | |
| const showStatusBar = this.config.statusBarShowStatusBar; | |
| if (showStatusBar == null || showStatusBar === "never") { | |
| this.statusBar.hide(); | |
| } else if (showStatusBar === "always") { | |
| this.statusBar.show(); | |
| } else { | |
| const documentSelector = showStatusBar.documentSelector; | |
| if (editor != null && vscode.languages.match(documentSelector, editor.document) > 0) { | |
| this.statusBar.show(); | |
| } else { | |
| this.statusBar.hide(); | |
| } | |
| } | |
| } | |
| pushExtCleanup(d: Disposable) { | |
| this.extCtx.subscriptions.push(d); | |
| } | |
| pushClientCleanup(d: Disposable) { | |
| this.clientSubscriptions.push(d); | |
| } | |
| } | |
| export interface Disposable { | |
| dispose(): void; | |
| } | |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |
| export type Cmd = (...args: any[]) => unknown; | |
| /** | |
| * Pattern to detect MSRV warning messages from the rust-analyzer server. | |
| */ | |
| const MSRV_WARNING_PATTERN = /using an outdated toolchain version.*rust-analyzer only supports/is; | |
| /** | |
| * Handles the MSRV warning by checking for rust-toolchain files and showing | |
| * an enhanced message if found. | |
| */ | |
| export async function handleMsrvWarning(message: string): Promise<boolean> { | |
| if (!MSRV_WARNING_PATTERN.test(message)) { | |
| return false; | |
| } | |
| const toolchainFiles = await findRustToolchainFiles(); | |
| if (toolchainFiles.length === 0) { | |
| return false; | |
| } | |
| const openFile = "Open rust-toolchain file"; | |
| const result = await vscode.window.showWarningMessage( | |
| "Your workspace uses a rust-toolchain file with a toolchain too old for the extension shipped rust-analyzer to work properly. " + | |
| "Consider adding the rust-analyzer component to the toolchain file to use a compatible rust-analyzer version. " + | |
| "Add the following to your rust-toolchain file's `[toolchain]` section:\n" + | |
| 'components = ["rust-analyzer"]', | |
| { modal: true }, | |
| openFile, | |
| ); | |
| if (result === openFile) { | |
| const fileToOpen = toolchainFiles[0]; | |
| if (fileToOpen) { | |
| const document = await vscode.workspace.openTextDocument(fileToOpen); | |
| await vscode.window.showTextDocument(document); | |
| } | |
| } | |
| return true; | |
| } |