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