Skip to content

Commit 927ef0c

Browse files
committed
Auto merge of #12215 - listochkin:Support-variable-substitution-in-vscode-settings, r=Veykril
feat: Support variable substitution in VSCode settings Currently support a subset of [variables provided by VSCode](https://code.visualstudio.com/docs/editor/variables-reference) in `server.extraEnv` section of Rust-Analyzer settings: * `workspaceFolder` * `workspaceFolderBasename` * `cwd` * `execPath` * `pathSeparator` Also, this PR adds support for general environment variables resolution. You can declare environment variables and reference them from other variables like this: ```JSON "rust-analyzer.server.extraEnv": { "RUSTFLAGS": "-L${env:OPEN_XR_SDK_PATH}", "OPEN_XR_SDK_PATH": "${workspaceFolder}\\..\\OpenXR-SDK\\build\\src\\loader\\Release" }, ``` The order of variable declaration doesn't matter, you can reference variables before defining them. If the variable is not present in `extraEnv` section, VSCode will search for them in your environment. Missing variables will be replaced with empty string. Circular references won't be resolved and will be passed to rust-analyzer server process as is. Closes #9626, but doesn't address use cases where people want to use values provided by `rustc` or `cargo`, such as `${targetTriple}` proposal #11649
2 parents 7a55863 + 33d2c8a commit 927ef0c

File tree

7 files changed

+225
-9
lines changed

7 files changed

+225
-9
lines changed

editors/code/package-lock.json

+29-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

editors/code/package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,20 @@
3333
"lint": "tsfmt --verify && eslint -c .eslintrc.js --ext ts ./src ./tests",
3434
"fix": " tsfmt -r && eslint -c .eslintrc.js --ext ts ./src ./tests --fix",
3535
"pretest": "tsc && npm run build",
36-
"test": "node ./out/tests/runTests.js"
36+
"test": "cross-env TEST_VARIABLE=test node ./out/tests/runTests.js"
3737
},
3838
"dependencies": {
39-
"vscode-languageclient": "8.0.0-next.14",
4039
"d3": "^7.3.0",
41-
"d3-graphviz": "^4.1.0"
40+
"d3-graphviz": "^4.1.0",
41+
"vscode-languageclient": "8.0.0-next.14"
4242
},
4343
"devDependencies": {
4444
"@types/node": "~14.17.5",
4545
"@types/vscode": "~1.66.0",
4646
"@typescript-eslint/eslint-plugin": "^5.16.0",
4747
"@typescript-eslint/parser": "^5.16.0",
4848
"@vscode/test-electron": "^2.1.3",
49+
"cross-env": "^7.0.3",
4950
"esbuild": "^0.14.27",
5051
"eslint": "^8.11.0",
5152
"tslib": "^2.3.0",

editors/code/src/client.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { assert } from './util';
66
import { WorkspaceEdit } from 'vscode';
77
import { Workspace } from './ctx';
88
import { updateConfig } from './config';
9+
import { substituteVariablesInEnv } from './config';
910

1011
export interface Env {
1112
[name: string]: string;
@@ -30,9 +31,9 @@ export async function createClient(serverPath: string, workspace: Workspace, ext
3031
// TODO?: Workspace folders support Uri's (eg: file://test.txt).
3132
// It might be a good idea to test if the uri points to a file.
3233

33-
const newEnv = Object.assign({}, process.env);
34-
Object.assign(newEnv, extraEnv);
35-
34+
const newEnv = substituteVariablesInEnv(Object.assign(
35+
{}, process.env, extraEnv
36+
));
3637
const run: lc.Executable = {
3738
command: serverPath,
3839
options: { env: newEnv },

editors/code/src/config.ts

+123
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path = require('path');
12
import * as vscode from 'vscode';
23
import { Env } from './client';
34
import { log } from "./util";
@@ -210,3 +211,125 @@ export async function updateConfig(config: vscode.WorkspaceConfiguration) {
210211
}
211212
}
212213
}
214+
215+
export function substituteVariablesInEnv(env: Env): Env {
216+
const missingDeps = new Set<string>();
217+
// vscode uses `env:ENV_NAME` for env vars resolution, and it's easier
218+
// to follow the same convention for our dependency tracking
219+
const definedEnvKeys = new Set(Object.keys(env).map(key => `env:${key}`));
220+
const envWithDeps = Object.fromEntries(Object.entries(env).map(([key, value]) => {
221+
const deps = new Set<string>();
222+
const depRe = new RegExp(/\${(?<depName>.+?)}/g);
223+
let match = undefined;
224+
while ((match = depRe.exec(value))) {
225+
const depName = match.groups!.depName;
226+
deps.add(depName);
227+
// `depName` at this point can have a form of `expression` or
228+
// `prefix:expression`
229+
if (!definedEnvKeys.has(depName)) {
230+
missingDeps.add(depName);
231+
}
232+
}
233+
return [`env:${key}`, { deps: [...deps], value }];
234+
}));
235+
236+
const resolved = new Set<string>();
237+
for (const dep of missingDeps) {
238+
const match = /(?<prefix>.*?):(?<body>.+)/.exec(dep);
239+
if (match) {
240+
const { prefix, body } = match.groups!;
241+
if (prefix === 'env') {
242+
const envName = body;
243+
envWithDeps[dep] = {
244+
value: process.env[envName] ?? '',
245+
deps: []
246+
};
247+
resolved.add(dep);
248+
} else {
249+
// we can't handle other prefixes at the moment
250+
// leave values as is, but still mark them as resolved
251+
envWithDeps[dep] = {
252+
value: '${' + dep + '}',
253+
deps: []
254+
};
255+
resolved.add(dep);
256+
}
257+
} else {
258+
envWithDeps[dep] = {
259+
value: computeVscodeVar(dep),
260+
deps: []
261+
};
262+
}
263+
}
264+
const toResolve = new Set(Object.keys(envWithDeps));
265+
266+
let leftToResolveSize;
267+
do {
268+
leftToResolveSize = toResolve.size;
269+
for (const key of toResolve) {
270+
if (envWithDeps[key].deps.every(dep => resolved.has(dep))) {
271+
envWithDeps[key].value = envWithDeps[key].value.replace(
272+
/\${(?<depName>.+?)}/g, (_wholeMatch, depName) => {
273+
return envWithDeps[depName].value;
274+
});
275+
resolved.add(key);
276+
toResolve.delete(key);
277+
}
278+
}
279+
} while (toResolve.size > 0 && toResolve.size < leftToResolveSize);
280+
281+
const resolvedEnv: Env = {};
282+
for (const key of Object.keys(env)) {
283+
resolvedEnv[key] = envWithDeps[`env:${key}`].value;
284+
}
285+
return resolvedEnv;
286+
}
287+
288+
function computeVscodeVar(varName: string): string {
289+
// https://code.visualstudio.com/docs/editor/variables-reference
290+
const supportedVariables: { [k: string]: () => string } = {
291+
workspaceFolder: () => {
292+
const folders = vscode.workspace.workspaceFolders ?? [];
293+
if (folders.length === 1) {
294+
// TODO: support for remote workspaces?
295+
return folders[0].uri.fsPath;
296+
} else if (folders.length > 1) {
297+
// could use currently opened document to detect the correct
298+
// workspace. However, that would be determined by the document
299+
// user has opened on Editor startup. Could lead to
300+
// unpredictable workspace selection in practice.
301+
// It's better to pick the first one
302+
return folders[0].uri.fsPath;
303+
} else {
304+
// no workspace opened
305+
return '';
306+
}
307+
},
308+
309+
workspaceFolderBasename: () => {
310+
const workspaceFolder = computeVscodeVar('workspaceFolder');
311+
if (workspaceFolder) {
312+
return path.basename(workspaceFolder);
313+
} else {
314+
return '';
315+
}
316+
},
317+
318+
cwd: () => process.cwd(),
319+
320+
// see
321+
// https://github.com/microsoft/vscode/blob/08ac1bb67ca2459496b272d8f4a908757f24f56f/src/vs/workbench/api/common/extHostVariableResolverService.ts#L81
322+
// or
323+
// https://github.com/microsoft/vscode/blob/29eb316bb9f154b7870eb5204ec7f2e7cf649bec/src/vs/server/node/remoteTerminalChannel.ts#L56
324+
execPath: () => process.env.VSCODE_EXEC_PATH ?? process.execPath,
325+
326+
pathSeparator: () => path.sep
327+
};
328+
329+
if (varName in supportedVariables) {
330+
return supportedVariables[varName]();
331+
} else {
332+
// can't resolve, keep the expression as is
333+
return '${' + varName + '}';
334+
}
335+
}

editors/code/tests/runTests.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ async function main() {
1414
let minimalVersion: string = json.engines.vscode;
1515
if (minimalVersion.startsWith('^')) minimalVersion = minimalVersion.slice(1);
1616

17-
const launchArgs = ["--disable-extensions"];
17+
const launchArgs = ["--disable-extensions", extensionDevelopmentPath];
1818

1919
// All test suites (either unit tests or integration tests) should be in subfolders.
2020
const extensionTestsPath = path.resolve(__dirname, './unit/index');

editors/code/tests/unit/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readdir } from 'fs/promises';
12
import * as path from 'path';
23

34
class Test {
@@ -57,7 +58,8 @@ export class Context {
5758

5859
export async function run(): Promise<void> {
5960
const context = new Context();
60-
const testFiles = ["launch_config.test.js", "runnable_env.test.js"];
61+
62+
const testFiles = (await readdir(path.resolve(__dirname))).filter(name => name.endsWith('.test.js'));
6163
for (const testFile of testFiles) {
6264
try {
6365
const testModule = require(path.resolve(__dirname, testFile));
+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as assert from 'assert';
2+
import { Context } from '.';
3+
import { substituteVariablesInEnv } from '../../src/config';
4+
5+
export async function getTests(ctx: Context) {
6+
await ctx.suite('Server Env Settings', suite => {
7+
suite.addTest('Replacing Env Variables', async () => {
8+
const envJson = {
9+
USING_MY_VAR: "${env:MY_VAR} test ${env:MY_VAR}",
10+
MY_VAR: "test"
11+
};
12+
const expectedEnv = {
13+
USING_MY_VAR: "test test test",
14+
MY_VAR: "test"
15+
};
16+
const actualEnv = await substituteVariablesInEnv(envJson);
17+
assert.deepStrictEqual(actualEnv, expectedEnv);
18+
});
19+
20+
suite.addTest('Circular dependencies remain as is', async () => {
21+
const envJson = {
22+
A_USES_B: "${env:B_USES_A}",
23+
B_USES_A: "${env:A_USES_B}",
24+
C_USES_ITSELF: "${env:C_USES_ITSELF}",
25+
D_USES_C: "${env:C_USES_ITSELF}",
26+
E_IS_ISOLATED: "test",
27+
F_USES_E: "${env:E_IS_ISOLATED}"
28+
};
29+
const expectedEnv = {
30+
A_USES_B: "${env:B_USES_A}",
31+
B_USES_A: "${env:A_USES_B}",
32+
C_USES_ITSELF: "${env:C_USES_ITSELF}",
33+
D_USES_C: "${env:C_USES_ITSELF}",
34+
E_IS_ISOLATED: "test",
35+
F_USES_E: "test"
36+
};
37+
const actualEnv = await substituteVariablesInEnv(envJson);
38+
assert.deepStrictEqual(actualEnv, expectedEnv);
39+
});
40+
41+
suite.addTest('Should support external variables', async () => {
42+
const envJson = {
43+
USING_EXTERNAL_VAR: "${env:TEST_VARIABLE} test ${env:TEST_VARIABLE}"
44+
};
45+
const expectedEnv = {
46+
USING_EXTERNAL_VAR: "test test test"
47+
};
48+
49+
const actualEnv = await substituteVariablesInEnv(envJson);
50+
assert.deepStrictEqual(actualEnv, expectedEnv);
51+
});
52+
53+
suite.addTest('should support VSCode variables', async () => {
54+
const envJson = {
55+
USING_VSCODE_VAR: "${workspaceFolderBasename}"
56+
};
57+
const actualEnv = await substituteVariablesInEnv(envJson);
58+
assert.deepStrictEqual(actualEnv.USING_VSCODE_VAR, 'code');
59+
});
60+
});
61+
}

0 commit comments

Comments
 (0)