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