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";
7 export type RunnableEnvCfg =
9 | Record<string, string>
10 | { mask?: string; env: Record<string, string> }[];
13 readonly extensionId = "rust-lang.rust-analyzer";
14 configureLang: vscode.Disposable | undefined;
16 readonly rootSection = "rust-analyzer";
17 private readonly requiresReloadOpts = [
23 "lens", // works as lens.*
24 ].map((opt) => `${this.rootSection}.${opt}`);
28 releaseTag: string | null;
29 enableProposedApi: boolean | undefined;
30 } = vscode.extensions.getExtension(this.extensionId)!.packageJSON;
32 readonly globalStorageUri: vscode.Uri;
34 constructor(ctx: vscode.ExtensionContext) {
35 this.globalStorageUri = ctx.globalStorageUri;
36 vscode.workspace.onDidChangeConfiguration(
37 this.onDidChangeConfiguration,
41 this.refreshLogging();
42 this.configureLanguage();
46 this.configureLang?.dispose();
49 private refreshLogging() {
50 log.setEnabled(this.traceExtension);
51 log.info("Extension version:", this.package.version);
53 const cfg = Object.entries(this.cfg).filter(([_, val]) => !(val instanceof Function));
54 log.info("Using configuration", Object.fromEntries(cfg));
57 private async onDidChangeConfiguration(event: vscode.ConfigurationChangeEvent) {
58 this.refreshLogging();
60 this.configureLanguage();
62 const requiresReloadOpt = this.requiresReloadOpts.find((opt) =>
63 event.affectsConfiguration(opt)
66 if (!requiresReloadOpt) return;
68 if (this.restartServerOnConfigChange) {
69 await vscode.commands.executeCommand("rust-analyzer.reload");
73 const message = `Changing "${requiresReloadOpt}" requires a server restart`;
74 const userResponse = await vscode.window.showInformationMessage(message, "Restart now");
77 const command = "rust-analyzer.reload";
78 await vscode.commands.executeCommand(command);
83 * Sets up additional language configuration that's impossible to do via a
84 * separate language-configuration.json file. See [1] for more information.
86 * [1]: https://github.com/Microsoft/vscode/issues/11514#issuecomment-244707076
88 private configureLanguage() {
89 if (this.typingContinueCommentsOnNewline && !this.configureLang) {
90 const indentAction = vscode.IndentAction.None;
92 this.configureLang = vscode.languages.setLanguageConfiguration("rust", {
95 // Doc single-line comment
97 beforeText: /^\s*\/{3}.*$/,
98 action: { indentAction, appendText: "/// " },
101 // Parent doc single-line comment
103 beforeText: /^\s*\/{2}\!.*$/,
104 action: { indentAction, appendText: "//! " },
107 // Begins an auto-closed multi-line comment (standard or parent doc)
108 // e.g. /** | */ or /*! | */
109 beforeText: /^\s*\/\*(\*|\!)(?!\/)([^\*]|\*(?!\/))*$/,
110 afterText: /^\s*\*\/$/,
112 indentAction: vscode.IndentAction.IndentOutdent,
117 // Begins a multi-line comment (standard or parent doc)
118 // e.g. /** ...| or /*! ...|
119 beforeText: /^\s*\/\*(\*|\!)(?!\/)([^\*]|\*(?!\/))*$/,
120 action: { indentAction, appendText: " * " },
123 // Continues a multi-line comment
125 beforeText: /^(\ \ )*\ \*(\ ([^\*]|\*(?!\/))*)?$/,
126 action: { indentAction, appendText: "* " },
129 // Dedents after closing a multi-line comment
131 beforeText: /^(\ \ )*\ \*\/\s*$/,
132 action: { indentAction, removeText: 1 },
137 if (!this.typingContinueCommentsOnNewline && this.configureLang) {
138 this.configureLang.dispose();
139 this.configureLang = undefined;
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
146 private get cfg(): vscode.WorkspaceConfiguration {
147 return vscode.workspace.getConfiguration(this.rootSection);
151 * Beware that postfix `!` operator erases both `null` and `undefined`.
152 * This is why the following doesn't work as expected:
155 * const nullableNum = vscode
158 * .getConfiguration("rust-analyzer")
159 * .get<number | null>(path)!;
161 * // What happens is that type of `nullableNum` is `number` but not `null | number`:
162 * const fullFledgedNum: number = nullableNum;
164 * So this getter handles this quirk by not requiring the caller to use postfix `!`
166 private get<T>(path: string): T {
167 return this.cfg.get<T>(path)!;
171 return this.get<null | string>("server.path") ?? this.get<null | string>("serverPath");
173 get serverExtraEnv(): Env {
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])
180 get traceExtension() {
181 return this.get<boolean>("trace.extension");
185 return this.get<string | undefined>("cargoRunner");
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]);
198 if (item instanceof Array) {
199 item.forEach((x) => fixRecord(x.env));
206 get restartServerOnConfigChange() {
207 return this.get<boolean>("restartServerOnConfigChange");
210 get typingContinueCommentsOnNewline() {
211 return this.get<boolean>("typing.continueCommentsOnNewline");
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;
224 engine: this.get<string>("debug.engine"),
225 engineSettings: this.get<object>("debug.engineSettings"),
226 openDebugPane: this.get<boolean>("debug.openDebugPane"),
227 sourceFileMap: sourceFileMap,
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"),
243 const VarRegex = new RegExp(/\$\{(.+?)\}/g);
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;
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);
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);
269 } else if (typeof resp === "function") {
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;
287 // `depName` at this point can have a form of `expression` or
288 // `prefix:expression`
289 if (!definedEnvKeys.has(depName)) {
290 missingDeps.add(depName);
293 return [`env:${key}`, { deps: [...deps], value }];
297 const resolved = new Set<string>();
298 for (const dep of missingDeps) {
299 const match = /(?<prefix>.*?):(?<body>.+)/.exec(dep);
301 const { prefix, body } = match.groups!;
302 if (prefix === "env") {
303 const envName = body;
305 value: process.env[envName] ?? "",
310 // we can't handle other prefixes at the moment
311 // leave values as is, but still mark them as resolved
313 value: "${" + dep + "}",
320 value: computeVscodeVar(dep) || "${" + dep + "}",
325 const toResolve = new Set(Object.keys(envWithDeps));
327 let leftToResolveSize;
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;
339 toResolve.delete(key);
342 } while (toResolve.size > 0 && toResolve.size < leftToResolveSize);
344 const resolvedEnv: Env = {};
345 for (const key of Object.keys(env)) {
346 resolvedEnv[key] = envWithDeps[`env:${key}`].value;
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;
365 // no workspace opened
369 // https://code.visualstudio.com/docs/editor/variables-reference
370 const supportedVariables: { [k: string]: () => string } = {
373 workspaceFolderBasename: () => {
374 return path.basename(workspaceFolder());
377 cwd: () => process.cwd(),
378 userHome: () => os.homedir(),
381 // https://github.com/microsoft/vscode/blob/08ac1bb67ca2459496b272d8f4a908757f24f56f/src/vs/workbench/api/common/extHostVariableResolverService.ts#L81
383 // https://github.com/microsoft/vscode/blob/29eb316bb9f154b7870eb5204ec7f2e7cf649bec/src/vs/server/node/remoteTerminalChannel.ts#L56
384 execPath: () => process.env.VSCODE_EXEC_PATH ?? process.execPath,
386 pathSeparator: () => path.sep,
389 if (varName in supportedVariables) {
390 return supportedVariables[varName]();
392 // return "${" + varName + "}";