| import * as Is from "vscode-languageclient/lib/common/utils/is"; | |
| import * as os from "os"; | |
| import * as path from "path"; | |
| import * as vscode from "vscode"; | |
| import { expectNotUndefined, log, normalizeDriveLetter, unwrapUndefinable } from "./util"; | |
| import type { Env } from "./util"; | |
| import { cloneDeep, get, pickBy, set } from "lodash"; | |
| export type RunnableEnvCfgItem = { | |
| mask?: string; | |
| env: { [key: string]: { toString(): string } | null }; | |
| platform?: string | string[]; | |
| }; | |
| export type ConfigurationTree = { [key: string]: ConfigurationValue }; | |
| export type ConfigurationValue = | |
| | undefined | |
| | null | |
| | boolean | |
| | number | |
| | string | |
| | ConfigurationValue[] | |
| | ConfigurationTree; | |
| type ShowStatusBar = "always" | "never" | { documentSelector: vscode.DocumentSelector }; | |
| export class Config { | |
| readonly extensionId = "rust-lang.rust-analyzer"; | |
| configureLang: vscode.Disposable | undefined; | |
| workspaceState: vscode.Memento; | |
| private readonly rootSection = "rust-analyzer"; | |
| private readonly requiresServerReloadOpts = ["server", "files", "showSyntaxTree"].map( | |
| (opt) => `${this.rootSection}.${opt}`, | |
| ); | |
| private readonly requiresWindowReloadOpts = ["testExplorer"].map( | |
| (opt) => `${this.rootSection}.${opt}`, | |
| ); | |
| constructor(ctx: vscode.ExtensionContext) { | |
| this.workspaceState = ctx.workspaceState; | |
| vscode.workspace.onDidChangeConfiguration( | |
| this.onDidChangeConfiguration, | |
| this, | |
| ctx.subscriptions, | |
| ); | |
| this.refreshLogging(); | |
| this.configureLanguage(); | |
| } | |
| dispose() { | |
| this.configureLang?.dispose(); | |
| } | |
| private readonly extensionConfigurationStateKey = "extensionConfigurations"; | |
| /// Returns the rust-analyzer-specific workspace configuration, incl. any | |
| /// configuration items overridden by (present) extensions. | |
| get extensionConfigurations(): Record<string, Record<string, unknown>> { | |
| return pickBy( | |
| this.workspaceState.get<Record<string, ConfigurationTree>>( | |
| "extensionConfigurations", | |
| {}, | |
| ), | |
| // ignore configurations from disabled/removed extensions | |
| (_, extensionId) => vscode.extensions.getExtension(extensionId) !== undefined, | |
| ); | |
| } | |
| async addExtensionConfiguration( | |
| extensionId: string, | |
| configuration: Record<string, unknown>, | |
| ): Promise<void> { | |
| const oldConfiguration = this.cfg; | |
| const extCfgs = this.extensionConfigurations; | |
| extCfgs[extensionId] = configuration; | |
| await this.workspaceState.update(this.extensionConfigurationStateKey, extCfgs); | |
| const newConfiguration = this.cfg; | |
| const prefix = `${this.rootSection}.`; | |
| await this.onDidChangeConfiguration({ | |
| affectsConfiguration(section: string, _scope?: vscode.ConfigurationScope): boolean { | |
| return ( | |
| section.startsWith(prefix) && | |
| get(oldConfiguration, section.slice(prefix.length)) !== | |
| get(newConfiguration, section.slice(prefix.length)) | |
| ); | |
| }, | |
| }); | |
| } | |
| private refreshLogging() { | |
| log.info( | |
| "Extension version:", | |
| vscode.extensions.getExtension(this.extensionId)!.packageJSON.version, | |
| ); | |
| const cfg = Object.entries(this.cfg).filter(([_, val]) => !(val instanceof Function)); | |
| log.info("Using configuration", Object.fromEntries(cfg)); | |
| } | |
| private async onDidChangeConfiguration(event: vscode.ConfigurationChangeEvent) { | |
| this.refreshLogging(); | |
| this.configureLanguage(); | |
| const requiresWindowReloadOpt = this.requiresWindowReloadOpts.find((opt) => | |
| event.affectsConfiguration(opt), | |
| ); | |
| if (requiresWindowReloadOpt) { | |
| const message = `Changing "${requiresWindowReloadOpt}" requires a window reload`; | |
| const userResponse = await vscode.window.showInformationMessage(message, "Reload now"); | |
| if (userResponse) { | |
| await vscode.commands.executeCommand("workbench.action.reloadWindow"); | |
| } | |
| } | |
| const requiresServerReloadOpt = this.requiresServerReloadOpts.find((opt) => | |
| event.affectsConfiguration(opt), | |
| ); | |
| if (!requiresServerReloadOpt) return; | |
| if (this.restartServerOnConfigChange) { | |
| await vscode.commands.executeCommand("rust-analyzer.restartServer"); | |
| return; | |
| } | |
| const message = `Changing "${requiresServerReloadOpt}" requires a server restart`; | |
| const userResponse = await vscode.window.showInformationMessage(message, "Restart now"); | |
| if (userResponse) { | |
| const command = "rust-analyzer.restartServer"; | |
| await vscode.commands.executeCommand(command); | |
| } | |
| } | |
| /** | |
| * Sets up additional language configuration that's impossible to do via a | |
| * separate language-configuration.json file. See [1] for more information. | |
| * | |
| * [1]: https://github.com/Microsoft/vscode/issues/11514#issuecomment-244707076 | |
| */ | |
| private configureLanguage() { | |
| // Only need to dispose of the config if there's a change | |
| if (this.configureLang) { | |
| this.configureLang.dispose(); | |
| this.configureLang = undefined; | |
| } | |
| let onEnterRules: vscode.OnEnterRule[] = [ | |
| { | |
| // Carry indentation from the previous line | |
| // if it's only whitespace | |
| beforeText: /^\s+$/, | |
| action: { indentAction: vscode.IndentAction.None }, | |
| }, | |
| { | |
| // After the end of a function/field chain, | |
| // with the semicolon on the same line | |
| beforeText: /^\s+\..*;/, | |
| action: { indentAction: vscode.IndentAction.Outdent }, | |
| }, | |
| { | |
| // After the end of a function/field chain, | |
| // with semicolon detached from the rest | |
| beforeText: /^\s+;/, | |
| previousLineText: /^\s+\..*/, | |
| action: { indentAction: vscode.IndentAction.Outdent }, | |
| }, | |
| ]; | |
| if (this.typingContinueCommentsOnNewline) { | |
| const indentAction = vscode.IndentAction.None; | |
| onEnterRules = [ | |
| ...onEnterRules, | |
| { | |
| // Doc single-line comment | |
| // e.g. ///| | |
| beforeText: /^\s*\/{3}.*$/, | |
| action: { indentAction, appendText: "/// " }, | |
| }, | |
| { | |
| // Parent doc single-line comment | |
| // e.g. //!| | |
| beforeText: /^\s*\/{2}!.*$/, | |
| action: { indentAction, appendText: "//! " }, | |
| }, | |
| { | |
| // Begins an auto-closed multi-line comment (standard or parent doc) | |
| // e.g. /** | */ or /*! | */ | |
| beforeText: /^\s*\/\*(\*|!)(?!\/)([^*]|\*(?!\/))*$/, | |
| afterText: /^\s*\*\/$/, | |
| action: { | |
| indentAction: vscode.IndentAction.IndentOutdent, | |
| appendText: " * ", | |
| }, | |
| }, | |
| { | |
| // Begins a multi-line comment (standard or parent doc) | |
| // e.g. /** ...| or /*! ...| | |
| beforeText: /^\s*\/\*(\*|!)(?!\/)([^*]|\*(?!\/))*$/, | |
| action: { indentAction, appendText: " * " }, | |
| }, | |
| { | |
| // Continues a multi-line comment | |
| // e.g. * ...| | |
| beforeText: /^( {2})* \*( ([^*]|\*(?!\/))*)?$/, | |
| action: { indentAction, appendText: "* " }, | |
| }, | |
| { | |
| // Dedents after closing a multi-line comment | |
| // e.g. */| | |
| beforeText: /^( {2})* \*\/\s*$/, | |
| action: { indentAction, removeText: 1 }, | |
| }, | |
| ]; | |
| } | |
| this.configureLang = vscode.languages.setLanguageConfiguration("rust", { | |
| onEnterRules, | |
| }); | |
| } | |
| // We don't do runtime config validation here for simplicity. More on stackoverflow: | |
| // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension | |
| // Returns the raw configuration for rust-analyzer as returned by vscode. This | |
| // should only be used when modifications to the user/workspace configuration | |
| // are required. | |
| private get rawCfg(): vscode.WorkspaceConfiguration { | |
| return vscode.workspace.getConfiguration(this.rootSection); | |
| } | |
| // Returns the final configuration to use, with extension configuration overrides merged in. | |
| public get cfg(): ConfigurationTree { | |
| const finalConfig = cloneDeep<ConfigurationTree>(this.rawCfg); | |
| for (const [extensionId, items] of Object.entries(this.extensionConfigurations)) { | |
| for (const [k, v] of Object.entries(items)) { | |
| const i = this.rawCfg.inspect(k); | |
| if ( | |
| i?.workspaceValue !== undefined || | |
| i?.workspaceFolderValue !== undefined || | |
| i?.globalValue !== undefined | |
| ) { | |
| log.trace( | |
| `Ignoring configuration override for ${k} from extension ${extensionId}`, | |
| ); | |
| continue; | |
| } | |
| log.trace(`Extension ${extensionId} overrides configuration ${k} to `, v); | |
| set(finalConfig, k, v); | |
| } | |
| } | |
| return finalConfig; | |
| } | |
| /** | |
| * Beware that postfix `!` operator erases both `null` and `undefined`. | |
| * This is why the following doesn't work as expected: | |
| * | |
| * ```ts | |
| * const nullableNum = vscode | |
| * .workspace | |
| * .getConfiguration("rust-analyzer") | |
| * .get<number | null>(path)!; | |
| * | |
| * // What happens is that type of `nullableNum` is `number` but not `null | number`: | |
| * const fullFledgedNum: number = nullableNum; | |
| * ``` | |
| * So this getter handles this quirk by not requiring the caller to use postfix `!` | |
| */ | |
| private get<T>(path: string): T | undefined { | |
| return prepareVSCodeConfig(get(this.cfg, path)) as T; | |
| } | |
| get serverPath() { | |
| return this.get<null | string>("server.path"); | |
| } | |
| get serverExtraEnv(): Env { | |
| const extraEnv = | |
| this.get<{ [key: string]: { toString(): string } | null } | null>("server.extraEnv") ?? | |
| {}; | |
| return substituteVariablesInEnv( | |
| Object.fromEntries( | |
| Object.entries(extraEnv).map(([k, v]) => [ | |
| k, | |
| typeof v === "string" ? v : v?.toString(), | |
| ]), | |
| ), | |
| ); | |
| } | |
| get checkOnSave() { | |
| return this.get<boolean>("checkOnSave") ?? false; | |
| } | |
| async toggleCheckOnSave() { | |
| const config = this.rawCfg.inspect<boolean>("checkOnSave") ?? { key: "checkOnSave" }; | |
| let overrideInLanguage; | |
| let target; | |
| let value; | |
| if ( | |
| config.workspaceFolderValue !== undefined || | |
| config.workspaceFolderLanguageValue !== undefined | |
| ) { | |
| target = vscode.ConfigurationTarget.WorkspaceFolder; | |
| overrideInLanguage = config.workspaceFolderLanguageValue; | |
| value = config.workspaceFolderValue || config.workspaceFolderLanguageValue; | |
| } else if ( | |
| config.workspaceValue !== undefined || | |
| config.workspaceLanguageValue !== undefined | |
| ) { | |
| target = vscode.ConfigurationTarget.Workspace; | |
| overrideInLanguage = config.workspaceLanguageValue; | |
| value = config.workspaceValue || config.workspaceLanguageValue; | |
| } else if (config.globalValue !== undefined || config.globalLanguageValue !== undefined) { | |
| target = vscode.ConfigurationTarget.Global; | |
| overrideInLanguage = config.globalLanguageValue; | |
| value = config.globalValue || config.globalLanguageValue; | |
| } else if (config.defaultValue !== undefined || config.defaultLanguageValue !== undefined) { | |
| overrideInLanguage = config.defaultLanguageValue; | |
| value = config.defaultValue || config.defaultLanguageValue; | |
| } | |
| await this.rawCfg.update( | |
| "checkOnSave", | |
| !(value || false), | |
| target || null, | |
| overrideInLanguage, | |
| ); | |
| } | |
| get problemMatcher(): string[] { | |
| return this.get<string[]>("runnables.problemMatcher") || []; | |
| } | |
| get testExplorer() { | |
| return this.get<boolean | undefined>("testExplorer"); | |
| } | |
| runnablesExtraEnv(label: string): Env { | |
| const serverEnv = this.serverExtraEnv; | |
| let extraEnv = | |
| this.get< | |
| RunnableEnvCfgItem[] | { [key: string]: { toString(): string } | null } | null | |
| >("runnables.extraEnv") ?? {}; | |
| if (!extraEnv) return serverEnv; | |
| const platform = process.platform; | |
| const checkPlatform = (it: RunnableEnvCfgItem) => { | |
| if (it.platform) { | |
| const platforms = Array.isArray(it.platform) ? it.platform : [it.platform]; | |
| return platforms.indexOf(platform) >= 0; | |
| } | |
| return true; | |
| }; | |
| if (extraEnv instanceof Array) { | |
| const env = {}; | |
| for (const it of extraEnv) { | |
| const masked = !it.mask || new RegExp(it.mask).test(label); | |
| if (masked && checkPlatform(it)) { | |
| Object.assign(env, it.env); | |
| } | |
| } | |
| extraEnv = env; | |
| } | |
| const runnableExtraEnv = substituteVariablesInEnv( | |
| Object.fromEntries( | |
| Object.entries(extraEnv).map(([k, v]) => [ | |
| k, | |
| typeof v === "string" ? v : v?.toString(), | |
| ]), | |
| ), | |
| ); | |
| return { ...runnableExtraEnv, ...serverEnv }; | |
| } | |
| get restartServerOnConfigChange() { | |
| return this.get<boolean>("restartServerOnConfigChange"); | |
| } | |
| get typingContinueCommentsOnNewline() { | |
| return this.get<boolean>("typing.continueCommentsOnNewline"); | |
| } | |
| get debug() { | |
| let sourceFileMap = this.get<Record<string, string> | "auto">("debug.sourceFileMap"); | |
| if (sourceFileMap !== "auto") { | |
| // "/rustc/<id>" used by suggestions only. | |
| const { ["/rustc/<id>"]: _, ...trimmed } = | |
| this.get<Record<string, string>>("debug.sourceFileMap") ?? {}; | |
| sourceFileMap = trimmed; | |
| } | |
| return { | |
| engine: this.get<string>("debug.engine"), | |
| engineSettings: this.get<object>("debug.engineSettings") ?? {}, | |
| buildBeforeRestart: this.get<boolean>("debug.buildBeforeRestart"), | |
| sourceFileMap: sourceFileMap, | |
| }; | |
| } | |
| get hoverActions() { | |
| return { | |
| enable: this.get<boolean>("hover.actions.enable"), | |
| implementations: this.get<boolean>("hover.actions.implementations.enable"), | |
| references: this.get<boolean>("hover.actions.references.enable"), | |
| run: this.get<boolean>("hover.actions.run.enable"), | |
| debug: this.get<boolean>("hover.actions.debug.enable"), | |
| gotoTypeDef: this.get<boolean>("hover.actions.gotoTypeDef.enable"), | |
| }; | |
| } | |
| get previewRustcOutput() { | |
| return this.get<boolean>("diagnostics.previewRustcOutput"); | |
| } | |
| get useRustcErrorCode() { | |
| return this.get<boolean>("diagnostics.useRustcErrorCode"); | |
| } | |
| get showDependenciesExplorer() { | |
| return this.get<boolean>("showDependenciesExplorer"); | |
| } | |
| get showSyntaxTree() { | |
| return this.get<boolean>("showSyntaxTree"); | |
| } | |
| get statusBarClickAction() { | |
| return this.get<string>("statusBar.clickAction"); | |
| } | |
| get statusBarShowStatusBar() { | |
| return this.get<ShowStatusBar>("statusBar.showStatusBar"); | |
| } | |
| get initializeStopped() { | |
| return this.get<boolean>("initializeStopped"); | |
| } | |
| get projectCreationOpenAfterCreate() { | |
| return this.get<string>("projectCreation.openAfterCreate"); | |
| } | |
| get askBeforeUpdateTest() { | |
| return this.get<boolean>("runnables.askBeforeUpdateTest"); | |
| } | |
| async setAskBeforeUpdateTest(value: boolean) { | |
| await this.rawCfg.update("runnables.askBeforeUpdateTest", value, true); | |
| } | |
| } | |
| export function prepareVSCodeConfig(resp: ConfigurationValue): ConfigurationValue { | |
| if (Is.string(resp)) { | |
| return substituteVSCodeVariableInString(resp); | |
| } else if (resp && Is.array(resp)) { | |
| return resp.map((val) => { | |
| return prepareVSCodeConfig(val); | |
| }); | |
| } else if (resp && typeof resp === "object") { | |
| const res: ConfigurationTree = {}; | |
| for (const key in resp) { | |
| const val = resp[key]; | |
| res[key] = prepareVSCodeConfig(val); | |
| } | |
| return res; | |
| } | |
| return resp; | |
| } | |
| // FIXME: Merge this with `substituteVSCodeVariables` above | |
| export function substituteVariablesInEnv(env: Env): Env { | |
| const depRe = new RegExp(/\${(?<depName>.+?)}/g); | |
| const missingDeps = new Set<string>(); | |
| // vscode uses `env:ENV_NAME` for env vars resolution, and it's easier | |
| // to follow the same convention for our dependency tracking | |
| const definedEnvKeys = new Set(Object.keys(env).map((key) => `env:${key}`)); | |
| const envWithDeps = Object.fromEntries( | |
| Object.entries(env).map(([key, value]) => { | |
| const deps = new Set<string>(); | |
| if (value) { | |
| let match; | |
| while ((match = depRe.exec(value))) { | |
| const depName = unwrapUndefinable(match.groups?.["depName"]); | |
| deps.add(depName); | |
| // `depName` at this point can have a form of `expression` or | |
| // `prefix:expression` | |
| if (!definedEnvKeys.has(depName)) { | |
| missingDeps.add(depName); | |
| } | |
| } | |
| } | |
| return [`env:${key}`, { deps: [...deps], value }]; | |
| }), | |
| ); | |
| const resolved = new Set<string>(); | |
| for (const dep of missingDeps) { | |
| const match = /(?<prefix>.*?):(?<body>.+)/.exec(dep); | |
| if (match) { | |
| const { prefix, body } = match.groups!; | |
| if (prefix === "env") { | |
| const envName = unwrapUndefinable(body); | |
| envWithDeps[dep] = { | |
| value: process.env[envName] ?? "", | |
| deps: [], | |
| }; | |
| resolved.add(dep); | |
| } else { | |
| // we can't handle other prefixes at the moment | |
| // leave values as is, but still mark them as resolved | |
| envWithDeps[dep] = { | |
| value: "${" + dep + "}", | |
| deps: [], | |
| }; | |
| resolved.add(dep); | |
| } | |
| } else { | |
| envWithDeps[dep] = { | |
| value: computeVscodeVar(dep) || "${" + dep + "}", | |
| deps: [], | |
| }; | |
| } | |
| } | |
| const toResolve = new Set(Object.keys(envWithDeps)); | |
| let leftToResolveSize; | |
| do { | |
| leftToResolveSize = toResolve.size; | |
| for (const key of toResolve) { | |
| const item = envWithDeps[key]; | |
| if (item && item.deps.every((dep) => resolved.has(dep))) { | |
| item.value = item.value?.replace(/\${(?<depName>.+?)}/g, (_wholeMatch, depName) => { | |
| return envWithDeps[depName]?.value ?? ""; | |
| }); | |
| resolved.add(key); | |
| toResolve.delete(key); | |
| } | |
| } | |
| } while (toResolve.size > 0 && toResolve.size < leftToResolveSize); | |
| const resolvedEnv: Env = {}; | |
| for (const key of Object.keys(env)) { | |
| const item = unwrapUndefinable(envWithDeps[`env:${key}`]); | |
| resolvedEnv[key] = item.value; | |
| } | |
| return resolvedEnv; | |
| } | |
| const VarRegex = new RegExp(/\$\{(.+?)\}/g); | |
| function substituteVSCodeVariableInString(val: string): string { | |
| return val.replace(VarRegex, (substring: string, varName) => { | |
| if (Is.string(varName)) { | |
| return computeVscodeVar(varName) || substring; | |
| } else { | |
| return substring; | |
| } | |
| }); | |
| } | |
| function computeVscodeVar(varName: string): string | null { | |
| const workspaceFolder = () => { | |
| const folders = vscode.workspace.workspaceFolders ?? []; | |
| const folder = folders[0]; | |
| // TODO: support for remote workspaces? | |
| const fsPath: string = | |
| folder === undefined | |
| ? "" // no workspace opened | |
| : // could use currently opened document to detect the correct | |
| // workspace. However, that would be determined by the document | |
| // user has opened on Editor startup. Could lead to | |
| // unpredictable workspace selection in practice. | |
| // It's better to pick the first one | |
| normalizeDriveLetter(folder.uri.fsPath); | |
| return fsPath; | |
| }; | |
| // https://code.visualstudio.com/docs/editor/variables-reference | |
| const supportedVariables: { [k: string]: () => string } = { | |
| workspaceFolder, | |
| workspaceFolderBasename: () => { | |
| return path.basename(workspaceFolder()); | |
| }, | |
| cwd: () => process.cwd(), | |
| userHome: () => os.homedir(), | |
| // see | |
| // https://github.com/microsoft/vscode/blob/08ac1bb67ca2459496b272d8f4a908757f24f56f/src/vs/workbench/api/common/extHostVariableResolverService.ts#L81 | |
| // or | |
| // https://github.com/microsoft/vscode/blob/29eb316bb9f154b7870eb5204ec7f2e7cf649bec/src/vs/server/node/remoteTerminalChannel.ts#L56 | |
| execPath: () => process.env["VSCODE_EXEC_PATH"] ?? process.execPath, | |
| pathSeparator: () => path.sep, | |
| }; | |
| if (varName in supportedVariables) { | |
| const fn = expectNotUndefined( | |
| supportedVariables[varName], | |
| `${varName} should not be undefined here`, | |
| ); | |
| return fn(); | |
| } else { | |
| // return "${" + varName + "}"; | |
| return null; | |
| } | |
| } |