diff --git a/CHANGELOG.md b/CHANGELOG.md index f01f78f38..aee1cb88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Features: - 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) - Add `onConfigureResult` event to the CMake Tools API that fires after every configure attempt (success or failure), allowing dependent extensions to detect and react to configure failures. [#4021](https://github.com/microsoft/vscode-cmake-tools/issues/4021) - Add `cmake.preConfigureTask` setting to execute a named VS Code task before every CMake configure. [#2449](https://github.com/microsoft/vscode-cmake-tools/issues/2449) [#4960](https://github.com/microsoft/vscode-cmake-tools/pull/4960) [@erdemiru](https://github.com/erdemiru) +- Add `cmake.environmentSetupScript` setting to source (Linux/macOS) or call (Windows) a script and use its environment as the parent environment when using CMake Presets, so preset macro/`$penv{}` expansion and configure/build/test can use environments that cannot be expressed as static variables. Cross-platform counterpart to the Visual Studio Developer Environment. [#PR](https://github.com/microsoft/vscode-cmake-tools/pull/PR) [@vasdal](https://github.com/vasdal) Improvements: - Add `${testName}` variable support for `cmake.ctestArgs` and `cmake.ctestDefaultArgs`, enabling per-test argument expansion (e.g., unique log file paths per test). [#4416](https://github.com/microsoft/vscode-cmake-tools/issues/4416) diff --git a/docs/cmake-presets.md b/docs/cmake-presets.md index 95bc09757..ca5798295 100644 --- a/docs/cmake-presets.md +++ b/docs/cmake-presets.md @@ -193,6 +193,12 @@ Environment variables set in a Configure Preset also automatically flow to assoc You can reference environment variables by using the `$env{}` and `$penv{}` syntax. For more information, see [Macro Expansion](https://cmake.org/cmake/help/latest/manual/cmake-presets.7.html#macro-expansion). +### Bootstrap the parent environment from a setup script + +Some environments cannot be expressed as static `environment` entries — for example when the required variables are large, interdependent, or produced by a tool's own setup script. For these cases, set `cmake.environmentSetupScript` to a script that CMake Tools sources (Linux/macOS) or calls (Windows) before working with presets. The resulting environment becomes the parent environment, so it is available both when expanding preset macros (including `$penv{}` in `include` paths and toolchain files) and when running configure, build, and test. + +This is the cross-platform counterpart to the Visual Studio Developer Environment, which CMake Tools sources automatically for Visual C++ toolsets (see `cmake.useVsDeveloperEnvironment`). The setting value supports variable substitution such as `${workspaceFolder}`. + ### Select your target and host architecture when building with the Visual C++ toolset The target architecture (x64, Win32, ARM64, or ARM) can be set with `architecture.value`. This is equivalent to passing `-A` to CMake from the command line. For more information, see [Platform Selection](https://cmake.org/cmake/help/latest/generator/Visual%20Studio%2016%202019.html#platform-selection). diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index de05fda00..58d53cfb3 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -58,6 +58,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.languageServerOnlyMode` | If `true`, keep CMake language services enabled while disabling CMake project, build, test, and kit integration. | `false` | no | | `cmake.enableTraceLogging` | If `true`, enable trace logging. | `false` | no | | `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 | +| `cmake.environmentSetupScript` | When using CMake Presets, source (Linux/macOS) or call (Windows) this script and use the resulting environment as the parent environment for preset expansion and for configure, build, and test. Use this to bootstrap environments that cannot be captured as static variables. | `""` (no script) | yes | | `cmake.exclude` | CMake Tools will ignore the folders defined in this setting. | `[]` | yes | | `cmake.exportCompileCommandsFile` | If `true`, generate the compile_commands.json file. | `true` | no | | `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 | diff --git a/package.json b/package.json index 473795b2a..9cdc3ea2d 100644 --- a/package.json +++ b/package.json @@ -4112,6 +4112,12 @@ "description": "%cmake-tools.configuration.cmake.useVsDeveloperEnvironment.description%", "scope": "resource" }, + "cmake.environmentSetupScript": { + "type": "string", + "default": "", + "description": "%cmake-tools.configuration.cmake.environmentSetupScript.description%", + "scope": "resource" + }, "cmake.allowCommentsInPresetsFile": { "type": "boolean", "default": false, diff --git a/package.nls.json b/package.nls.json index b0b8d2c26..17ac9453d 100644 --- a/package.nls.json +++ b/package.nls.json @@ -369,6 +369,7 @@ "cmake-tools.configuration.cmake.exportCompileCommandsFile.description": "Enables exporting compile_commands.json. This only is used in Kits scenarios. In Presets scenarios, please set this by using CMakePresets.json", "cmake-tools.configuration.cmake.useCMakePresets.description": "Use CMakePresets.json to configure drive CMake configure, build, and test. When using CMakePresets.json, kits, variants, and some settings in settings.json will be ignored.", "cmake-tools.configuration.cmake.useVsDeveloperEnvironment.description": "When using CMake Presets on Windows, use the Visual Studio environment as the parent environment. Selecting auto will only apply the Visual Studio environment when we detect a supported compiler (cl, clang, clang-cl, clang-cpp, clang++), or the Ninja generator is being used.", + "cmake-tools.configuration.cmake.environmentSetupScript.description": "When using CMake Presets, source (Linux/macOS) or call (Windows) this script and use the resulting environment as the parent environment for preset expansion and for configure, build, and test. Use this to bootstrap environments that cannot be captured as static variables. Supports variable substitution (e.g. ${workspaceFolder}).", "cmake-tools.configuration.cmake.allowCommentsInPresetsFile.description": "Allow the use of JSON extensions such as comments in CMakePresets.json. Please note that your CMakePresets.json file may be considered invalid by other IDEs or on the command line if you use non-standard JSON.", "cmake-tools.configuration.cmake.allowUnsupportedPresetsVersions.description": "Enables the use of presets files that are using features from the versions that CMake Tools extension doesn't currently support. Unknown properties and macros will be ignored.", "cmake-tools.configuration.cmake.ignoreCMakeListsMissing.description": { diff --git a/src/config.ts b/src/config.ts index fccd7e421..e6d616927 100644 --- a/src/config.ts +++ b/src/config.ts @@ -245,6 +245,7 @@ export interface ExtensionConfigurationSettings { options: OptionConfig; useCMakePresets: UseCMakePresets; useVsDeveloperEnvironment: UseVsDeveloperEnvironment; + environmentSetupScript: string; allowCommentsInPresetsFile: boolean; allowUnsupportedPresetsVersions: boolean; launchBehavior: string; @@ -529,6 +530,9 @@ export class ConfigurationReader implements vscode.Disposable { get useVsDeveloperEnvironment(): UseVsDeveloperEnvironment { return this.configData.useVsDeveloperEnvironment; } + get environmentSetupScript(): string { + return this.configData.environmentSetupScript; + } get allowCommentsInPresetsFile(): boolean { return this.configData.allowCommentsInPresetsFile; } @@ -768,6 +772,7 @@ export class ConfigurationReader implements vscode.Disposable { options: new vscode.EventEmitter(), useCMakePresets: new vscode.EventEmitter(), useVsDeveloperEnvironment: new vscode.EventEmitter(), + environmentSetupScript: new vscode.EventEmitter(), allowCommentsInPresetsFile: new vscode.EventEmitter(), allowUnsupportedPresetsVersions: new vscode.EventEmitter(), ignoreCMakeListsMissing: new vscode.EventEmitter(), diff --git a/src/kits/kit.ts b/src/kits/kit.ts index 0f16b11e0..f64851f14 100644 --- a/src/kits/kit.ts +++ b/src/kits/kit.ts @@ -727,6 +727,16 @@ export const MSVC_HOST_ARCHES: MsvcHostArches[] = ['x86', 'x64']; */ export async function getShellScriptEnvironment(kit: Kit, opts?: expand.ExpansionOptions): Promise { console.assert(kit.environmentSetupScript); + return getEnvironmentFromSetupScript(kit.environmentSetupScript!, opts); +} + +/** + * Gets the environment variables produced by sourcing (Linux/macOS) or calling (Windows) a setup script. + * Shared by kits (`environmentSetupScript`) and, in preset mode, by the `cmake.environmentSetupScript` setting. + * @param setupScript Path to the setup script (optionally followed by args, in which case it must be quoted). + * @param opts Expansion options applied to the script string before it is run. + */ +export async function getEnvironmentFromSetupScript(setupScript: string, opts?: expand.ExpansionOptions): Promise { const filename = Math.random().toString() + (process.platform === 'win32' ? '.bat' : '.sh'); const script_filename = `vs-cmt-${filename}`; const environment_filename = script_filename + '.env'; @@ -752,7 +762,7 @@ export async function getShellScriptEnvironment(kit: Kit, opts?: expand.Expansio let script = ''; let run_command = ''; - let environmentSetupScript = kit.environmentSetupScript!.trim(); + let environmentSetupScript = setupScript.trim(); if (opts) { environmentSetupScript = await expand.expandString(environmentSetupScript, opts); } @@ -786,7 +796,7 @@ export async function getShellScriptEnvironment(kit: Kit, opts?: expand.Expansio const output = (res.stdout) ? res.stdout + (res.stderr || '') : res.stderr; if (res.retc !== 0) { - log.error(localize('error.running.setup.script', 'Error running {0} with: {1}', kit.environmentSetupScript, output)); + log.error(localize('error.running.setup.script', 'Error running {0} with: {1}', setupScript, output)); return; } @@ -799,7 +809,7 @@ export async function getShellScriptEnvironment(kit: Kit, opts?: expand.Expansio log.error(error as Error); } if (!env || env === '') { - console.log(`Error running ${kit.environmentSetupScript} with:`, output); + console.log(`Error running ${setupScript} with:`, output); return; } @@ -814,7 +824,7 @@ export async function getShellScriptEnvironment(kit: Kit, opts?: expand.Expansio } return acc; }, EnvironmentUtils.create()); - log.debug(localize('ok.running', 'OK running {0}, env vars: {1}', kit.environmentSetupScript, JSON.stringify(vars))); + log.debug(localize('ok.running', 'OK running {0}, env vars: {1}', setupScript, JSON.stringify(vars))); return vars; } diff --git a/src/presets/preset.ts b/src/presets/preset.ts index cb518d3fa..6db313797 100644 --- a/src/presets/preset.ts +++ b/src/presets/preset.ts @@ -11,8 +11,10 @@ import { execute } from '@cmt/proc'; import { errorHandlerHelper, expandString, ExpansionErrorHandler, ExpansionOptions } from '@cmt/expand'; import paths from '@cmt/paths'; import { compareVersions, VSInstallation, vsInstallations, enumerateMsvcToolsets, varsForVSInstallation, getVcVarsBatScript } from '@cmt/installs/visualStudio'; -import { EnvironmentUtils, EnvironmentWithNull } from '@cmt/environmentVariables'; +import { Environment, EnvironmentUtils, EnvironmentWithNull } from '@cmt/environmentVariables'; import { UseVsDeveloperEnvironment } from '@cmt/config'; +import { getEnvironmentFromSetupScript } from '@cmt/kits/kit'; +import { fs } from '@cmt/pr'; nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); const localize: nls.LocalizeFunc = nls.loadMessageBundle(); @@ -681,8 +683,8 @@ async function getVendorForConfigurePresetHelper(folder: string, preset: Configu return preset.vendor || null; } -async function getExpansionOptions(workspaceFolder: string, sourceDir: string, preset: ConfigurePreset | BuildPreset | TestPreset | PackagePreset, envOverride?: EnvironmentWithNull, penvOverride?: EnvironmentWithNull, includeGenerator: boolean = true) { - const generator = includeGenerator ? 'generator' in preset +async function getExpansionOptions(workspaceFolder: string, sourceDir: string, preset?: ConfigurePreset | BuildPreset | TestPreset | PackagePreset, envOverride?: EnvironmentWithNull, penvOverride?: EnvironmentWithNull, includeGenerator: boolean = true) { + const generator = includeGenerator && preset ? 'generator' in preset ? preset.generator : ('__generator' in preset ? preset.__generator : undefined) : undefined; @@ -698,9 +700,9 @@ async function getExpansionOptions(workspaceFolder: string, sourceDir: string, p sourceDir, sourceParentDir: path.dirname(sourceDir), sourceDirName: path.basename(sourceDir), - presetName: preset.name + presetName: preset?.name ?? '' }, - envOverride: envOverride ?? preset.environment, + envOverride: envOverride ?? preset?.environment, penvOverride: penvOverride, recursive: true, // Don't support commands since expansion might be called on activation. If there is @@ -709,13 +711,13 @@ async function getExpansionOptions(workspaceFolder: string, sourceDir: string, p doNotSupportCommands: true }; - if (preset.__file && preset.__file.version >= 3) { + if (preset?.__file && preset.__file.version >= 3) { expansionOpts.vars.hostSystemName = await util.getHostSystemNameMemo(); } - if (preset.__file && preset.__file.version >= 4) { + if (preset?.__file && preset.__file.version >= 4) { expansionOpts.vars.fileDir = path.dirname(preset.__file!.__path!); } - if (preset.__file && preset.__file.version >= 5) { + if (preset?.__file && preset.__file.version >= 5) { expansionOpts.vars.pathListSep = path.delimiter; } @@ -990,10 +992,64 @@ export function getVsDevEnvAutoDetectionInfo(preset: ConfigurePreset): VsDevEnvA * @param sourceDir The source dir of the CMake project. * @returns Void. We don't return as we are modifying the preset by reference. */ +// Cache of environments produced by `cmake.environmentSetupScript`, keyed by the +// expanded script string plus its file mtime, so the script is not re-sourced on +// every configure/build/test. A changed setting yields a different key; a rebuilt +// setup file changes the mtime and thereby invalidates the entry. +const setupScriptEnvCache = new Map(); + +/** + * Sources (Linux/macOS) or calls (Windows) the `cmake.environmentSetupScript`, if configured, + * and returns the resulting environment. Cross-platform counterpart to the VS Dev Env: it lets + * users bootstrap a parent environment that cannot be expressed as static variables. + * + * This is used in two places so a single script bootstraps everything (they share the cache): + * - preset include/`$penv{}` resolution (via the presets controller's settings environment), and + * - per-preset expansion + the configure/build/test spawn environment (via `tryApplyVsDevEnv`). + * + * @returns The environment produced by the script, or undefined if the setting is empty or the script fails. + */ +export async function getEnvironmentSetupScriptEnv(workspaceFolder: string, sourceDir: string, preset?: ConfigurePreset): Promise { + const setupScript = vscode.workspace.getConfiguration("cmake", vscode.Uri.file(workspaceFolder)).get("environmentSetupScript"); + if (!setupScript) { + return undefined; + } + + // `preset` is passed when expanding for a specific preset (so ${presetName}/generator resolve), + // and omitted for the file-level include resolution, where no preset has been selected yet. + const opts = await getExpansionOptions(workspaceFolder, sourceDir, preset); + const expandedScript = await expandString(setupScript, opts); + + // Best-effort mtime so a rebuilt setup file invalidates the cache. If the value is not a bare + // path (e.g. it carries arguments or surrounding quotes) we simply cache by the string alone. + let cacheKey = expandedScript; + try { + const stat = await fs.stat(expandedScript); + cacheKey = `${expandedScript}\0${stat.mtimeMs}`; + } catch { + // Not a plain, existing file path; fall back to string-only key. + } + + const cached = setupScriptEnvCache.get(cacheKey); + if (cached) { + return cached; + } + + const env = await getEnvironmentFromSetupScript(expandedScript, opts); + if (env) { + setupScriptEnvCache.set(cacheKey, env); + } + return env; +} + export async function tryApplyVsDevEnv(preset: ConfigurePreset, workspaceFolder: string, sourceDir: string): Promise { + // The environment-setup-script environment is applied regardless of the VS Dev Env mode (including + // "never"), since it is the cross-platform mechanism for bootstrapping a parent environment. + const setupScriptEnvironment = await getEnvironmentSetupScriptEnv(workspaceFolder, sourceDir, preset); + const useVsDeveloperEnvironmentMode = vscode.workspace.getConfiguration("cmake", vscode.Uri.file(workspaceFolder)).get("useVsDeveloperEnvironment") as UseVsDeveloperEnvironment; if (useVsDeveloperEnvironmentMode === "never") { - preset.__parentEnvironment = process.env; + preset.__parentEnvironment = EnvironmentUtils.mergePreserveNull([process.env, setupScriptEnvironment]); return; } @@ -1075,7 +1131,7 @@ export async function tryApplyVsDevEnv(preset: ConfigurePreset, workspaceFolder: preset.__developerEnvironmentArchitecture = getArchitecture(preset); } - preset.__parentEnvironment = EnvironmentUtils.mergePreserveNull([process.env, preset.__parentEnvironment, developerEnvironment]); + preset.__parentEnvironment = EnvironmentUtils.mergePreserveNull([process.env, preset.__parentEnvironment, developerEnvironment, setupScriptEnvironment]); } /** diff --git a/src/presets/presetsController.ts b/src/presets/presetsController.ts index 249e7f5b8..fdbe343d1 100644 --- a/src/presets/presetsController.ts +++ b/src/presets/presetsController.ts @@ -70,9 +70,9 @@ export class PresetsController implements vscode.Disposable { ); }, presetsController._presetsChangedEmitter.fire, presetsController._userPresetsChangedEmitter.fire); - // Pass cmake.environment and cmake.configureEnvironment settings so that $penv{} in preset - // include paths can resolve variables defined in VS Code settings. - presetsController.updateSettingsEnvironment(); + // Pass the setup-script env plus cmake.environment and cmake.configureEnvironment settings + // so that $penv{} in preset include paths can resolve variables from those sources. + await presetsController.updateSettingsEnvironment(); // We explicitly read presets file here, instead of on the initialization of the file watcher. Otherwise // there might be timing issues, since listeners are invoked async. @@ -104,11 +104,15 @@ export class PresetsController implements vscode.Disposable { // We need to reapply presets when environment settings change so that $penv{} expansions // in include paths are re-evaluated with the updated environment variables. project.workspaceContext.config.onChange('environment', async () => { - presetsController.updateSettingsEnvironment(); + await presetsController.updateSettingsEnvironment(); await presetsController.reapplyPresets(); }); project.workspaceContext.config.onChange('configureEnvironment', async () => { - presetsController.updateSettingsEnvironment(); + await presetsController.updateSettingsEnvironment(); + await presetsController.reapplyPresets(); + }); + project.workspaceContext.config.onChange('environmentSetupScript', async () => { + await presetsController.updateSettingsEnvironment(); await presetsController.reapplyPresets(); }); @@ -118,14 +122,17 @@ export class PresetsController implements vscode.Disposable { private constructor(private readonly project: CMakeProject, private readonly _kitsController: KitsController, private isMultiProject: boolean) {} /** - * Merges cmake.environment and cmake.configureEnvironment settings into a single - * environment object and passes it to the PresetsParser for $penv{} expansion. - * cmake.configureEnvironment takes precedence over cmake.environment. + * Merges the `cmake.environmentSetupScript` environment, cmake.environment, and + * cmake.configureEnvironment settings into a single environment object and passes it to the + * PresetsParser for $penv{} expansion in preset include paths. Explicit settings take + * precedence over the setup-script environment; cmake.configureEnvironment takes precedence + * over cmake.environment. */ - private updateSettingsEnvironment(): void { + private async updateSettingsEnvironment(): Promise { + const setupScriptEnv = await preset.getEnvironmentSetupScriptEnv(this.project.workspaceFolder.uri.fsPath, this.project.sourceDir) ?? {}; const env = this.project.workspaceContext.config.environment; const configureEnv = this.project.workspaceContext.config.configureEnvironment; - this._presetsParser.settingsEnvironment = EnvironmentUtils.merge([env, configureEnv]); + this._presetsParser.settingsEnvironment = EnvironmentUtils.merge([setupScriptEnv, env, configureEnv]); } get presetsPath() { diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index 6e0d28bef..3f252026f 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -78,6 +78,7 @@ function createConfig(conf: Partial): Configurat }, useCMakePresets: 'never', useVsDeveloperEnvironment: 'auto', + environmentSetupScript: '', allowCommentsInPresetsFile: false, allowUnsupportedPresetsVersions: false, launchBehavior: 'reuseTerminal',