Skip to content

Commit 33531af

Browse files
Copilotsnehara99
andauthored
feat: support ${workspaceFolder} and relative paths in cmake.exclude (#4689)
* Initial plan * feat: add support for ${workspaceFolder} and relative paths in cmake.exclude Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> * fix: address code review feedback for cmake.exclude path expansion Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> * fix: improve test assertion for workspaceFolder:name fallback behavior Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> * docs: add documentation for test updates in util.test.ts Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> * docs: link PR #4689 in CHANGELOG.md for cmake.exclude feature Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> * fix: use lightNormalizePath in tests for cross-platform path consistency Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> * changelog entry and filled in gap in testing --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: snehara99 <113148726+snehara99@users.noreply.github.com> Co-authored-by: Sneha Ramachandran <snehara@microsoft.com>
1 parent de8566e commit 33531af

5 files changed

Lines changed: 181 additions & 3 deletions

File tree

CHANGELOG.md

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

55
Features:
66
- Add support for the FASTBuild generator (CMake 4.2+). [#4690](https://github.com/microsoft/vscode-cmake-tools/pull/4690)
7+
- Add support for `${workspaceFolder}`, `${workspaceFolder:name}` variables and relative paths in `cmake.exclude` setting for multi-root workspaces. [#4689](https://github.com/microsoft/vscode-cmake-tools/pull/4689)
78

89
Bug Fixes:
910
- Fix kit detection returning "unknown vendor" when using clang-cl compiler. [#4638](https://github.com/microsoft/vscode-cmake-tools/issues/4638)

docs/cmake-settings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Options that support substitution, in the table below, allow variable references
5757
| `cmake.languageServerOnlyMode` | If `true`, keep CMake language services enabled while disabling CMake project, build, test, and kit integration. | `false` | no |
5858
| `cmake.enableTraceLogging` | If `true`, enable trace logging. | `false` | no |
5959
| `cmake.environment` | An object containing `key:value` pairs of environment variables, which will be available when configuring, building, or testing with CTest. | `{}` (no environment variables) | yes |
60-
| `cmake.exclude` | CMake Tools will ignore the folders defined in this setting. | `[]` | no |
60+
| `cmake.exclude` | CMake Tools will ignore the folders defined in this setting. | `[]` | yes |
6161
| `cmake.exportCompileCommandsFile` | If `true`, generate the compile_commands.json file. | `true` | no |
6262
| `cmake.generator` | Set to a string to override CMake Tools preferred generator logic. If set, CMake will unconditionally use it as the `-G` CMake generator command line argument. | `null` | no |
6363
| `cmake.ignoreCMakeListsMissing` | If `true`, do not show error when opening a project without CMakeLists.txt. | `false` | no |

src/projectController.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export class ProjectController implements vscode.Disposable {
356356
} else {
357357
// Load for the workspace.
358358
const workspaceContext = DirectoryContext.createForDirectory(folder, new StateManager(this.extensionContext, folder));
359-
const excludedFolders = workspaceContext.config.exclude;
359+
const excludedFolders = util.expandExcludePaths(workspaceContext.config.exclude, folder);
360360

361361
if (excludedFolders.findIndex((f) => util.normalizePath(f, { normCase: 'always'}) === util.normalizePath(folder.uri.fsPath, { normCase: 'always' })) === -1) {
362362
projects = await this.acknowledgeFolder(folder, workspaceContext);
@@ -589,8 +589,11 @@ export class ProjectController implements vscode.Disposable {
589589
for (const folder of this.folderToProjectsMap.keys()) {
590590
const folderPath = util.normalizePath(folder.uri.fsPath, { normCase: 'always' });
591591

592+
// Expand the excluded folders paths with variable substitution and relative path resolution
593+
const expandedExcludedFolders = util.expandExcludePaths(excludedFolders, folder);
594+
592595
// Check if the folder is in the ignored folders list
593-
const isIgnored = excludedFolders.some((ignoredFolder) => {
596+
const isIgnored = expandedExcludedFolders.some((ignoredFolder) => {
594597
const normalizedIgnoredFolder = util.normalizePath(ignoredFolder, { normCase: 'always' });
595598
return folderPath === normalizedIgnoredFolder;
596599
});

src/util.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,44 @@ export function resolvePath(inpath: string, base: string) {
177177
return lightNormalizePath(abspath);
178178
}
179179

180+
/**
181+
* Expands a path that may contain ${workspaceFolder} or ${workspaceFolder:name} variables,
182+
* and resolves relative paths to absolute paths based on the provided workspace folder.
183+
* Note: The ${workspaceFolder:name} lookup is case-insensitive, consistent with VS Code's
184+
* behavior on Windows/macOS file systems.
185+
* @param inputPath The path to expand
186+
* @param workspaceFolder The workspace folder to use for expansion and resolving relative paths
187+
* @returns The expanded and resolved path
188+
*/
189+
export function expandExcludePath(inputPath: string, workspaceFolder: vscode.WorkspaceFolder): string {
190+
let expandedPath = inputPath;
191+
192+
// First expand ${workspaceFolder} to the current workspace folder path
193+
expandedPath = expandedPath.replace(/\$\{workspaceFolder\}/g, workspaceFolder.uri.fsPath);
194+
195+
// Then expand ${workspaceFolder:name} to the specific workspace folder path
196+
if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) {
197+
expandedPath = expandedPath.replace(/\$\{workspaceFolder:(.+?)\}/g, (match, folderName) => {
198+
const folder = vscode.workspace.workspaceFolders!.find(f => f.name.toLowerCase() === folderName.toLowerCase());
199+
return folder ? folder.uri.fsPath : match;
200+
});
201+
}
202+
203+
// Finally resolve relative paths to absolute paths based on the workspace folder
204+
return resolvePath(expandedPath, workspaceFolder.uri.fsPath);
205+
}
206+
207+
/**
208+
* Expands an array of paths that may contain ${workspaceFolder} or ${workspaceFolder:name} variables,
209+
* and resolves relative paths to absolute paths based on the provided workspace folder.
210+
* @param paths The paths to expand
211+
* @param workspaceFolder The workspace folder to use for expansion and resolving relative paths
212+
* @returns The expanded and resolved paths
213+
*/
214+
export function expandExcludePaths(paths: string[], workspaceFolder: vscode.WorkspaceFolder): string[] {
215+
return paths.map(p => expandExcludePath(p, workspaceFolder));
216+
}
217+
180218
/**
181219
* Split a path into its elements.
182220
* @param p The path to split

test/unit-tests/util.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1+
/**
2+
* Unit tests for utility functions in util.ts
3+
*/
4+
15
import * as util from '@cmt/util';
6+
import * as vscode from 'vscode';
27
import { expect } from '@test/util';
8+
import * as path from 'path';
9+
import * as sinon from 'sinon';
310

411
suite('Utils test', () => {
512
test('Split path into elements', () => {
@@ -29,3 +36,132 @@ suite('Utils test', () => {
2936
}
3037
});
3138
});
39+
40+
// Shared test helper for creating mock workspace folders
41+
function createMockWorkspaceFolder(fsPath: string, name: string): vscode.WorkspaceFolder {
42+
return {
43+
uri: vscode.Uri.file(fsPath),
44+
name: name,
45+
index: 0
46+
};
47+
}
48+
49+
// Test base path that works on both Windows and Unix
50+
const testBasePath = process.platform === 'win32' ? 'C:\\Projects\\MyProject' : '/home/user/projects/myproject';
51+
52+
suite('expandExcludePath tests', () => {
53+
// Helper to get expected path using the same normalization as the code under test
54+
// The expandExcludePath function uses lightNormalizePath which converts backslashes to forward slashes
55+
// and vscode.Uri.file().fsPath may return lowercase drive letters on Windows
56+
function getExpectedPath(...segments: string[]): string {
57+
const folder = createMockWorkspaceFolder(segments[0], 'test');
58+
return util.lightNormalizePath(path.join(...segments.map(s => s === segments[0] ? folder.uri.fsPath : s)));
59+
}
60+
61+
test('Expand ${workspaceFolder} variable', () => {
62+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
63+
const result = util.expandExcludePath('${workspaceFolder}/subdir', folder);
64+
const expected = getExpectedPath(testBasePath, 'subdir');
65+
expect(result).to.eq(expected);
66+
});
67+
68+
test('Expand multiple ${workspaceFolder} variables', () => {
69+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
70+
const result = util.expandExcludePath('${workspaceFolder}/foo/${workspaceFolder}/bar', folder);
71+
// For this case, we need to manually construct the expected path since it contains testBasePath twice
72+
const folderPath = folder.uri.fsPath;
73+
const expected = util.lightNormalizePath(path.join(folderPath, 'foo', folderPath, 'bar'));
74+
expect(result).to.eq(expected);
75+
});
76+
77+
test('Resolve relative path', () => {
78+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
79+
const result = util.expandExcludePath('subdir/nested', folder);
80+
const expected = getExpectedPath(testBasePath, 'subdir', 'nested');
81+
expect(result).to.eq(expected);
82+
});
83+
84+
test('Absolute path remains unchanged', () => {
85+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
86+
const absolutePath = process.platform === 'win32' ? 'D:\\Other\\Path' : '/other/path';
87+
const result = util.expandExcludePath(absolutePath, folder);
88+
const expected = util.lightNormalizePath(absolutePath);
89+
expect(result).to.eq(expected);
90+
});
91+
92+
test('Expand ${workspaceFolder} and resolve relative path', () => {
93+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
94+
const result = util.expandExcludePath('${workspaceFolder}/../other', folder);
95+
const folderPath = folder.uri.fsPath;
96+
const expected = util.lightNormalizePath(path.join(folderPath, '..', 'other'));
97+
expect(result).to.eq(expected);
98+
});
99+
100+
test('Expand ${workspaceFolder:name} when named folder exists', () => {
101+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
102+
const otherBasePath = process.platform === 'win32' ? 'C:\\Projects\\OtherProject' : '/home/user/projects/otherproject';
103+
const otherFolder = createMockWorkspaceFolder(otherBasePath, 'OtherProject');
104+
105+
const stub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folder, otherFolder]);
106+
try {
107+
const result = util.expandExcludePath('${workspaceFolder:OtherProject}/subdir', folder);
108+
const expected = util.lightNormalizePath(path.join(otherFolder.uri.fsPath, 'subdir'));
109+
expect(result).to.eq(expected);
110+
} finally {
111+
stub.restore();
112+
}
113+
});
114+
115+
test('Expand ${workspaceFolder:name} is case-insensitive', () => {
116+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
117+
const otherBasePath = process.platform === 'win32' ? 'C:\\Projects\\OtherProject' : '/home/user/projects/otherproject';
118+
const otherFolder = createMockWorkspaceFolder(otherBasePath, 'OtherProject');
119+
120+
const stub = sinon.stub(vscode.workspace, 'workspaceFolders').value([folder, otherFolder]);
121+
try {
122+
const result = util.expandExcludePath('${workspaceFolder:otherproject}/subdir', folder);
123+
const expected = util.lightNormalizePath(path.join(otherFolder.uri.fsPath, 'subdir'));
124+
expect(result).to.eq(expected);
125+
} finally {
126+
stub.restore();
127+
}
128+
});
129+
130+
test('${workspaceFolder:name} fallback when folder name not found', () => {
131+
// When ${workspaceFolder:name} references a folder that doesn't exist in the workspace,
132+
// the variable should be left unchanged and then resolved as a relative path
133+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
134+
const input = '${workspaceFolder:NonExistentFolder}/subdir';
135+
const result = util.expandExcludePath(input, folder);
136+
// If vscode.workspace.workspaceFolders is empty or undefined, or if the folder is not found,
137+
// the ${workspaceFolder:NonExistentFolder} variable is left as-is
138+
// Then resolvePath treats it as a relative path segment
139+
const folderPath = folder.uri.fsPath;
140+
const expectedPath = util.lightNormalizePath(path.join(folderPath, '${workspaceFolder:NonExistentFolder}', 'subdir'));
141+
expect(result).to.eq(expectedPath);
142+
});
143+
});
144+
145+
suite('expandExcludePaths tests', () => {
146+
test('Expand multiple paths', () => {
147+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
148+
const paths = [
149+
'${workspaceFolder}/subdir1',
150+
'relative/path',
151+
process.platform === 'win32' ? 'D:\\Absolute\\Path' : '/absolute/path'
152+
];
153+
const results = util.expandExcludePaths(paths, folder);
154+
const folderPath = folder.uri.fsPath;
155+
156+
expect(results).to.have.lengthOf(3);
157+
expect(results[0]).to.eq(util.lightNormalizePath(path.join(folderPath, 'subdir1')));
158+
expect(results[1]).to.eq(util.lightNormalizePath(path.join(folderPath, 'relative', 'path')));
159+
expect(results[2]).to.eq(util.lightNormalizePath(process.platform === 'win32' ? 'D:\\Absolute\\Path' : '/absolute/path'));
160+
});
161+
162+
test('Empty array returns empty array', () => {
163+
const folder = createMockWorkspaceFolder(testBasePath, 'MyProject');
164+
const results = util.expandExcludePaths([], folder);
165+
expect(results).to.have.lengthOf(0);
166+
});
167+
});

0 commit comments

Comments
 (0)