import * as anser from "anser"; import * as lc from "vscode-languageclient/node"; import * as vscode from "vscode"; import * as ra from "../src/lsp_ext"; import * as Is from "vscode-languageclient/lib/common/utils/is"; import { assert } from "./util"; import * as diagnostics from "./diagnostics"; import { WorkspaceEdit } from "vscode"; import { type Config, prepareVSCodeConfig } from "./config"; import { sep as pathSeparator } from "path"; import { RaLanguageClient } from "./lang_client"; export async function createClient( traceOutputChannel: vscode.OutputChannel, outputChannel: vscode.OutputChannel, initializationOptions: lc.LanguageClientOptions["initializationOptions"], serverOptions: lc.ServerOptions, config: Config, unlinkedFiles: vscode.Uri[], ): Promise { const raMiddleware: lc.Middleware = { workspace: { // HACK: This is a workaround, when the client has been disposed, VSCode // continues to emit events to the client and the default one for this event // attempt to restart the client for no reason async didChangeWatchedFile(event, next) { if (client.isRunning()) { await next(event); } }, async configuration( params: lc.ConfigurationParams, _token: vscode.CancellationToken, _next: lc.ConfigurationRequest.HandlerSignature, ) { // The rust-analyzer LSP only ever asks for the "rust-analyzer" // section, so we only need to support that. Instead of letting // the vscode-languageclient handle it, use the `cfg` property // in the config. if ( params.items.length !== 1 || params.items[0]?.section !== "rust-analyzer" || params.items[0]?.scopeUri !== undefined ) { return new lc.ResponseError( lc.ErrorCodes.InvalidParams, 'Only the "rust-analyzer" config section is supported.', ); } return [prepareVSCodeConfig(config.cfg)]; }, }, async handleDiagnostics( uri: vscode.Uri, diagnosticList: vscode.Diagnostic[], next: lc.HandleDiagnosticsSignature, ) { const preview = config.previewRustcOutput; const errorCode = config.useRustcErrorCode; diagnosticList.forEach((diag, idx) => { const value = typeof diag.code === "string" || typeof diag.code === "number" ? diag.code : diag.code?.value; if ( // FIXME: We currently emit this diagnostic way too early, before we have // loaded the project fully // value === "unlinked-file" && value === "temporary-disabled" && !unlinkedFiles.includes(uri) && (diag.message === "file not included in crate hierarchy" || diag.message.startsWith("This file is not included in any crates")) ) { const config = vscode.workspace.getConfiguration("rust-analyzer"); if (config.get("showUnlinkedFileNotification")) { unlinkedFiles.push(uri); const folder = vscode.workspace.getWorkspaceFolder(uri)?.uri.fsPath; if (folder) { const parentBackslash = uri.fsPath.lastIndexOf(pathSeparator + "src"); const parent = uri.fsPath.substring(0, parentBackslash); if (parent.startsWith(folder)) { const path = vscode.Uri.file(parent + pathSeparator + "Cargo.toml"); void vscode.workspace.fs.stat(path).then(async () => { const choice = await vscode.window.showInformationMessage( `This rust file does not belong to a loaded cargo project. It looks like it might belong to the workspace at ${path.path}, do you want to add it to the linked Projects?`, "Yes", "No", "Don't show this again", ); switch (choice) { case undefined: break; case "No": break; case "Yes": { const pathToInsert = "." + parent.substring(folder.length) + pathSeparator + "Cargo.toml"; const value = config // eslint-disable-next-line @typescript-eslint/no-explicit-any .get("linkedProjects") ?.concat(pathToInsert); await config.update("linkedProjects", value, false); break; } case "Don't show this again": await config.update( "showUnlinkedFileNotification", false, false, ); break; } }); } } } } // Abuse the fact that VSCode leaks the LSP diagnostics data field through the // Diagnostic class, if they ever break this we are out of luck and have to go // back to the worst diagnostics experience ever:) // We encode the rendered output of a rustc diagnostic in the rendered field of // the data payload of the lsp diagnostic. If that field exists, overwrite the // diagnostic code such that clicking it opens the diagnostic in a readonly // text editor for easy inspection const rendered = (diag as unknown as { data?: { rendered?: string } }).data ?.rendered; if (rendered) { if (preview) { const decolorized = anser.ansiToText(rendered); const index = decolorized.match(/^(note|help):/m)?.index || rendered.length; diag.message = decolorized .substring(0, index) .replace(/^ -->[^\n]+\n/m, ""); } diag.code = { target: vscode.Uri.from({ scheme: diagnostics.URI_SCHEME, path: `/diagnostic message [${idx.toString()}]`, fragment: uri.toString(), query: idx.toString(), }), value: errorCode && value ? value : "Click for full compiler diagnostic", }; } }); return next(uri, diagnosticList); }, async provideHover( document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, _next: lc.ProvideHoverSignature, ) { const editor = vscode.window.activeTextEditor; const positionOrRange = editor?.selection?.contains(position) ? client.code2ProtocolConverter.asRange(editor.selection) : client.code2ProtocolConverter.asPosition(position); const params = { textDocument: client.code2ProtocolConverter.asTextDocumentIdentifier(document), position: positionOrRange, }; return client.sendRequest(ra.hover, params, token).then( (result) => { if (!result) return null; const hover = client.protocol2CodeConverter.asHover(result); if (result.actions) { hover.contents.push(renderHoverActions(result.actions)); } return hover; }, (error) => { client.handleFailedRequest(lc.HoverRequest.type, token, error, null); return Promise.resolve(null); }, ); }, // Using custom handling of CodeActions to support action groups and snippet edits. // Note that this means we have to re-implement lazy edit resolving ourselves as well. async provideCodeActions( document: vscode.TextDocument, range: vscode.Range, context: vscode.CodeActionContext, token: vscode.CancellationToken, _next: lc.ProvideCodeActionsSignature, ) { const params: lc.CodeActionParams = { textDocument: client.code2ProtocolConverter.asTextDocumentIdentifier(document), range: client.code2ProtocolConverter.asRange(range), context: await client.code2ProtocolConverter.asCodeActionContext(context, token), }; const callback = async ( values: (lc.Command | lc.CodeAction | object)[] | null, ): Promise<(vscode.Command | vscode.CodeAction)[] | undefined> => { if (values === null) return undefined; const result: (vscode.CodeAction | vscode.Command)[] = []; const groups = new Map< string, { primary: vscode.CodeAction; items: { label: string; arguments: lc.CodeAction }[]; } >(); for (const item of values) { // In our case we expect to get code edits only from diagnostics if (lc.CodeAction.is(item)) { assert(!item.command, "We don't expect to receive commands in CodeActions"); const action = await client.protocol2CodeConverter.asCodeAction( item, token, ); result.push(action); continue; } assertIsCodeActionWithoutEditsAndCommands(item); const kind = client.protocol2CodeConverter.asCodeActionKind(item.kind); const group = item.group; const mkAction = () => { const action = new vscode.CodeAction(item.title, kind); action.command = { command: "rust-analyzer.resolveCodeAction", title: item.title, arguments: [item], }; // Set a dummy edit, so that VS Code doesn't try to resolve this. action.edit = new WorkspaceEdit(); return action; }; if (group) { let entry = groups.get(group); if (!entry) { entry = { primary: mkAction(), items: [] }; groups.set(group, entry); } else { entry.items.push({ label: item.title, arguments: item, }); } } else { result.push(mkAction()); } } for (const [group, { items, primary }] of groups) { // This group contains more than one item, so rewrite it to be a group action if (items.length !== 0) { const args = [ { label: primary.title, arguments: primary.command!.arguments![0], }, ...items, ]; primary.title = group; primary.command = { command: "rust-analyzer.applyActionGroup", title: "", arguments: [args], }; } result.push(primary); } return result; }; return client .sendRequest(lc.CodeActionRequest.type, params, token) .then(callback, (_error) => undefined); }, }; const clientOptions: lc.LanguageClientOptions = { documentSelector: [{ scheme: "file", language: "rust" }], initializationOptions, diagnosticCollectionName: "rustc", traceOutputChannel, outputChannel, middleware: raMiddleware, markdown: { supportHtml: true, }, }; const client = new RaLanguageClient( "rust-analyzer", "Rust Analyzer Language Server", serverOptions, clientOptions, ); // To turn on all proposed features use: client.registerProposedFeatures(); client.registerFeature(new ExperimentalFeatures(config)); client.registerFeature(new OverrideFeatures()); return client; } class ExperimentalFeatures implements lc.StaticFeature { private readonly testExplorer: boolean; constructor(config: Config) { this.testExplorer = config.testExplorer || false; } getState(): lc.FeatureState { return { kind: "static" }; } fillClientCapabilities(capabilities: lc.ClientCapabilities): void { capabilities.experimental = { snippetTextEdit: true, codeActionGroup: true, hoverActions: true, serverStatusNotification: true, colorDiagnosticOutput: true, openServerLogs: true, localDocs: true, testExplorer: this.testExplorer, commands: { commands: [ "rust-analyzer.runSingle", "rust-analyzer.debugSingle", "rust-analyzer.showReferences", "rust-analyzer.gotoLocation", "rust-analyzer.triggerParameterHints", "rust-analyzer.rename", ], }, ...capabilities.experimental, }; } initialize( _capabilities: lc.ServerCapabilities, _documentSelector: lc.DocumentSelector | undefined, ): void {} dispose(): void {} clear(): void {} } class OverrideFeatures implements lc.StaticFeature { getState(): lc.FeatureState { return { kind: "static" }; } fillClientCapabilities(capabilities: lc.ClientCapabilities): void { // Force disable `augmentsSyntaxTokens`, VSCode's textmate grammar is somewhat incomplete // making the experience generally worse const caps = capabilities.textDocument?.semanticTokens; if (caps) { caps.augmentsSyntaxTokens = false; } } initialize( _capabilities: lc.ServerCapabilities, _documentSelector: lc.DocumentSelector | undefined, ): void {} dispose(): void {} clear(): void {} } function assertIsCodeActionWithoutEditsAndCommands( // eslint-disable-next-line @typescript-eslint/no-explicit-any candidate: any, ): asserts candidate is lc.CodeAction & { group?: string; } { assert( candidate && Is.string(candidate.title) && (candidate.diagnostics === undefined || Is.typedArray(candidate.diagnostics, lc.Diagnostic.is)) && (candidate.group === undefined || Is.string(candidate.group)) && (candidate.kind === undefined || Is.string(candidate.kind)) && candidate.edit === undefined && candidate.command === undefined, `Expected a CodeAction without edits or commands, got: ${JSON.stringify(candidate)}`, ); } // Command URIs have a form of command:command-name?arguments, where // arguments is a percent-encoded array of data we want to pass along to // the command function. For "Show References" this is a list of all file // URIs with locations of every reference, and it can get quite long. // So long in fact that it will fail rendering inside an `a` tag so we need // to proxy around that. We store the last hover's reference command link // here, as only one hover can be active at a time, and we don't need to // keep a history of these. export let HOVER_REFERENCE_COMMAND: ra.CommandLink[] = []; function renderCommand(cmd: ra.CommandLink): string { HOVER_REFERENCE_COMMAND.push(cmd); return `[${cmd.title}](command:rust-analyzer.hoverRefCommandProxy?${ HOVER_REFERENCE_COMMAND.length - 1 } '${cmd.tooltip}')`; } function renderHoverActions(actions: ra.CommandLinkGroup[]): vscode.MarkdownString { // clean up the previous hover ref command HOVER_REFERENCE_COMMAND = []; const text = actions .map( (group) => (group.title ? group.title + " " : "") + group.commands.map(renderCommand).join(" | "), ) .join(" | "); const result = new vscode.MarkdownString(text); result.isTrusted = true; return result; }