diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json index e35a159cbc3f..6dd448522379 100644 --- a/editors/code/package-lock.json +++ b/editors/code/package-lock.json @@ -21,6 +21,7 @@ "@stylistic/eslint-plugin": "^4.1.0", "@stylistic/eslint-plugin-js": "^4.1.0", "@tsconfig/strictest": "^2.0.5", + "@types/lodash": "^4.17.20", "@types/node": "~22.13.4", "@types/vscode": "~1.93.0", "@typescript-eslint/eslint-plugin": "^8.25.0", @@ -1388,6 +1389,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", diff --git a/editors/code/package.json b/editors/code/package.json index 70687238c854..d659421a0299 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -58,6 +58,7 @@ "@stylistic/eslint-plugin": "^4.1.0", "@stylistic/eslint-plugin-js": "^4.1.0", "@tsconfig/strictest": "^2.0.5", + "@types/lodash": "^4.17.20", "@types/node": "~22.13.4", "@types/vscode": "~1.93.0", "@typescript-eslint/eslint-plugin": "^8.25.0", diff --git a/editors/code/src/client.ts b/editors/code/src/client.ts index 073ff2f4703f..cb71a01138b3 100644 --- a/editors/code/src/client.ts +++ b/editors/code/src/client.ts @@ -13,7 +13,7 @@ import { RaLanguageClient } from "./lang_client"; export async function createClient( traceOutputChannel: vscode.OutputChannel, outputChannel: vscode.OutputChannel, - initializationOptions: vscode.WorkspaceConfiguration, + initializationOptions: lc.LanguageClientOptions["initializationOptions"], serverOptions: lc.ServerOptions, config: Config, unlinkedFiles: vscode.Uri[], diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts index 3b1b0768d3cf..c0a1b3f02e36 100644 --- a/editors/code/src/config.ts +++ b/editors/code/src/config.ts @@ -4,7 +4,7 @@ import * as path from "path"; import * as vscode from "vscode"; import { expectNotUndefined, log, normalizeDriveLetter, unwrapUndefinable } from "./util"; import type { Env } from "./util"; -import type { Disposable } from "vscode"; +import { cloneDeep, get, pickBy, set } from "lodash"; export type RunnableEnvCfgItem = { mask?: string; @@ -12,14 +12,26 @@ export type RunnableEnvCfgItem = { 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; - readonly rootSection = "rust-analyzer"; - private readonly requiresServerReloadOpts = ["server", "files", "showSyntaxTree"].map( + private readonly rootSection = "rust-analyzer"; + private readonly requiresServerReloadOpts = ["cargo", "server", "files", "showSyntaxTree"].map( (opt) => `${this.rootSection}.${opt}`, ); @@ -27,8 +39,13 @@ export class Config { (opt) => `${this.rootSection}.${opt}`, ); - constructor(disposables: Disposable[]) { - vscode.workspace.onDidChangeConfiguration(this.onDidChangeConfiguration, this, disposables); + constructor(ctx: vscode.ExtensionContext) { + this.workspaceState = ctx.workspaceState; + vscode.workspace.onDidChangeConfiguration( + this.onDidChangeConfiguration, + this, + ctx.subscriptions, + ); this.refreshLogging(); this.configureLanguage(); } @@ -37,6 +54,44 @@ export class Config { 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> { + return pickBy( + this.workspaceState.get>( + "extensionConfigurations", + {}, + ), + // ignore configurations from disabled/removed extensions + (_, extensionId) => vscode.extensions.getExtension(extensionId) !== undefined, + ); + } + + async addExtensionConfiguration( + extensionId: string, + configuration: Record, + ): Promise { + 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:", @@ -176,10 +231,36 @@ export class Config { // 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 - private get cfg(): vscode.WorkspaceConfiguration { + // 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(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: @@ -187,7 +268,6 @@ export class Config { * ```ts * const nullableNum = vscode * .workspace - * .getConfiguration * .getConfiguration("rust-analyzer") * .get(path)!; * @@ -197,7 +277,7 @@ export class Config { * So this getter handles this quirk by not requiring the caller to use postfix `!` */ private get(path: string): T | undefined { - return prepareVSCodeConfig(this.cfg.get(path)); + return prepareVSCodeConfig(get(this.cfg, path)) as T; } get serverPath() { @@ -223,7 +303,7 @@ export class Config { } async toggleCheckOnSave() { - const config = this.cfg.inspect("checkOnSave") ?? { key: "checkOnSave" }; + const config = this.rawCfg.inspect("checkOnSave") ?? { key: "checkOnSave" }; let overrideInLanguage; let target; let value; @@ -249,7 +329,12 @@ export class Config { overrideInLanguage = config.defaultLanguageValue; value = config.defaultValue || config.defaultLanguageValue; } - await this.cfg.update("checkOnSave", !(value || false), target || null, overrideInLanguage); + await this.rawCfg.update( + "checkOnSave", + !(value || false), + target || null, + overrideInLanguage, + ); } get problemMatcher(): string[] { @@ -367,26 +452,24 @@ export class Config { } async setAskBeforeUpdateTest(value: boolean) { - await this.cfg.update("runnables.askBeforeUpdateTest", value, true); + await this.rawCfg.update("runnables.askBeforeUpdateTest", value, true); } } -export function prepareVSCodeConfig(resp: T): T { +export function prepareVSCodeConfig(resp: ConfigurationValue): ConfigurationValue { if (Is.string(resp)) { - return substituteVSCodeVariableInString(resp) as T; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } else if (resp && Is.array(resp)) { + return substituteVSCodeVariableInString(resp); + } else if (resp && Is.array(resp)) { return resp.map((val) => { return prepareVSCodeConfig(val); - }) as T; + }); } else if (resp && typeof resp === "object") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const res: { [key: string]: any } = {}; + const res: ConfigurationTree = {}; for (const key in resp) { const val = resp[key]; res[key] = prepareVSCodeConfig(val); } - return res as T; + return res; } return resp; } diff --git a/editors/code/src/ctx.ts b/editors/code/src/ctx.ts index e55754fb9f04..a7b7be03b5d8 100644 --- a/editors/code/src/ctx.ts +++ b/editors/code/src/ctx.ts @@ -125,7 +125,7 @@ export class Ctx implements RustAnalyzerExtensionApi { extCtx.subscriptions.push(this); this.version = extCtx.extension.packageJSON.version ?? ""; this._serverVersion = ""; - this.config = new Config(extCtx.subscriptions); + this.config = new Config(extCtx); this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); this.updateStatusBarVisibility(vscode.window.activeTextEditor); this.statusBarActiveEditorListener = vscode.window.onDidChangeActiveTextEditor((editor) => @@ -150,6 +150,13 @@ export class Ctx implements RustAnalyzerExtensionApi { }); } + async addConfiguration( + extensionId: string, + configuration: Record, + ): Promise { + await this.config.addExtensionConfiguration(extensionId, configuration); + } + dispose() { this.config.dispose(); this.statusBar.dispose(); @@ -230,7 +237,7 @@ export class Ctx implements RustAnalyzerExtensionApi { debug: run, }; - let rawInitializationOptions = vscode.workspace.getConfiguration("rust-analyzer"); + let rawInitializationOptions = this.config.cfg; if (this.workspace.kind === "Detached Files") { rawInitializationOptions = { diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts index 996298524f11..190f5866d0ea 100644 --- a/editors/code/src/main.ts +++ b/editors/code/src/main.ts @@ -13,6 +13,12 @@ const RUST_PROJECT_CONTEXT_NAME = "inRustProject"; export interface RustAnalyzerExtensionApi { // FIXME: this should be non-optional readonly client?: lc.LanguageClient; + + // Allows adding a configuration override from another extension. + // `extensionId` is used to only merge configuration override from present + // extensions. `configuration` is map of rust-analyzer-specific setting + // overrides, e.g., `{"cargo.cfgs": ["foo", "bar"]}`. + addConfiguration(extensionId: string, configuration: Record): Promise; } export async function deactivate() {