Skip to content

Commit f9b1a80

Browse files
CopilotOmotola
andauthored
feat: add ${cmake.testEnvironment} placeholder for CTest debug configurations (#4821)
* Initial plan * feat: add ${cmake.testEnvironment} placeholder and auto-include CTest ENVIRONMENT in debug config (#4572) Co-authored-by: Omotola <[email protected]> * Revert unneccesary bookmark changes and cleanup documentation * allow value to be both string and array --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Omotola <[email protected]> Co-authored-by: Omotola <[email protected]>
1 parent 300de6a commit f9b1a80

6 files changed

Lines changed: 215 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 1.23
44

55
Features:
6+
- Add `${cmake.testEnvironment}` placeholder for launch.json that resolves to the CTest `ENVIRONMENT` test property, and automatically include CTest environment variables when debugging tests without a launch configuration. [#4572](https://github.com/microsoft/vscode-cmake-tools/issues/4572) [#4821](https://github.com/microsoft/vscode-cmake-tools/pull/4821)
67
- Add "Delete Build Directory and Reconfigure" command that removes the entire build directory before reconfiguring, ensuring a completely clean state. [#4826](https://github.com/microsoft/vscode-cmake-tools/pull/4826)
78
- Add `cmake.shell` setting to route CMake/CTest/CPack subprocess invocations through a custom shell (e.g., Git Bash, MSYS2), enabling embedded toolchains that require POSIX path translation on Windows. [#1750](https://github.com/microsoft/vscode-cmake-tools/issues/1750)
89
- triple: Add riscv32be riscv64be support. [#4648](https://github.com/microsoft/vscode-cmake-tools/pull/4648) [@lygstate](https://github.com/lygstate)

docs/cmake-settings.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ Supported commands for substitution:
152152
|`cmake.activeBuildPresetName`|The name of the active build preset.|
153153
|`cmake.activeTestPresetName`|The name of the active test preset.|
154154

155+
### Test debug placeholders
156+
157+
The following placeholders are available in launch.json debug configurations used to debug CTest tests from the Test Explorer. See [Debugging tests](debug-launch.md#debugging-tests) for full examples.
158+
159+
|Placeholder|Expansion|
160+
|-----------|---------|
161+
|`${cmake.testProgram}`|The full path to the test executable.|
162+
|`${cmake.testArgs}`|The command-line arguments for the test.|
163+
|`${cmake.testWorkingDirectory}`|The working directory for the test.|
164+
|`${cmake.testEnvironment}`|The environment variables set via the CTest `ENVIRONMENT` test property (e.g., from `set_tests_properties(... PROPERTIES ENVIRONMENT "A=B;C=D")`). Replaced with an array of `{ "name": "...", "value": "..." }` objects suitable for launch.json.|
165+
155166
## Additional build problem matchers
156167

157168
The `cmake.additionalBuildProblemMatchers` setting lets you define custom problem matchers that are applied to build output. This is useful when you integrate tools like **clang-tidy**, **PCLint Plus**, **cppcheck**, or custom scripts into your CMake build via `add_custom_command` or `add_custom_target`. Diagnostics from these tools will appear in the VS Code **Problems** pane alongside the standard compiler errors.

docs/debug-launch.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,9 @@ You can also construct launch.json configurations that allow you to debug tests
268268
> These launch.json configurations are to be used specifically from the UI of the Test Explorer.
269269
270270
The easiest way to do this is to construct the debug configuration using `cmake.testProgram` for the `program` field, `cmake.testArgs` for
271-
the `args` field, and `cmake.testWorkingDirectory` for the `cwd` field.
271+
the `args` field, `cmake.testWorkingDirectory` for the `cwd` field, and `cmake.testEnvironment` for the `environment` field.
272+
273+
`cmake.testEnvironment` resolves to the environment variables set via the CTest `ENVIRONMENT` test property (e.g., from `set_tests_properties(... PROPERTIES ENVIRONMENT "A=B;C=D")`). It is replaced with an array of `{ "name": "...", "value": "..." }` objects suitable for launch.json.
272274

273275
A couple of examples:
274276

@@ -283,6 +285,7 @@ A couple of examples:
283285
"cwd": "${cmake.testWorkingDirectory}",
284286
"program": "${cmake.testProgram}",
285287
"args": [ "${cmake.testArgs}"],
288+
"environment": "${cmake.testEnvironment}",
286289
}
287290
```
288291
### msvc
@@ -295,6 +298,7 @@ A couple of examples:
295298
// Resolved by CMake Tools:
296299
"program": "${cmake.testProgram}",
297300
"args": [ "${cmake.testArgs}"],
301+
"environment": "${cmake.testEnvironment}",
298302
}
299303
```
300304

src/cmakeProject.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2646,7 +2646,10 @@ export class CMakeProject {
26462646
const userConfig = this.workspaceContext.config.debugConfig;
26472647
Object.assign(debugConfig, userConfig);
26482648

2649-
const launchEnv = await this.getTargetLaunchEnvironment(drv, debugConfig.environment);
2649+
// Merge CTest ENVIRONMENT properties into the debug environment
2650+
const testEnvVars = util.makeDebuggerEnvironmentVars(testInfo.environment);
2651+
const combinedEnvVars = [...testEnvVars, ...(debugConfig.environment ?? [])];
2652+
const launchEnv = await this.getTargetLaunchEnvironment(drv, combinedEnvVars);
26502653
debugConfig.environment = util.makeDebuggerEnvironmentVars(launchEnv);
26512654

26522655
await vscode.debug.startDebugging(this.workspaceFolder, debugConfig);

src/ctest.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,18 +1194,19 @@ export class CTestDriver implements vscode.Disposable {
11941194
}
11951195

11961196
/**
1197-
* Returns the executable path, arguments, and working directory for a given test name.
1197+
* Returns the executable path, arguments, working directory, and environment for a given test name.
11981198
* Used by cmakeProject to auto-generate debug configurations without requiring launch.json.
11991199
*/
1200-
getTestInfo(testName: string): { program: string; args: string[]; workingDirectory: string } | undefined {
1200+
getTestInfo(testName: string): { program: string; args: string[]; workingDirectory: string; environment: { [key: string]: string } } | undefined {
12011201
const program = this.testProgram(testName);
12021202
if (!program) {
12031203
return undefined;
12041204
}
12051205
return {
12061206
program,
12071207
args: this.testArgs(testName),
1208-
workingDirectory: this.testWorkingDirectory(testName)
1208+
workingDirectory: this.testWorkingDirectory(testName),
1209+
environment: this.testEnvironment(testName)
12091210
};
12101211
}
12111212

@@ -1573,6 +1574,30 @@ export class CTestDriver implements vscode.Disposable {
15731574
return [];
15741575
}
15751576

1577+
/**
1578+
* Returns the ENVIRONMENT property from the CTest test properties for the given test.
1579+
* CTest stores environment as `["KEY=VALUE", ...]`; this returns `{ KEY: "VALUE", ... }`.
1580+
*/
1581+
private testEnvironment(testName: string): { [key: string]: string } {
1582+
const env: { [key: string]: string } = {};
1583+
const property = this.tests?.tests
1584+
.find(test => test.name === testName)?.properties
1585+
.find(prop => prop.name === 'ENVIRONMENT');
1586+
1587+
if (property) {
1588+
const entries = Array.isArray(property.value) ? property.value : [property.value];
1589+
for (const entry of entries) {
1590+
const eqIndex = entry.indexOf('=');
1591+
if (eqIndex !== -1) {
1592+
const name = entry.substring(0, eqIndex);
1593+
const value = entry.substring(eqIndex + 1);
1594+
env[name] = value;
1595+
}
1596+
}
1597+
}
1598+
return env;
1599+
}
1600+
15761601
private replaceAllInObject<T>(obj: any, str: string, replace: string): T {
15771602
const regex = new RegExp(util.escapeStringForRegex(str), 'g');
15781603
if (util.isString(obj)) {
@@ -1624,6 +1649,25 @@ export class CTestDriver implements vscode.Disposable {
16241649
return orig;
16251650
}
16261651

1652+
/**
1653+
* Recursively replaces a string value that exactly matches `str` with an arbitrary replacement value.
1654+
* Used for replacing placeholders like `${cmake.testEnvironment}` with an array of objects.
1655+
*/
1656+
private replaceValueInObject<T>(obj: any, str: string, replace: any): T {
1657+
if (util.isString(obj) && obj === str) {
1658+
return replace;
1659+
} else if (util.isArray(obj)) {
1660+
for (let i = 0; i < obj.length; i++) {
1661+
obj[i] = this.replaceValueInObject(obj[i], str, replace);
1662+
}
1663+
} else if (typeof obj === 'object' && obj !== null) {
1664+
for (const key of Object.keys(obj)) {
1665+
obj[key] = this.replaceValueInObject(obj[key], str, replace);
1666+
}
1667+
}
1668+
return obj;
1669+
}
1670+
16271671
private getLaunchConfigs(workspaceFolder: vscode.WorkspaceFolder): ConfigItem[] {
16281672
// Use inspect() to read configs from each scope separately, avoiding the
16291673
// duplicates that get() produces when it merges all scopes together.
@@ -1687,6 +1731,11 @@ export class CTestDriver implements vscode.Disposable {
16871731
// since we need to replace the quotes as well.
16881732
chosenConfig.config = this.replaceArrayItems(chosenConfig.config, '${cmake.testArgs}', this.testArgs(testName)) as vscode.DebugConfiguration;
16891733

1734+
// Replace cmake.testEnvironment with the test's ENVIRONMENT property as an array of { name, value } objects.
1735+
const testEnv = this.testEnvironment(testName);
1736+
const testEnvArray = Object.entries(testEnv).map(([name, value]) => ({ name, value }));
1737+
chosenConfig.config = this.replaceValueInObject<vscode.DebugConfiguration>(chosenConfig.config, '${cmake.testEnvironment}', testEnvArray);
1738+
16901739
// Identify the session we started
16911740
chosenConfig.config[magicKey] = magicValue;
16921741
let onDidStartDebugSession: vscode.Disposable | undefined;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { expect } from 'chai';
2+
3+
/**
4+
* Mirror of the pure parsing logic from CTestDriver.testEnvironment().
5+
* Parses CTest ENVIRONMENT property values (string or string[]) into a { KEY: VALUE } map.
6+
* CTest JSON v1 stores a single environment variable as a plain string, multiple as an array.
7+
*/
8+
function parseTestEnvironment(envEntries: string | string[]): { [key: string]: string } {
9+
const env: { [key: string]: string } = {};
10+
const entries = Array.isArray(envEntries) ? envEntries : [envEntries];
11+
for (const entry of entries) {
12+
const eqIndex = entry.indexOf('=');
13+
if (eqIndex !== -1) {
14+
const name = entry.substring(0, eqIndex);
15+
const value = entry.substring(eqIndex + 1);
16+
env[name] = value;
17+
}
18+
}
19+
return env;
20+
}
21+
22+
/**
23+
* Mirror of CTestDriver.replaceValueInObject().
24+
* Recursively replaces string values exactly matching `str` with `replace`.
25+
*/
26+
function replaceValueInObject<T>(obj: any, str: string, replace: any): T {
27+
if (typeof obj === 'string' && obj === str) {
28+
return replace;
29+
} else if (Array.isArray(obj)) {
30+
for (let i = 0; i < obj.length; i++) {
31+
obj[i] = replaceValueInObject(obj[i], str, replace);
32+
}
33+
} else if (typeof obj === 'object' && obj !== null) {
34+
for (const key of Object.keys(obj)) {
35+
obj[key] = replaceValueInObject(obj[key], str, replace);
36+
}
37+
}
38+
return obj;
39+
}
40+
41+
suite('[CTest test environment parsing]', () => {
42+
test('Parse basic KEY=VALUE entries', () => {
43+
const result = parseTestEnvironment(['A=B', 'C=D']);
44+
expect(result).to.deep.equal({ A: 'B', C: 'D' });
45+
});
46+
47+
test('Parse entries with values containing equals signs', () => {
48+
const result = parseTestEnvironment(['PATH=/usr/bin:/usr/local/bin', 'FLAGS=-O2 -DFOO=BAR']);
49+
expect(result).to.deep.equal({
50+
PATH: '/usr/bin:/usr/local/bin',
51+
FLAGS: '-O2 -DFOO=BAR'
52+
});
53+
});
54+
55+
test('Parse LD_LIBRARY_PATH entry', () => {
56+
const result = parseTestEnvironment(['LD_LIBRARY_PATH=/some/lib:/other/lib']);
57+
expect(result).to.deep.equal({ LD_LIBRARY_PATH: '/some/lib:/other/lib' });
58+
});
59+
60+
test('Skip entries without equals sign', () => {
61+
const result = parseTestEnvironment(['VALID=value', 'NOEQUALSSIGN', 'ALSO_VALID=123']);
62+
expect(result).to.deep.equal({ VALID: 'value', ALSO_VALID: '123' });
63+
});
64+
65+
test('Handle empty array', () => {
66+
const result = parseTestEnvironment([]);
67+
expect(result).to.deep.equal({});
68+
});
69+
70+
test('Handle entry with empty value', () => {
71+
const result = parseTestEnvironment(['KEY=']);
72+
expect(result).to.deep.equal({ KEY: '' });
73+
});
74+
75+
test('Handle entry with empty key', () => {
76+
const result = parseTestEnvironment(['=value']);
77+
expect(result).to.deep.equal({ '': 'value' });
78+
});
79+
80+
test('Parse single string value (CTest JSON v1 single-entry format)', () => {
81+
const result = parseTestEnvironment('MY_VAR=hello_world');
82+
expect(result).to.deep.equal({ MY_VAR: 'hello_world' });
83+
});
84+
});
85+
86+
suite('[replaceValueInObject]', () => {
87+
test('Replace string placeholder at top level', () => {
88+
const obj = { environment: '${cmake.testEnvironment}' };
89+
const replacement = [{ name: 'A', value: 'B' }];
90+
const result = replaceValueInObject(obj, '${cmake.testEnvironment}', replacement);
91+
expect(result).to.deep.equal({ environment: [{ name: 'A', value: 'B' }] });
92+
});
93+
94+
test('Replace placeholder nested in object', () => {
95+
const obj = {
96+
name: 'test',
97+
config: {
98+
program: '/path/to/test',
99+
environment: '${cmake.testEnvironment}'
100+
}
101+
};
102+
const replacement = [{ name: 'X', value: 'Y' }, { name: 'Z', value: 'W' }];
103+
const result = replaceValueInObject(obj, '${cmake.testEnvironment}', replacement);
104+
expect(result).to.deep.equal({
105+
name: 'test',
106+
config: {
107+
program: '/path/to/test',
108+
environment: [{ name: 'X', value: 'Y' }, { name: 'Z', value: 'W' }]
109+
}
110+
});
111+
});
112+
113+
test('Do not replace partial string matches', () => {
114+
const obj = { value: 'prefix${cmake.testEnvironment}suffix' };
115+
const replacement = [{ name: 'A', value: 'B' }];
116+
const result = replaceValueInObject(obj, '${cmake.testEnvironment}', replacement);
117+
expect(result).to.deep.equal({ value: 'prefix${cmake.testEnvironment}suffix' });
118+
});
119+
120+
test('Replace placeholder in array element', () => {
121+
const obj = { items: ['keep', '${cmake.testEnvironment}', 'also-keep'] };
122+
const replacement = [{ name: 'A', value: 'B' }];
123+
const result = replaceValueInObject(obj, '${cmake.testEnvironment}', replacement);
124+
expect(result).to.deep.equal({ items: ['keep', [{ name: 'A', value: 'B' }], 'also-keep'] });
125+
});
126+
127+
test('Handle empty replacement array', () => {
128+
const obj = { environment: '${cmake.testEnvironment}' };
129+
const result = replaceValueInObject(obj, '${cmake.testEnvironment}', []);
130+
expect(result).to.deep.equal({ environment: [] });
131+
});
132+
133+
test('Leave unrelated strings untouched', () => {
134+
const obj = { program: '${cmake.testProgram}', environment: '${cmake.testEnvironment}' };
135+
const replacement = [{ name: 'A', value: 'B' }];
136+
const result = replaceValueInObject(obj, '${cmake.testEnvironment}', replacement);
137+
expect(result).to.deep.equal({
138+
program: '${cmake.testProgram}',
139+
environment: [{ name: 'A', value: 'B' }]
140+
});
141+
});
142+
});

0 commit comments

Comments
 (0)