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