From 898c553fa7923aa5b81f5ee93536429e0423cb39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:11:54 +0000 Subject: [PATCH 1/4] Initial plan From efa666c968f5a8b5ac1d1d5f178c4377a160b030 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:27:26 +0000 Subject: [PATCH 2/4] feat: add singleConfig/multiConfig object form for cmake.buildDirectory (#2426) Co-authored-by: hanniavalera <90047725+hanniavalera@users.noreply.github.com> --- CHANGELOG.md | 1 + package.json | 20 +++++++++- package.nls.json | 4 +- src/config.ts | 19 ++++++++-- src/drivers/cmakeDriver.ts | 4 +- src/projectController.ts | 12 ++++-- test/unit-tests/config.test.ts | 69 ++++++++++++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0663d6ea8..c4f4ceb9b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.23 Features: +- Add `singleConfig`/`multiConfig` object form for `cmake.buildDirectory` to allow separate build directory templates for single- and multi-config generators. [#2426](https://github.com/microsoft/vscode-cmake-tools/issues/2426) - triple: Add riscv32be riscv64be support. [#4648](https://github.com/microsoft/vscode-cmake-tools/pull/4648) [@lygstate](https://github.com/lygstate) - Add command to clear build diagnostics from the Problems pane. [#4691](https://github.com/microsoft/vscode-cmake-tools/pull/4691) - Clear build diagnostics from the Problems pane when a new build starts and populate them incrementally during the build. [#4608](https://github.com/microsoft/vscode-cmake-tools/issues/4608) diff --git a/package.json b/package.json index 46be1f8fe6..651833577c 100644 --- a/package.json +++ b/package.json @@ -2261,7 +2261,25 @@ "scope": "resource" }, "cmake.buildDirectory": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "singleConfig": { + "type": "string", + "description": "%cmake-tools.configuration.cmake.buildDirectory.singleConfig.description%" + }, + "multiConfig": { + "type": "string", + "description": "%cmake-tools.configuration.cmake.buildDirectory.multiConfig.description%" + } + }, + "additionalProperties": false + } + ], "default": "${workspaceFolder}/build", "description": "%cmake-tools.configuration.cmake.buildDirectory.description%", "scope": "resource" diff --git a/package.nls.json b/package.nls.json index ddea591cb7..3349d74f2a 100644 --- a/package.nls.json +++ b/package.nls.json @@ -106,7 +106,9 @@ ] }, "cmake-tools.configuration.cmake.cmakePath.description": "Name/path of the CMake executable to use.", - "cmake-tools.configuration.cmake.buildDirectory.description": "The directory where CMake build files will go.", + "cmake-tools.configuration.cmake.buildDirectory.description": "The directory where CMake build files will go. Can be a string (used for all generators) or an object with `singleConfig` and/or `multiConfig` properties to specify separate build directories for single-config (e.g. Make, Ninja) and multi-config (e.g. Ninja Multi-Config, Visual Studio) generators.", + "cmake-tools.configuration.cmake.buildDirectory.singleConfig.description": "Build directory template for single-config generators (e.g. Make, Ninja).", + "cmake-tools.configuration.cmake.buildDirectory.multiConfig.description": "Build directory template for multi-config generators (e.g. Ninja Multi-Config, Visual Studio, Xcode).", "cmake-tools.configuration.cmake.installPrefix.description": "The directory where CMake installed files will go.", "cmake-tools.configuration.cmake.sourceDirectory.description": "Path or array of paths to the CMakeLists.txt root directory/directories.", "cmake-tools.configuration.cmake.saveBeforeBuild.description": "Save open files before building.", diff --git a/src/config.ts b/src/config.ts index 467dd6483e..2734e521d9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -169,7 +169,7 @@ export interface ExtensionConfigurationSettings { defaultActiveFolder: string | null; exclude: string[]; cmakePath: string; - buildDirectory: string; + buildDirectory: string | { singleConfig?: string; multiConfig?: string }; installPrefix: string | null; sourceDirectory: string | string[]; saveBeforeBuild: boolean; @@ -341,11 +341,24 @@ export class ConfigurationReader implements vscode.Disposable { return this.configData.exclude; } - buildDirectory(multiProject: boolean, workspaceFolder?: vscode.ConfigurationScope): string { + buildDirectory(multiProject: boolean, workspaceFolder?: vscode.ConfigurationScope, generatorName?: string | null): string { + const defaultBuildDir = '${workspaceFolder}/build'; if (multiProject && this.isDefaultValue('buildDirectory', workspaceFolder)) { return '${sourceDirectory}/build'; } - return this.configData.buildDirectory; + const raw = this.configData.buildDirectory; + if (typeof raw === 'string') { + return raw; + } + // Object form: pick the branch based on the generator type. + // isMultiConfGeneratorFast is a fast/pre-configure heuristic and may not be + // authoritative until after the first configure run. + const isMultiConf = util.isMultiConfGeneratorFast(generatorName ?? undefined); + if (isMultiConf) { + return raw.multiConfig ?? raw.singleConfig ?? defaultBuildDir; + } else { + return raw.singleConfig ?? raw.multiConfig ?? defaultBuildDir; + } } get installPrefix(): string | null { return this.configData.installPrefix; diff --git a/src/drivers/cmakeDriver.ts b/src/drivers/cmakeDriver.ts index b65e8dfab1..9649ab0479 100644 --- a/src/drivers/cmakeDriver.ts +++ b/src/drivers/cmakeDriver.ts @@ -742,7 +742,7 @@ export abstract class CMakeDriver implements vscode.Disposable { await this._setKit(kit, preferredGenerators); await this._refreshExpansions(); const scope = this.workspaceFolder ? vscode.Uri.file(this.workspaceFolder) : undefined; - const newBinaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope), this.expansionOptions)); + const newBinaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope, this.generatorName), this.expansionOptions)); if (needsCleanIfKitChange && (newBinaryDir === oldBinaryDir)) { await this._cleanPriorConfiguration(); } @@ -855,7 +855,7 @@ export abstract class CMakeDriver implements vscode.Disposable { if (!this.useCMakePresets) { const scope = this.workspaceFolder ? vscode.Uri.file(this.workspaceFolder) : undefined; - this._binaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope), opts)); + this._binaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope, this.generatorName), opts)); } }); } diff --git a/src/projectController.ts b/src/projectController.ts index 4ee27859ea..78d3c12a44 100644 --- a/src/projectController.ts +++ b/src/projectController.ts @@ -307,9 +307,15 @@ export class ProjectController implements vscode.Disposable { if (sourceDirectories.length <= 1) { return; } - const unresolvedBuildDirectory: string = config.buildDirectory(sourceDirectories.length > 1); - - if (unresolvedBuildDirectory.includes("${sourceDirectory}") || unresolvedBuildDirectory.includes("${sourceDir}")) { + // When the object form is used, check both branches since the generator is not yet known. + const singleConfDir: string = config.buildDirectory(sourceDirectories.length > 1, undefined, 'Unix Makefiles'); + const multiConfDir: string = config.buildDirectory(sourceDirectories.length > 1, undefined, 'Ninja Multi-Config'); + const dirsToCheck = new Set([singleConfDir, multiConfDir]); + + const allContainSourceDir = [...dirsToCheck].every( + d => d.includes("${sourceDirectory}") || d.includes("${sourceDir}") + ); + if (allContainSourceDir) { return; } else { const sameBinaryDir = localize('duplicate.build.directory.1', 'Multiple CMake projects in this folder are using the same CMAKE_BINARY_DIR.'); diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index 39fd8fb910..18c92b830e 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -156,4 +156,73 @@ suite('Configuration', () => { conf.updatePartial({ buildDirectory: 'Foo' }); expect(conf.parallelJobs).to.eq(5); }); + + test('buildDirectory plain string form returns the string', () => { + const conf = createConfig({ buildDirectory: '/my/build/path' }); + expect(conf.buildDirectory(false)).to.eq('/my/build/path'); + }); + + test('buildDirectory object form returns singleConfig for single-config generator', () => { + const conf = createConfig({ + buildDirectory: { + singleConfig: '/build/single-${buildType}', + multiConfig: '/build/multi' + } + }); + // 'Unix Makefiles' is a single-config generator + expect(conf.buildDirectory(false, undefined, 'Unix Makefiles')).to.eq('/build/single-${buildType}'); + }); + + test('buildDirectory object form returns multiConfig for multi-config generator', () => { + const conf = createConfig({ + buildDirectory: { + singleConfig: '/build/single-${buildType}', + multiConfig: '/build/multi' + } + }); + // 'Ninja Multi-Config' is a multi-config generator + expect(conf.buildDirectory(false, undefined, 'Ninja Multi-Config')).to.eq('/build/multi'); + }); + + test('buildDirectory object form with only singleConfig falls back for multi-config generator', () => { + const conf = createConfig({ + buildDirectory: { singleConfig: '/build/single' } + }); + // No multiConfig set, should fall back to singleConfig + expect(conf.buildDirectory(false, undefined, 'Ninja Multi-Config')).to.eq('/build/single'); + }); + + test('buildDirectory object form with only multiConfig falls back for single-config generator', () => { + const conf = createConfig({ + buildDirectory: { multiConfig: '/build/multi' } + }); + // No singleConfig set, should fall back to multiConfig + expect(conf.buildDirectory(false, undefined, 'Unix Makefiles')).to.eq('/build/multi'); + }); + + test('buildDirectory object form with empty object falls back to default', () => { + const conf = createConfig({ + buildDirectory: {} + }); + expect(conf.buildDirectory(false, undefined, 'Unix Makefiles')).to.eq('${workspaceFolder}/build'); + }); + + test('buildDirectory object form with Visual Studio generator returns multiConfig', () => { + const conf = createConfig({ + buildDirectory: { + singleConfig: '/build/single', + multiConfig: '/build/multi' + } + }); + expect(conf.buildDirectory(false, undefined, 'Visual Studio 17 2022')).to.eq('/build/multi'); + }); + + test('buildDirectory multiProject default override still works with plain string', () => { + // When buildDirectory is default (empty string) and multiProject is true, + // it returns '${sourceDirectory}/build'. But since isDefaultValue checks + // vscode.workspace config (not available in unit tests), this tests that + // a non-default string value is returned as-is regardless of multiProject. + const conf = createConfig({ buildDirectory: '/custom/build' }); + expect(conf.buildDirectory(true)).to.eq('/custom/build'); + }); }); From 43f25374f2f06aac3e9742bd49fc1be067516d63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:29:43 +0000 Subject: [PATCH 3/4] refactor: use boolean isMultiConfig param and extract default constant per review Co-authored-by: hanniavalera <90047725+hanniavalera@users.noreply.github.com> --- src/config.ts | 16 ++++++++-------- src/drivers/cmakeDriver.ts | 4 ++-- src/projectController.ts | 4 ++-- test/unit-tests/config.test.ts | 21 ++++++++++----------- 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2734e521d9..cc6da2902c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,8 @@ export function defaultNumJobs (): number { const log = logging.createLogger('config'); +export const defaultBuildDirectoryValue = '${workspaceFolder}/build'; + export type LogLevelKey = 'trace' | 'debug' | 'info' | 'note' | 'warning' | 'error' | 'fatal'; export type CMakeCommunicationMode = 'legacy' | 'serverApi' | 'fileApi' | 'automatic'; export type StatusBarOptionVisibility = "visible" | "compact" | "icon" | "hidden" | "inherit"; @@ -341,8 +343,7 @@ export class ConfigurationReader implements vscode.Disposable { return this.configData.exclude; } - buildDirectory(multiProject: boolean, workspaceFolder?: vscode.ConfigurationScope, generatorName?: string | null): string { - const defaultBuildDir = '${workspaceFolder}/build'; + buildDirectory(multiProject: boolean, workspaceFolder?: vscode.ConfigurationScope, isMultiConfig?: boolean): string { if (multiProject && this.isDefaultValue('buildDirectory', workspaceFolder)) { return '${sourceDirectory}/build'; } @@ -351,13 +352,12 @@ export class ConfigurationReader implements vscode.Disposable { return raw; } // Object form: pick the branch based on the generator type. - // isMultiConfGeneratorFast is a fast/pre-configure heuristic and may not be - // authoritative until after the first configure run. - const isMultiConf = util.isMultiConfGeneratorFast(generatorName ?? undefined); - if (isMultiConf) { - return raw.multiConfig ?? raw.singleConfig ?? defaultBuildDir; + // The isMultiConfig flag is typically derived from isMultiConfGeneratorFast(), + // a fast/pre-configure heuristic that may not be authoritative until after the first configure run. + if (isMultiConfig) { + return raw.multiConfig ?? raw.singleConfig ?? defaultBuildDirectoryValue; } else { - return raw.singleConfig ?? raw.multiConfig ?? defaultBuildDir; + return raw.singleConfig ?? raw.multiConfig ?? defaultBuildDirectoryValue; } } get installPrefix(): string | null { diff --git a/src/drivers/cmakeDriver.ts b/src/drivers/cmakeDriver.ts index 9649ab0479..e6d855e13a 100644 --- a/src/drivers/cmakeDriver.ts +++ b/src/drivers/cmakeDriver.ts @@ -742,7 +742,7 @@ export abstract class CMakeDriver implements vscode.Disposable { await this._setKit(kit, preferredGenerators); await this._refreshExpansions(); const scope = this.workspaceFolder ? vscode.Uri.file(this.workspaceFolder) : undefined; - const newBinaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope, this.generatorName), this.expansionOptions)); + const newBinaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope, this.isMultiConfFast), this.expansionOptions)); if (needsCleanIfKitChange && (newBinaryDir === oldBinaryDir)) { await this._cleanPriorConfiguration(); } @@ -855,7 +855,7 @@ export abstract class CMakeDriver implements vscode.Disposable { if (!this.useCMakePresets) { const scope = this.workspaceFolder ? vscode.Uri.file(this.workspaceFolder) : undefined; - this._binaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope, this.generatorName), opts)); + this._binaryDir = util.lightNormalizePath(await expand.expandString(this.config.buildDirectory(this.isMultiProject, scope, this.isMultiConfFast), opts)); } }); } diff --git a/src/projectController.ts b/src/projectController.ts index 78d3c12a44..281c688785 100644 --- a/src/projectController.ts +++ b/src/projectController.ts @@ -308,8 +308,8 @@ export class ProjectController implements vscode.Disposable { return; } // When the object form is used, check both branches since the generator is not yet known. - const singleConfDir: string = config.buildDirectory(sourceDirectories.length > 1, undefined, 'Unix Makefiles'); - const multiConfDir: string = config.buildDirectory(sourceDirectories.length > 1, undefined, 'Ninja Multi-Config'); + const singleConfDir: string = config.buildDirectory(sourceDirectories.length > 1, undefined, false); + const multiConfDir: string = config.buildDirectory(sourceDirectories.length > 1, undefined, true); const dirsToCheck = new Set([singleConfDir, multiConfDir]); const allContainSourceDir = [...dirsToCheck].every( diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index 18c92b830e..9cc3a6271d 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -162,26 +162,24 @@ suite('Configuration', () => { expect(conf.buildDirectory(false)).to.eq('/my/build/path'); }); - test('buildDirectory object form returns singleConfig for single-config generator', () => { + test('buildDirectory object form returns singleConfig when isMultiConfig is false', () => { const conf = createConfig({ buildDirectory: { singleConfig: '/build/single-${buildType}', multiConfig: '/build/multi' } }); - // 'Unix Makefiles' is a single-config generator - expect(conf.buildDirectory(false, undefined, 'Unix Makefiles')).to.eq('/build/single-${buildType}'); + expect(conf.buildDirectory(false, undefined, false)).to.eq('/build/single-${buildType}'); }); - test('buildDirectory object form returns multiConfig for multi-config generator', () => { + test('buildDirectory object form returns multiConfig when isMultiConfig is true', () => { const conf = createConfig({ buildDirectory: { singleConfig: '/build/single-${buildType}', multiConfig: '/build/multi' } }); - // 'Ninja Multi-Config' is a multi-config generator - expect(conf.buildDirectory(false, undefined, 'Ninja Multi-Config')).to.eq('/build/multi'); + expect(conf.buildDirectory(false, undefined, true)).to.eq('/build/multi'); }); test('buildDirectory object form with only singleConfig falls back for multi-config generator', () => { @@ -189,7 +187,7 @@ suite('Configuration', () => { buildDirectory: { singleConfig: '/build/single' } }); // No multiConfig set, should fall back to singleConfig - expect(conf.buildDirectory(false, undefined, 'Ninja Multi-Config')).to.eq('/build/single'); + expect(conf.buildDirectory(false, undefined, true)).to.eq('/build/single'); }); test('buildDirectory object form with only multiConfig falls back for single-config generator', () => { @@ -197,24 +195,25 @@ suite('Configuration', () => { buildDirectory: { multiConfig: '/build/multi' } }); // No singleConfig set, should fall back to multiConfig - expect(conf.buildDirectory(false, undefined, 'Unix Makefiles')).to.eq('/build/multi'); + expect(conf.buildDirectory(false, undefined, false)).to.eq('/build/multi'); }); test('buildDirectory object form with empty object falls back to default', () => { const conf = createConfig({ buildDirectory: {} }); - expect(conf.buildDirectory(false, undefined, 'Unix Makefiles')).to.eq('${workspaceFolder}/build'); + expect(conf.buildDirectory(false, undefined, false)).to.eq('${workspaceFolder}/build'); }); - test('buildDirectory object form with Visual Studio generator returns multiConfig', () => { + test('buildDirectory object form defaults to singleConfig when isMultiConfig is undefined', () => { const conf = createConfig({ buildDirectory: { singleConfig: '/build/single', multiConfig: '/build/multi' } }); - expect(conf.buildDirectory(false, undefined, 'Visual Studio 17 2022')).to.eq('/build/multi'); + // When isMultiConfig is not provided, defaults to false (single-config) + expect(conf.buildDirectory(false)).to.eq('/build/single'); }); test('buildDirectory multiProject default override still works with plain string', () => { From 4b4e510aca8e48db0b2a7ebdcb8d8a94b275616f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:00:51 +0000 Subject: [PATCH 4/4] fix: correct buildDirectory multiProject test that fails in VS Code test host The test was calling buildDirectory(true) which triggers isDefaultValue() that checks real vscode.workspace.getConfiguration. In CI's VS Code test host, no workspace override exists, so isDefaultValue returns true and the method returns '${sourceDirectory}/build' instead of the custom value. Changed the test to use multiProject=false since the multiProject=true codepath depends on VS Code workspace state that cannot be mocked in the unit test infrastructure. Co-authored-by: hanniavalera <90047725+hanniavalera@users.noreply.github.com> --- test/unit-tests/config.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index 9cc3a6271d..90572146ff 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -216,12 +216,11 @@ suite('Configuration', () => { expect(conf.buildDirectory(false)).to.eq('/build/single'); }); - test('buildDirectory multiProject default override still works with plain string', () => { - // When buildDirectory is default (empty string) and multiProject is true, - // it returns '${sourceDirectory}/build'. But since isDefaultValue checks - // vscode.workspace config (not available in unit tests), this tests that - // a non-default string value is returned as-is regardless of multiProject. + test('buildDirectory plain string form works with multiProject=false', () => { + // Note: we cannot test multiProject=true with a non-default value here because + // isDefaultValue() checks the real vscode.workspace.getConfiguration (not configData), + // which always reports the default in the test host environment. const conf = createConfig({ buildDirectory: '/custom/build' }); - expect(conf.buildDirectory(true)).to.eq('/custom/build'); + expect(conf.buildDirectory(false)).to.eq('/custom/build'); }); });