Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multi-pass environment variable resolution #2322

Merged
merged 11 commits into from
Jul 24, 2018
2 changes: 1 addition & 1 deletion Extension/.vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"runtimeExecutable": "${execPath}",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}",
"--extensionTestsPath=${workspaceFolder}/out/test"
"--extensionTestsPath=${workspaceFolder}/out/test/unitTests"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that's interesting...I guess we don't Launch Tests very much :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good so far. I'm not done reviewing yet...

],
"stopOnEntry": false,
"sourceMaps": true,
Expand Down
6 changes: 5 additions & 1 deletion Extension/gulpfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ gulp.task('allTests', () => {
});

gulp.task('unitTests', () => {
gulp.src('./out/test/unitTests', {read: false}).pipe(
env.set({
CODE_TESTS_PATH: "./out/test/unitTests",
}
);
gulp.src('./test/runVsCodeTestsWithAbsolutePaths.js', {read: false}).pipe(
mocha({
ui: "tdd"
})
Expand Down
87 changes: 46 additions & 41 deletions Extension/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,54 +178,59 @@ export function resolveVariables(input: string, additionalEnvironment: {[key: st
}

// Replace environment and configuration variables.
let regexp: RegExp = /\$\{((env|config|workspaceFolder)(.|:))?(.*?)\}/g;
let ret: string = input.replace(regexp, (match: string, ignored1: string, varType: string, ignored2: string, name: string) => {
// Historically, if the variable didn't have anything before the "." or ":"
// it was assumed to be an environment variable
if (varType === undefined) {
varType = "env";
}
let newValue: string = undefined;
switch (varType) {
case "env": {
let v: string | string[] = additionalEnvironment[name];
if (typeof v === "string") {
newValue = v;
} else if (input === match && v instanceof Array) {
newValue = v.join(";");
}
if (!newValue) {
newValue = process.env[name];
}
break;
let regexp: () => RegExp = () => /\$\{((env|config|workspaceFolder)(\.|:))?(.*?)\}/g;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch 👍

let ret: string = input;
let cycleCache: Set<string> = new Set();
while (!cycleCache.has(ret)) {
cycleCache.add(ret);
ret = ret.replace(regexp(), (match: string, ignored1: string, varType: string, ignored2: string, name: string) => {
// Historically, if the variable didn't have anything before the "." or ":"
// it was assumed to be an environment variable
if (varType === undefined) {
varType = "env";
}
case "config": {
let config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration();
if (config) {
newValue = config.get<string>(name);
let newValue: string = undefined;
switch (varType) {
case "env": {
let v: string | string[] = additionalEnvironment[name];
if (typeof v === "string") {
newValue = v;
} else if (input === match && v instanceof Array) {
newValue = v.join(";");
}
if (!newValue) {
newValue = process.env[name];
}
break;
}
break;
}
case "workspaceFolder": {
// Only replace ${workspaceFolder:name} variables for now.
// We may consider doing replacement of ${workspaceFolder} here later, but we would have to update the language server and also
// intercept messages with paths in them and add the ${workspaceFolder} variable back in (e.g. for light bulb suggestions)
if (name && vscode.workspace && vscode.workspace.workspaceFolders) {
let folder: vscode.WorkspaceFolder = vscode.workspace.workspaceFolders.find(folder => folder.name.toLocaleLowerCase() === name.toLocaleLowerCase());
if (folder) {
newValue = folder.uri.fsPath;
case "config": {
let config: vscode.WorkspaceConfiguration = vscode.workspace.getConfiguration();
if (config) {
newValue = config.get<string>(name);
}
break;
}
case "workspaceFolder": {
// Only replace ${workspaceFolder:name} variables for now.
// We may consider doing replacement of ${workspaceFolder} here later, but we would have to update the language server and also
// intercept messages with paths in them and add the ${workspaceFolder} variable back in (e.g. for light bulb suggestions)
if (name && vscode.workspace && vscode.workspace.workspaceFolders) {
let folder: vscode.WorkspaceFolder = vscode.workspace.workspaceFolders.find(folder => folder.name.toLocaleLowerCase() === name.toLocaleLowerCase());
if (folder) {
newValue = folder.uri.fsPath;
}
}
break;
}
break;
default: { assert.fail("unknown varType matched"); }
}
default: { assert.fail("unknown varType matched"); }
}
return (newValue) ? newValue : match;
});
return (newValue) ? newValue : match;
});
}

// Resolve '~' at the start of the path.
regexp = /^\~/g;
ret = ret.replace(regexp, (match: string, name: string) => {
regexp = () => /^\~/g;
ret = ret.replace(regexp(), (match: string, name: string) => {
let newValue: string = process.env.HOME;
return (newValue) ? newValue : match;
});
Expand Down
226 changes: 226 additions & 0 deletions Extension/test/unitTests/common.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/* --------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All Rights Reserved.
* See 'LICENSE' in the project root for license information.
* ------------------------------------------------------------------------------------------ */

import * as assert from "assert";
import { resolveVariables } from "../../src/common";

suite("Common Utility validation", () => {
suite("resolveVariables", () => {
const success: string = "success";
const home: string = process.env.HOME;

test("raw input", () => {
const input: string = "test";
inputAndEnvironment(input, {})
.shouldResolveTo(input);
});

test("raw input with tilde", () => {
inputAndEnvironment("~/test", {})
.shouldResolveTo(`${home}/test`);
});

test("env input with tilde", () => {
inputAndEnvironment("${path}/test", {
path: home
})
.shouldResolveTo(`${home}/test`);
});

test("solo env input resulting in array", () => {
inputAndEnvironment("${test}", {
test: ["foo", "bar"]
})
.shouldResolveTo("foo;bar");
});

test("mixed raw and env input resulting in array", () => {
const input: string = "baz${test}";
resolveVariablesWithInput(input)
.withEnvironment({
test: ["foo", "bar"]
})
.shouldResolveTo(input);
});

test("solo env input not in env config finds process env", () => {
const processKey: string = `cpptoolstests_${Date.now()}`;
const input: string = "foo${" + processKey + "}";
let actual: string;
try {
process.env[processKey] = "bar";
actual = resolveVariables(input, {});
} finally {
delete process.env[processKey];
}
assert.equal(actual, "foobar");
});

test("env input", () => {
resolveVariablesWithInput("${test}")
.withEnvironment({
"test": success
})
.shouldResolveTo(success);
});

test("env input mixed with plain text", () => {
resolveVariablesWithInput("${test}bar")
.withEnvironment({
"test": "foo"
})
.shouldResolveTo("foobar");
});

test("env input with two variables", () => {
resolveVariablesWithInput("f${a}${b}r")
.withEnvironment({
a: "oo",
b: "ba"
})
.shouldResolveTo("foobar");
});

test("env input not in env", () => {
const input: string = "${test}";
resolveVariablesWithInput(input)
.withEnvironment({})
.shouldResolveTo(input);
});

test("env with macro inside environment definition", () => {
resolveVariablesWithInput("${arm6.include}")
.withEnvironment({
"envRoot": "apps/tool/buildenv",
"arm6.include": "${envRoot}/arm6/include"
})
.shouldResolveTo("apps/tool/buildenv/arm6/include");
});

test("env nested with half open variable", () => {
resolveVariablesWithInput("${arm6.include}")
.withEnvironment({
"envRoot": "apps/tool/buildenv",
"arm6.include": "${envRoot/arm6/include"
})
.shouldResolveTo("${envRoot/arm6/include");
});

test("env nested with half closed variable", () => {
resolveVariablesWithInput("${arm6.include}")
.withEnvironment({
"envRoot": "apps/tool/buildenv",
"arm6.include": "envRoot}/arm6/include"
})
.shouldResolveTo("envRoot}/arm6/include");
});

test("env nested with a cycle", () => {
resolveVariablesWithInput("${a}")
.withEnvironment({
"a": "${b}",
"b": "${c}",
"c": "${a}"
})
.shouldResolveTo("${a}");
});

test("env input with 1 level of nested variables anchored at end", () => {
resolveVariablesWithInput("${foo${test}}")
.withEnvironment({
"foobar": success,
"test": "bar"
})
.shouldResolveTo("${foo${test}}");
});

test("env input with 1 level of nested variables anchored in the middle", () => {
resolveVariablesWithInput("${f${test}r}")
.withEnvironment({
"foobar": success,
"test": "ooba"
})
.shouldResolveTo("${f${test}r}");
});

test("env input with 1 level of nested variable anchored at front", () => {
resolveVariablesWithInput("${${test}bar}")
.withEnvironment({
"foobar": success,
"test": "foo"
})
.shouldResolveTo("${${test}bar}");
});

test("env input with 3 levels of nested variables", () => {
resolveVariablesWithInput("${foo${a${b${c}}}}")
.withEnvironment({
"foobar": success,
"a1": "bar",
"b2": "1",
"c": "2"
})
.shouldResolveTo("${foo${a${b${c}}}}");
});

test("env input contains env", () => {
resolveVariablesWithInput("${envRoot}")
.shouldLookupSymbol("envRoot");
});

test("env input contains config", () => {
resolveVariablesWithInput("${configRoot}")
.shouldLookupSymbol("configRoot");
});

test("env input contains workspaceFolder", () => {
resolveVariablesWithInput("${workspaceFolderRoot}")
.shouldLookupSymbol("workspaceFolderRoot");
});

test("input contains env.", () => {
resolveVariablesWithInput("${env.Root}")
.shouldLookupSymbol("Root");
});

test("input contains env:", () => {
resolveVariablesWithInput("${env:Root}")
.shouldLookupSymbol("Root");
});

interface ResolveTestFlowEnvironment {
withEnvironment(additionalEnvironment: {[key: string]: string | string[]}): ResolveTestFlowAssert;
shouldLookupSymbol: (key: string) => void;
}
interface ResolveTestFlowAssert {
shouldResolveTo: (x: string) => void;
}

function resolveVariablesWithInput(input: string): ResolveTestFlowEnvironment {
return {
withEnvironment: (additionalEnvironment: {[key: string]: string | string[]}) => {
return inputAndEnvironment(input, additionalEnvironment);
},
shouldLookupSymbol: (symbol: string) => {
const environment: {[key: string]: string | string[]} = {};
environment[symbol] = success;
return inputAndEnvironment(input, environment)
.shouldResolveTo(success);
}
};
}

function inputAndEnvironment(input: string, additionalEnvironment: {[key: string]: string | string[]}): ResolveTestFlowAssert {
return {
shouldResolveTo: (expected: string) => {
const actual: string = resolveVariables(input, additionalEnvironment);
const msg: string = `Expected ${expected}. Got ${actual} with input ${input} and environment ${JSON.stringify(additionalEnvironment)}.`;
assert.equal(actual, expected, msg);
}
};
}

});
});