]> git.lizzy.rs Git - rust.git/blob - editors/code/src/config.ts
VSCode variables support for substitutions
[rust.git] / editors / code / src / config.ts
1 import path = require('path');
2 import * as vscode from 'vscode';
3 import { Env } from './client';
4 import { log } from "./util";
5
6 export type UpdatesChannel = "stable" | "nightly";
7
8 const NIGHTLY_TAG = "nightly";
9
10 export type RunnableEnvCfg = undefined | Record<string, string> | { mask?: string; env: Record<string, string> }[];
11
12 export class Config {
13     readonly extensionId = "matklad.rust-analyzer";
14
15     readonly rootSection = "rust-analyzer";
16     private readonly requiresReloadOpts = [
17         "serverPath",
18         "server",
19         "cargo",
20         "procMacro",
21         "files",
22         "lens", // works as lens.*
23     ]
24         .map(opt => `${this.rootSection}.${opt}`);
25
26     readonly package: {
27         version: string;
28         releaseTag: string | null;
29         enableProposedApi: boolean | undefined;
30     } = vscode.extensions.getExtension(this.extensionId)!.packageJSON;
31
32     readonly globalStorageUri: vscode.Uri;
33
34     constructor(ctx: vscode.ExtensionContext) {
35         this.globalStorageUri = ctx.globalStorageUri;
36         vscode.workspace.onDidChangeConfiguration(this.onDidChangeConfiguration, this, ctx.subscriptions);
37         this.refreshLogging();
38     }
39
40     private refreshLogging() {
41         log.setEnabled(this.traceExtension);
42         log.info("Extension version:", this.package.version);
43
44         const cfg = Object.entries(this.cfg).filter(([_, val]) => !(val instanceof Function));
45         log.info("Using configuration", Object.fromEntries(cfg));
46     }
47
48     private async onDidChangeConfiguration(event: vscode.ConfigurationChangeEvent) {
49         this.refreshLogging();
50
51         const requiresReloadOpt = this.requiresReloadOpts.find(
52             opt => event.affectsConfiguration(opt)
53         );
54
55         if (!requiresReloadOpt) return;
56
57         const userResponse = await vscode.window.showInformationMessage(
58             `Changing "${requiresReloadOpt}" requires a reload`,
59             "Reload now"
60         );
61
62         if (userResponse === "Reload now") {
63             await vscode.commands.executeCommand("workbench.action.reloadWindow");
64         }
65     }
66
67     // We don't do runtime config validation here for simplicity. More on stackoverflow:
68     // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension
69
70     private get cfg(): vscode.WorkspaceConfiguration {
71         return vscode.workspace.getConfiguration(this.rootSection);
72     }
73
74     /**
75      * Beware that postfix `!` operator erases both `null` and `undefined`.
76      * This is why the following doesn't work as expected:
77      *
78      * ```ts
79      * const nullableNum = vscode
80      *  .workspace
81      *  .getConfiguration
82      *  .getConfiguration("rust-analyzer")
83      *  .get<number | null>(path)!;
84      *
85      * // What happens is that type of `nullableNum` is `number` but not `null | number`:
86      * const fullFledgedNum: number = nullableNum;
87      * ```
88      * So this getter handles this quirk by not requiring the caller to use postfix `!`
89      */
90     private get<T>(path: string): T {
91         return this.cfg.get<T>(path)!;
92     }
93
94     get serverPath() {
95         return this.get<null | string>("server.path") ?? this.get<null | string>("serverPath");
96     }
97     get serverExtraEnv() { return this.get<Env | null>("server.extraEnv") ?? {}; }
98     get traceExtension() { return this.get<boolean>("trace.extension"); }
99
100     get cargoRunner() {
101         return this.get<string | undefined>("cargoRunner");
102     }
103
104     get runnableEnv() {
105         return this.get<RunnableEnvCfg>("runnableEnv");
106     }
107
108     get debug() {
109         let sourceFileMap = this.get<Record<string, string> | "auto">("debug.sourceFileMap");
110         if (sourceFileMap !== "auto") {
111             // "/rustc/<id>" used by suggestions only.
112             const { ["/rustc/<id>"]: _, ...trimmed } = this.get<Record<string, string>>("debug.sourceFileMap");
113             sourceFileMap = trimmed;
114         }
115
116         return {
117             engine: this.get<string>("debug.engine"),
118             engineSettings: this.get<object>("debug.engineSettings"),
119             openDebugPane: this.get<boolean>("debug.openDebugPane"),
120             sourceFileMap: sourceFileMap
121         };
122     }
123
124     get hoverActions() {
125         return {
126             enable: this.get<boolean>("hoverActions.enable"),
127             implementations: this.get<boolean>("hoverActions.implementations.enable"),
128             references: this.get<boolean>("hoverActions.references.enable"),
129             run: this.get<boolean>("hoverActions.run.enable"),
130             debug: this.get<boolean>("hoverActions.debug.enable"),
131             gotoTypeDef: this.get<boolean>("hoverActions.gotoTypeDef.enable"),
132         };
133     }
134
135     get currentExtensionIsNightly() {
136         return this.package.releaseTag === NIGHTLY_TAG;
137     }
138 }
139
140 export async function updateConfig(config: vscode.WorkspaceConfiguration) {
141     const renames = [
142         ["assist.allowMergingIntoGlobImports", "imports.merge.glob",],
143         ["assist.exprFillDefault", "assist.expressionFillDefault",],
144         ["assist.importEnforceGranularity", "imports.granularity.enforce",],
145         ["assist.importGranularity", "imports.granularity.group",],
146         ["assist.importMergeBehavior", "imports.granularity.group",],
147         ["assist.importMergeBehaviour", "imports.granularity.group",],
148         ["assist.importGroup", "imports.group.enable",],
149         ["assist.importPrefix", "imports.prefix",],
150         ["cache.warmup", "primeCaches.enable",],
151         ["cargo.loadOutDirsFromCheck", "cargo.buildScripts.enable",],
152         ["cargo.runBuildScripts", "cargo.buildScripts.enable",],
153         ["cargo.runBuildScriptsCommand", "cargo.buildScripts.overrideCommand",],
154         ["cargo.useRustcWrapperForBuildScripts", "cargo.buildScripts.useRustcWrapper",],
155         ["completion.snippets", "completion.snippets.custom",],
156         ["diagnostics.enableExperimental", "diagnostics.experimental.enable",],
157         ["experimental.procAttrMacros", "procMacro.attributes.enable",],
158         ["highlighting.strings", "semanticHighlighting.strings.enable",],
159         ["highlightRelated.breakPoints", "highlightRelated.breakPoints.enable",],
160         ["highlightRelated.exitPoints", "highlightRelated.exitPoints.enable",],
161         ["highlightRelated.yieldPoints", "highlightRelated.yieldPoints.enable",],
162         ["highlightRelated.references", "highlightRelated.references.enable",],
163         ["hover.documentation", "hover.documentation.enable",],
164         ["hover.linksInHover", "hover.links.enable",],
165         ["hoverActions.linksInHover", "hover.links.enable",],
166         ["hoverActions.debug", "hoverActions.debug.enable",],
167         ["hoverActions.enable", "hoverActions.enable.enable",],
168         ["hoverActions.gotoTypeDef", "hoverActions.gotoTypeDef.enable",],
169         ["hoverActions.implementations", "hoverActions.implementations.enable",],
170         ["hoverActions.references", "hoverActions.references.enable",],
171         ["hoverActions.run", "hoverActions.run.enable",],
172         ["inlayHints.chainingHints", "inlayHints.chainingHints.enable",],
173         ["inlayHints.closureReturnTypeHints", "inlayHints.closureReturnTypeHints.enable",],
174         ["inlayHints.hideNamedConstructorHints", "inlayHints.typeHints.hideNamedConstructorHints",],
175         ["inlayHints.parameterHints", "inlayHints.parameterHints.enable",],
176         ["inlayHints.reborrowHints", "inlayHints.reborrowHints.enable",],
177         ["inlayHints.typeHints", "inlayHints.typeHints.enable",],
178         ["lruCapacity", "lru.capacity",],
179         ["runnables.cargoExtraArgs", "runnables.extraArgs",],
180         ["runnables.overrideCargo", "runnables.command",],
181         ["rustcSource", "rustc.source",],
182         ["rustfmt.enableRangeFormatting", "rustfmt.rangeFormatting.enable"]
183     ];
184
185     for (const [oldKey, newKey] of renames) {
186         const inspect = config.inspect(oldKey);
187         if (inspect !== undefined) {
188             const valMatrix = [
189                 { val: inspect.globalValue, langVal: inspect.globalLanguageValue, target: vscode.ConfigurationTarget.Global },
190                 { val: inspect.workspaceFolderValue, langVal: inspect.workspaceFolderLanguageValue, target: vscode.ConfigurationTarget.WorkspaceFolder },
191                 { val: inspect.workspaceValue, langVal: inspect.workspaceLanguageValue, target: vscode.ConfigurationTarget.Workspace }
192             ];
193             for (const { val, langVal, target } of valMatrix) {
194                 const pred = (val: unknown) => {
195                     // some of the updates we do only append "enable" or "custom"
196                     // that means on the next run we would find these again, but as objects with
197                     // these properties causing us to destroy the config
198                     // so filter those already updated ones out
199                     return val !== undefined && !(typeof val === "object" && val !== null && (val.hasOwnProperty("enable") || val.hasOwnProperty("custom")));
200                 };
201                 if (pred(val)) {
202                     await config.update(newKey, val, target, false);
203                     await config.update(oldKey, undefined, target, false);
204                 }
205                 if (pred(langVal)) {
206                     await config.update(newKey, langVal, target, true);
207                     await config.update(oldKey, undefined, target, true);
208                 }
209             }
210         }
211     }
212 }
213
214 export function substituteVariablesInEnv(env: Env): Env {
215     const missingDeps = new Set<string>();
216     // vscode uses `env:ENV_NAME` for env vars resolution, and it's easier
217     // to follow the same convention for our dependency tracking
218     const definedEnvKeys = new Set(Object.keys(env).map(key => `env:${key}`));
219     const envWithDeps = Object.fromEntries(Object.entries(env).map(([key, value]) => {
220         const deps = new Set<string>();
221         const depRe = new RegExp(/\${(?<depName>.+?)}/g);
222         let match = undefined;
223         while ((match = depRe.exec(value))) {
224             const depName = match.groups!.depName;
225             deps.add(depName);
226             // `depName` at this point can have a form of `expression` or
227             // `prefix:expression`
228             if (!definedEnvKeys.has(depName)) {
229                 missingDeps.add(depName);
230             }
231         }
232         return [`env:${key}`, { deps: [...deps], value }];
233     }));
234
235     const resolved = new Set<string>();
236     for (const dep of missingDeps) {
237         const match = /(?<prefix>.*?):(?<body>.+)/.exec(dep);
238         if (match) {
239             const { prefix, body } = match.groups!;
240             if (prefix === 'env') {
241                 const envName = body;
242                 envWithDeps[dep] = {
243                     value: process.env[envName] ?? '',
244                     deps: []
245                 };
246                 resolved.add(dep);
247             } else {
248                 // we can't handle other prefixes at the moment
249                 // leave values as is, but still mark them as resolved
250                 envWithDeps[dep] = {
251                     value: '${' + dep + '}',
252                     deps: []
253                 };
254                 resolved.add(dep);
255             }
256         } else {
257             envWithDeps[dep] = {
258                 value: computeVscodeVar(dep),
259                 deps: []
260             };
261         }
262     }
263     const toResolve = new Set(Object.keys(envWithDeps));
264
265     let leftToResolveSize;
266     do {
267         leftToResolveSize = toResolve.size;
268         for (const key of toResolve) {
269             if (envWithDeps[key].deps.every(dep => resolved.has(dep))) {
270                 envWithDeps[key].value = envWithDeps[key].value.replace(
271                     /\${(?<depName>.+?)}/g, (_wholeMatch, depName) => {
272                         return envWithDeps[depName].value;
273                     });
274                 resolved.add(key);
275                 toResolve.delete(key);
276             }
277         }
278     } while (toResolve.size > 0 && toResolve.size < leftToResolveSize);
279
280     const resolvedEnv: Env = {};
281     for (const key of Object.keys(env)) {
282         resolvedEnv[key] = envWithDeps[`env:${key}`].value;
283     }
284     return resolvedEnv;
285 }
286
287 function computeVscodeVar(varName: string): string {
288     // https://code.visualstudio.com/docs/editor/variables-reference
289     const supportedVariables: { [k: string]: () => string } = {
290         workspaceFolder: () => {
291             const folders = vscode.workspace.workspaceFolders ?? [];
292             if (folders.length === 1) {
293                 // TODO: support for remote workspaces?
294                 return folders[0].uri.fsPath;
295             } else if (folders.length > 1) {
296                 // could use currently opened document to detect the correct
297                 // workspace. However, that would be determined by the document
298                 // user has opened on Editor startup. Could lead to
299                 // unpredictable workspace selection in practice.
300                 // It's better to pick the first one
301                 return folders[0].uri.fsPath;
302             } else {
303                 // no workspace opened
304                 return '';
305             }
306         },
307
308         workspaceFolderBasename: () => {
309             const workspaceFolder = computeVscodeVar('workspaceFolder');
310             if (workspaceFolder) {
311                 return path.basename(workspaceFolder);
312             } else {
313                 return '';
314             }
315         },
316
317         cwd: () => process.cwd(),
318
319         // see
320         // https://github.com/microsoft/vscode/blob/08ac1bb67ca2459496b272d8f4a908757f24f56f/src/vs/workbench/api/common/extHostVariableResolverService.ts#L81
321         // or
322         // https://github.com/microsoft/vscode/blob/29eb316bb9f154b7870eb5204ec7f2e7cf649bec/src/vs/server/node/remoteTerminalChannel.ts#L56
323         execPath: () => process.env.VSCODE_EXEC_PATH ?? process.execPath,
324
325         pathSeparator: () => path.sep
326     };
327
328     if (varName in supportedVariables) {
329         return supportedVariables[varName]();
330     } else {
331         // can't resolve, keep the expression as is
332         return '${' + varName + '}';
333     }
334 }