]> git.lizzy.rs Git - rust.git/blob - src/tools/rust-analyzer/editors/code/src/config.ts
632a7d86faa369dcf3da8c1395b1d3c479730412
[rust.git] / src / tools / rust-analyzer / editors / code / src / config.ts
1 import * as path from "path";
2 import * as os from "os";
3 import * as vscode from "vscode";
4 import { Env } from "./client";
5 import { log } from "./util";
6
7 export type RunnableEnvCfg =
8     | undefined
9     | Record<string, string>
10     | { mask?: string; env: Record<string, string> }[];
11
12 export class Config {
13     readonly extensionId = "rust-lang.rust-analyzer";
14     configureLang: vscode.Disposable | undefined;
15
16     readonly rootSection = "rust-analyzer";
17     private readonly requiresReloadOpts = [
18         "cargo",
19         "procMacro",
20         "serverPath",
21         "server",
22         "files",
23         "lens", // works as lens.*
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(
37             this.onDidChangeConfiguration,
38             this,
39             ctx.subscriptions
40         );
41         this.refreshLogging();
42         this.configureLanguage();
43     }
44
45     dispose() {
46         this.configureLang?.dispose();
47     }
48
49     private refreshLogging() {
50         log.setEnabled(this.traceExtension);
51         log.info("Extension version:", this.package.version);
52
53         const cfg = Object.entries(this.cfg).filter(([_, val]) => !(val instanceof Function));
54         log.info("Using configuration", Object.fromEntries(cfg));
55     }
56
57     private async onDidChangeConfiguration(event: vscode.ConfigurationChangeEvent) {
58         this.refreshLogging();
59
60         this.configureLanguage();
61
62         const requiresReloadOpt = this.requiresReloadOpts.find((opt) =>
63             event.affectsConfiguration(opt)
64         );
65
66         if (!requiresReloadOpt) return;
67
68         if (this.restartServerOnConfigChange) {
69             await vscode.commands.executeCommand("rust-analyzer.reload");
70             return;
71         }
72
73         const message = `Changing "${requiresReloadOpt}" requires a server restart`;
74         const userResponse = await vscode.window.showInformationMessage(message, "Restart now");
75
76         if (userResponse) {
77             const command = "rust-analyzer.reload";
78             await vscode.commands.executeCommand(command);
79         }
80     }
81
82     /**
83      * Sets up additional language configuration that's impossible to do via a
84      * separate language-configuration.json file. See [1] for more information.
85      *
86      * [1]: https://github.com/Microsoft/vscode/issues/11514#issuecomment-244707076
87      */
88     private configureLanguage() {
89         if (this.typingContinueCommentsOnNewline && !this.configureLang) {
90             const indentAction = vscode.IndentAction.None;
91
92             this.configureLang = vscode.languages.setLanguageConfiguration("rust", {
93                 onEnterRules: [
94                     {
95                         // Doc single-line comment
96                         // e.g. ///|
97                         beforeText: /^\s*\/{3}.*$/,
98                         action: { indentAction, appendText: "/// " },
99                     },
100                     {
101                         // Parent doc single-line comment
102                         // e.g. //!|
103                         beforeText: /^\s*\/{2}\!.*$/,
104                         action: { indentAction, appendText: "//! " },
105                     },
106                     {
107                         // Begins an auto-closed multi-line comment (standard or parent doc)
108                         // e.g. /** | */ or /*! | */
109                         beforeText: /^\s*\/\*(\*|\!)(?!\/)([^\*]|\*(?!\/))*$/,
110                         afterText: /^\s*\*\/$/,
111                         action: {
112                             indentAction: vscode.IndentAction.IndentOutdent,
113                             appendText: " * ",
114                         },
115                     },
116                     {
117                         // Begins a multi-line comment (standard or parent doc)
118                         // e.g. /** ...| or /*! ...|
119                         beforeText: /^\s*\/\*(\*|\!)(?!\/)([^\*]|\*(?!\/))*$/,
120                         action: { indentAction, appendText: " * " },
121                     },
122                     {
123                         // Continues a multi-line comment
124                         // e.g.  * ...|
125                         beforeText: /^(\ \ )*\ \*(\ ([^\*]|\*(?!\/))*)?$/,
126                         action: { indentAction, appendText: "* " },
127                     },
128                     {
129                         // Dedents after closing a multi-line comment
130                         // e.g.  */|
131                         beforeText: /^(\ \ )*\ \*\/\s*$/,
132                         action: { indentAction, removeText: 1 },
133                     },
134                 ],
135             });
136         }
137         if (!this.typingContinueCommentsOnNewline && this.configureLang) {
138             this.configureLang.dispose();
139             this.configureLang = undefined;
140         }
141     }
142
143     // We don't do runtime config validation here for simplicity. More on stackoverflow:
144     // https://stackoverflow.com/questions/60135780/what-is-the-best-way-to-type-check-the-configuration-for-vscode-extension
145
146     private get cfg(): vscode.WorkspaceConfiguration {
147         return vscode.workspace.getConfiguration(this.rootSection);
148     }
149
150     /**
151      * Beware that postfix `!` operator erases both `null` and `undefined`.
152      * This is why the following doesn't work as expected:
153      *
154      * ```ts
155      * const nullableNum = vscode
156      *  .workspace
157      *  .getConfiguration
158      *  .getConfiguration("rust-analyzer")
159      *  .get<number | null>(path)!;
160      *
161      * // What happens is that type of `nullableNum` is `number` but not `null | number`:
162      * const fullFledgedNum: number = nullableNum;
163      * ```
164      * So this getter handles this quirk by not requiring the caller to use postfix `!`
165      */
166     private get<T>(path: string): T {
167         return this.cfg.get<T>(path)!;
168     }
169
170     get serverPath() {
171         return this.get<null | string>("server.path") ?? this.get<null | string>("serverPath");
172     }
173     get serverExtraEnv(): Env {
174         const extraEnv =
175             this.get<{ [key: string]: string | number } | null>("server.extraEnv") ?? {};
176         return Object.fromEntries(
177             Object.entries(extraEnv).map(([k, v]) => [k, typeof v !== "string" ? v.toString() : v])
178         );
179     }
180     get traceExtension() {
181         return this.get<boolean>("trace.extension");
182     }
183
184     get cargoRunner() {
185         return this.get<string | undefined>("cargoRunner");
186     }
187
188     get runnableEnv() {
189         const item = this.get<any>("runnableEnv");
190         if (!item) return item;
191         const fixRecord = (r: Record<string, any>) => {
192             for (const key in r) {
193                 if (typeof r[key] !== "string") {
194                     r[key] = String(r[key]);
195                 }
196             }
197         };
198         if (item instanceof Array) {
199             item.forEach((x) => fixRecord(x.env));
200         } else {
201             fixRecord(item);
202         }
203         return item;
204     }
205
206     get restartServerOnConfigChange() {
207         return this.get<boolean>("restartServerOnConfigChange");
208     }
209
210     get typingContinueCommentsOnNewline() {
211         return this.get<boolean>("typing.continueCommentsOnNewline");
212     }
213
214     get debug() {
215         let sourceFileMap = this.get<Record<string, string> | "auto">("debug.sourceFileMap");
216         if (sourceFileMap !== "auto") {
217             // "/rustc/<id>" used by suggestions only.
218             const { ["/rustc/<id>"]: _, ...trimmed } =
219                 this.get<Record<string, string>>("debug.sourceFileMap");
220             sourceFileMap = trimmed;
221         }
222
223         return {
224             engine: this.get<string>("debug.engine"),
225             engineSettings: this.get<object>("debug.engineSettings"),
226             openDebugPane: this.get<boolean>("debug.openDebugPane"),
227             sourceFileMap: sourceFileMap,
228         };
229     }
230
231     get hoverActions() {
232         return {
233             enable: this.get<boolean>("hover.actions.enable"),
234             implementations: this.get<boolean>("hover.actions.implementations.enable"),
235             references: this.get<boolean>("hover.actions.references.enable"),
236             run: this.get<boolean>("hover.actions.run.enable"),
237             debug: this.get<boolean>("hover.actions.debug.enable"),
238             gotoTypeDef: this.get<boolean>("hover.actions.gotoTypeDef.enable"),
239         };
240     }
241 }
242
243 const VarRegex = new RegExp(/\$\{(.+?)\}/g);
244
245 export function substituteVSCodeVariableInString(val: string): string {
246     return val.replace(VarRegex, (substring: string, varName) => {
247         if (typeof varName === "string") {
248             return computeVscodeVar(varName) || substring;
249         } else {
250             return substring;
251         }
252     });
253 }
254
255 export function substituteVSCodeVariables(resp: any): any {
256     if (typeof resp === "string") {
257         return substituteVSCodeVariableInString(resp);
258     } else if (resp && Array.isArray(resp)) {
259         return resp.map((val) => {
260             return substituteVSCodeVariables(val);
261         });
262     } else if (resp && typeof resp === "object") {
263         const res: { [key: string]: any } = {};
264         for (const key in resp) {
265             const val = resp[key];
266             res[key] = substituteVSCodeVariables(val);
267         }
268         return res;
269     } else if (typeof resp === "function") {
270         return null;
271     }
272     return resp;
273 }
274 export function substituteVariablesInEnv(env: Env): Env {
275     const missingDeps = new Set<string>();
276     // vscode uses `env:ENV_NAME` for env vars resolution, and it's easier
277     // to follow the same convention for our dependency tracking
278     const definedEnvKeys = new Set(Object.keys(env).map((key) => `env:${key}`));
279     const envWithDeps = Object.fromEntries(
280         Object.entries(env).map(([key, value]) => {
281             const deps = new Set<string>();
282             const depRe = new RegExp(/\${(?<depName>.+?)}/g);
283             let match = undefined;
284             while ((match = depRe.exec(value))) {
285                 const depName = match.groups!.depName;
286                 deps.add(depName);
287                 // `depName` at this point can have a form of `expression` or
288                 // `prefix:expression`
289                 if (!definedEnvKeys.has(depName)) {
290                     missingDeps.add(depName);
291                 }
292             }
293             return [`env:${key}`, { deps: [...deps], value }];
294         })
295     );
296
297     const resolved = new Set<string>();
298     for (const dep of missingDeps) {
299         const match = /(?<prefix>.*?):(?<body>.+)/.exec(dep);
300         if (match) {
301             const { prefix, body } = match.groups!;
302             if (prefix === "env") {
303                 const envName = body;
304                 envWithDeps[dep] = {
305                     value: process.env[envName] ?? "",
306                     deps: [],
307                 };
308                 resolved.add(dep);
309             } else {
310                 // we can't handle other prefixes at the moment
311                 // leave values as is, but still mark them as resolved
312                 envWithDeps[dep] = {
313                     value: "${" + dep + "}",
314                     deps: [],
315                 };
316                 resolved.add(dep);
317             }
318         } else {
319             envWithDeps[dep] = {
320                 value: computeVscodeVar(dep) || "${" + dep + "}",
321                 deps: [],
322             };
323         }
324     }
325     const toResolve = new Set(Object.keys(envWithDeps));
326
327     let leftToResolveSize;
328     do {
329         leftToResolveSize = toResolve.size;
330         for (const key of toResolve) {
331             if (envWithDeps[key].deps.every((dep) => resolved.has(dep))) {
332                 envWithDeps[key].value = envWithDeps[key].value.replace(
333                     /\${(?<depName>.+?)}/g,
334                     (_wholeMatch, depName) => {
335                         return envWithDeps[depName].value;
336                     }
337                 );
338                 resolved.add(key);
339                 toResolve.delete(key);
340             }
341         }
342     } while (toResolve.size > 0 && toResolve.size < leftToResolveSize);
343
344     const resolvedEnv: Env = {};
345     for (const key of Object.keys(env)) {
346         resolvedEnv[key] = envWithDeps[`env:${key}`].value;
347     }
348     return resolvedEnv;
349 }
350
351 function computeVscodeVar(varName: string): string | null {
352     const workspaceFolder = () => {
353         const folders = vscode.workspace.workspaceFolders ?? [];
354         if (folders.length === 1) {
355             // TODO: support for remote workspaces?
356             return folders[0].uri.fsPath;
357         } else if (folders.length > 1) {
358             // could use currently opened document to detect the correct
359             // workspace. However, that would be determined by the document
360             // user has opened on Editor startup. Could lead to
361             // unpredictable workspace selection in practice.
362             // It's better to pick the first one
363             return folders[0].uri.fsPath;
364         } else {
365             // no workspace opened
366             return "";
367         }
368     };
369     // https://code.visualstudio.com/docs/editor/variables-reference
370     const supportedVariables: { [k: string]: () => string } = {
371         workspaceFolder,
372
373         workspaceFolderBasename: () => {
374             return path.basename(workspaceFolder());
375         },
376
377         cwd: () => process.cwd(),
378         userHome: () => os.homedir(),
379
380         // see
381         // https://github.com/microsoft/vscode/blob/08ac1bb67ca2459496b272d8f4a908757f24f56f/src/vs/workbench/api/common/extHostVariableResolverService.ts#L81
382         // or
383         // https://github.com/microsoft/vscode/blob/29eb316bb9f154b7870eb5204ec7f2e7cf649bec/src/vs/server/node/remoteTerminalChannel.ts#L56
384         execPath: () => process.env.VSCODE_EXEC_PATH ?? process.execPath,
385
386         pathSeparator: () => path.sep,
387     };
388
389     if (varName in supportedVariables) {
390         return supportedVariables[varName]();
391     } else {
392         // return "${" + varName + "}";
393         return null;
394     }
395 }