diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fe12eec..adafbfcc4 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 experimental, opt-in `cmake.colorizedBuildOutput` setting to highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated CMake Build integrated terminal where error locations are clickable. A `rich` mode additionally adds accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer. A `compiler` mode forwards the compiler's and build tools' own colors by setting `CMAKE_COLOR_DIAGNOSTICS=ON` at configure time and `CLICOLOR_FORCE=1` at build time. The colorization is portable: it is rendered in the terminal today and is automatically routed to the Output panel if a future VS Code renders ANSI there. The CMake Build terminal opens promptly when you start a build (even when a configure runs first) and honors `cmake.revealLogOnAutomaticTrigger`, staying quiet for automatic and programmatic builds (e.g. configure-on-open and builds invoked through the CMake Tools API by Copilot via the C/C++ DevTools companion) while always surfacing failures. [#478](https://github.com/microsoft/vscode-cmake-tools/issues/478) Improvements: - Reduce CI pipeline time by parallelizing E2E test jobs and adding build artifact caching. diff --git a/docs/cmake-settings.md b/docs/cmake-settings.md index 2c4fcd6da..8b38fc376 100644 --- a/docs/cmake-settings.md +++ b/docs/cmake-settings.md @@ -18,12 +18,14 @@ Options that support substitution, in the table below, allow variable references | `cmake.buildBeforeRun` | If `true`, build the launch/debug target before running the target. | `true` | no | | `cmake.buildDirectory` | Specify the build directory (i.e. the root directory where `CMakeCache.txt` will be generated.) | `${workspaceFolder}/build` | yes | | `cmake.buildEnvironment`| An object containing `key:value` pairs of environment variables, which will be passed only to the compiler. | `null` (no environment variables specified) | yes | +| `cmake.buildOutputGlyphs` | Glyph style for `cmake.colorizedBuildOutput: rich`. Use `ascii` if your terminal font does not render the Unicode severity glyphs (✗ ⚠ ℹ ✓). One of `unicode`, `ascii`. | `unicode` | no | | `cmake.buildTask` | If `true`, generate VS Code tasks for building. | `false` | no | | `cmake.buildToolArgs` | An array of additional arguments to pass to the underlying build tool. | `[]` (empty array-no additional arguments) | yes | | `cmake.cacheInit` | Path, or list of paths, to cache-initialization files. Passed to CMake via the `-C` command-line argument. | `[]` (empty array-no cache initializer files) | no | | `cmake.clearOutputBeforeBuild` | If `true`, clear output before building. | `true` | no | | `cmake.cmakeCommunicationMode` | Specifies the protocol for communicating between the extension and CMake | `automatic` | no | | `cmake.cmakePath`| Specify location of the cmake executable. | `cmake` (causes CMake Tools to search the `PATH` environment variable, as well as some hard-coded locations.) | Supports substitution for `workspaceRoot`, `workspaceFolder`, `workspaceRootFolderName`, `userHome`, `${command:...}` and `${env:...}`. Other substitutions result in an empty string. | +| `cmake.colorizedBuildOutput` | Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors, shown in a dedicated **CMake Build** integrated terminal (the VS Code Output panel cannot render ANSI colors). `rich` additionally adds severity glyphs, dimmed progress, a build header, and a colored summary footer. `compiler` forwards the compiler's and build tools' own colors via `CMAKE_COLOR_DIAGNOSTICS=ON` (configure time, requires a reconfigure) and `CLICOLOR_FORCE=1` (build time). One of `off`, `severity`, `rich`, `compiler`. | `off` | no | | `cmake.configureArgs` | Arguments to CMake that will be passed during the configure process. Prefer to use `cmake.configureSettings` or [CMake variants](variants.md).
It is not recommended to pass `-D` arguments using this setting. | `[]` (empty array-no arguments) | yes | | `cmake.configureEnvironment` | An object containing `key:value` pairs of environment variables, which will be passed to CMake only when configuring.| `null` (no environment variable pairs) | yes | | `cmake.configureOnEdit` | Automatically configure CMake project directories when the path in the `cmake.sourceDirectory` setting is updated or when `CMakeLists.txt` or `*.cmake` files are saved. | `true` | no | @@ -90,6 +92,7 @@ Options that support substitution, in the table below, allow variable references | `cmake.preferredGenerators` | A list of strings of generator names to try, in order, when configuring a CMake project for the first time. | `[]` | no | | `cmake.preRunCoverageTarget` | Target to build before running tests with coverage using the test explorer. | `null` | no | | `cmake.revealLog` | Controls when the CMake output log should be revealed. Possible values: `focus` (show the log and move focus to the output channel), `always` (show the log but do not move focus), `never` (do not show the log), `error` (show the log only when an error occurs). | `always` | no | +| `cmake.revealLogOnAutomaticTrigger` | Whether automatic or programmatic CMake operations (configure-on-open, automatic reconfigure, and builds or tests invoked through the CMake Tools API, e.g. by Copilot via the C/C++ DevTools companion) reveal the CMake build output. Failures are always shown. | `false` | no | | `cmake.saveBeforeBuild` | If `true` (the default), saves open text documents when build or configure is invoked before running CMake. | `true` | no | | `cmake.setBuildTargetSameAsLaunchTarget` | If `true`, setting the launch/debug target automatically sets the build target to match. | `false` | no | | `cmake.setBuildTypeOnMultiConfig` | If `true`, set build type on multi-config generators. | `false` | no | diff --git a/package.json b/package.json index acd0d2310..cd7738cb5 100644 --- a/package.json +++ b/package.json @@ -2425,6 +2425,38 @@ "description": "%cmake-tools.configuration.cmake.clearOutputBeforeBuild.description%", "scope": "resource" }, + "cmake.colorizedBuildOutput": { + "type": "string", + "enum": [ + "off", + "severity", + "rich", + "compiler" + ], + "enumDescriptions": [ + "%cmake-tools.configuration.cmake.colorizedBuildOutput.off.description%", + "%cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description%", + "%cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description%", + "%cmake-tools.configuration.cmake.colorizedBuildOutput.compiler.description%" + ], + "default": "off", + "markdownDescription": "%cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription%", + "scope": "resource" + }, + "cmake.buildOutputGlyphs": { + "type": "string", + "enum": [ + "unicode", + "ascii" + ], + "enumDescriptions": [ + "%cmake-tools.configuration.cmake.buildOutputGlyphs.unicode.description%", + "%cmake-tools.configuration.cmake.buildOutputGlyphs.ascii.description%" + ], + "default": "unicode", + "markdownDescription": "%cmake-tools.configuration.cmake.buildOutputGlyphs.markdownDescription%", + "scope": "window" + }, "cmake.configureSettings": { "type": "object", "default": {}, @@ -4072,6 +4104,12 @@ ], "description": "%cmake-tools.configuration.cmake.revealLog.description%" }, + "cmake.revealLogOnAutomaticTrigger": { + "type": "boolean", + "default": false, + "markdownDescription": "%cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription%", + "scope": "window" + }, "cmake.exportCompileCommandsFile": { "type": "boolean", "default": true, diff --git a/package.nls.json b/package.nls.json index f10e95c73..65147ef07 100644 --- a/package.nls.json +++ b/package.nls.json @@ -120,6 +120,14 @@ "cmake-tools.configuration.cmake.saveBeforeBuild.description": "Save open files before building.", "cmake-tools.configuration.cmake.buildBeforeRun.description": "Build the target before running it.", "cmake-tools.configuration.cmake.clearOutputBeforeBuild.description": "Clear build output before each build.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.markdownDescription": "Experimental, opt-in: highlight build errors, warnings, and notes by severity using theme-aware ANSI colors. Because the VS Code Output panel cannot render ANSI colors, colorized output is shown in a dedicated **CMake Build** integrated terminal (the regular CMake/Build Output channel is unchanged).", + "cmake-tools.configuration.cmake.colorizedBuildOutput.off.description": "Do not colorize build output.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.severity.description": "Highlight build errors, warnings, and notes by severity, shown in a dedicated CMake Build integrated terminal.", + "cmake-tools.configuration.cmake.colorizedBuildOutput.rich.description": "Everything in 'severity', plus accessible severity glyphs, dimmed build-progress lines, a build header, and a colored build-summary footer (error/warning counts and elapsed time).", + "cmake-tools.configuration.cmake.colorizedBuildOutput.compiler.description": "Show the compiler's and build tools' own colors by setting CMAKE_COLOR_DIAGNOSTICS=ON at configure time and CLICOLOR_FORCE=1 at build time. Requires a reconfigure to take effect. Best with GCC/Clang and Ninja/Make; MSVC (cl.exe) emits no colors, so prefer 'severity' or 'rich' for MSVC.", + "cmake-tools.configuration.cmake.buildOutputGlyphs.markdownDescription": "Glyph style used by `cmake.colorizedBuildOutput: rich`. Use `ascii` if your terminal font does not render the Unicode severity glyphs (✗ ⚠ ℹ ✓).", + "cmake-tools.configuration.cmake.buildOutputGlyphs.unicode.description": "Use Unicode severity glyphs (✗ ⚠ ℹ ✓).", + "cmake-tools.configuration.cmake.buildOutputGlyphs.ascii.description": "Use ASCII severity markers (x ! i +) for fonts that do not render the Unicode glyphs.", "cmake-tools.configuration.cmake.configureSettings.description": "CMake variables to set on the command line. This setting is specific to kits and will not be used for CMake Presets.", "cmake-tools.configuration.cmake.cacheInit.string.description": "Path to a cache-initializing CMake file.", "cmake-tools.configuration.cmake.cacheInit.array.description": "List of cache initializer files.", @@ -364,6 +372,7 @@ "cmake-tools.configuration.cmake.revealLog.always.description": "The log appears but the output channel doesn't take the cursor focus.", "cmake-tools.configuration.cmake.revealLog.never.description": "The log neither appears nor takes the focus.", "cmake-tools.configuration.cmake.revealLog.onError.description": "The log appears only when the build or the configuration fails.", + "cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription": "When `true`, automatic and programmatic CMake operations - such as configure-on-open, automatic reconfigure, and builds or tests invoked by other extensions through the CMake Tools API (for example the C/C++ DevTools companion acting on behalf of Copilot) - reveal the CMake build output. When `false` (the default), these operations run quietly so they don't switch the panel away from a terminal you are using. The `#cmake.revealLog#` setting still controls how operations you start yourself reveal the log. Build, test, and configure failures are always shown, regardless of this setting.", "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.", diff --git a/src/api.ts b/src/api.ts index 3048f6986..5c5b34d01 100644 --- a/src/api.ts +++ b/src/api.ts @@ -152,17 +152,17 @@ class CMakeProjectWrapper implements api.Project { build(targets?: string[]): Promise { logApiTelemetry('build'); - return withErrorCheck('build', () => this.project.build(targets)); + return withErrorCheck('build', () => this.project.build(targets, undefined, undefined, undefined, true)); } async buildWithResult(targets?: string[], cancellationToken?: vscode.CancellationToken): Promise { logApiTelemetry('buildWithResult'); - return this.project.build(targets, undefined, undefined, cancellationToken); + return this.project.build(targets, undefined, undefined, cancellationToken, true); } async ctestWithResult(tests?: string[], cancellationToken?: vscode.CancellationToken): Promise { logApiTelemetry('ctestWithResult'); - return this.project.ctest(undefined, new CTestOutputLogger(), tests, cancellationToken); + return this.project.ctest(undefined, new CTestOutputLogger(), tests, cancellationToken, true); } install(): Promise { diff --git a/src/buildOutputTerminal.ts b/src/buildOutputTerminal.ts new file mode 100644 index 000000000..57374bc62 --- /dev/null +++ b/src/buildOutputTerminal.ts @@ -0,0 +1,273 @@ +/** + * Surfaces for rendering colorized build output. + * + * The VS Code Output panel is a Monaco text editor and does NOT render ANSI + * escape codes (it shows them as literal text), so today colorized build output + * is rendered in an integrated terminal ({@link BuildOutputTerminal}), backed by + * xterm.js, which does render ANSI. The colorization itself (in `@cmt/colorize`) + * produces surface-agnostic ANSI strings, so it is portable: if a future VS Code + * renders ANSI in the Output panel, {@link OutputChannelBuildSink} routes the + * SAME output to an Output channel with no other change — selected by the single + * {@link canRenderAnsiInOutput} capability gate. + * + * In both cases the per-line build output is still written to the on-disk log + * file (for diagnostics) and never duplicated into the regular CMake/Build + * Output channel. + */ + +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +import * as util from '@cmt/util'; +import { BuildColorMode, BuildOutcome, ColorizedBuildSink, GlyphStyle, canRenderAnsiInOutput, decorateBuildLine, linkifyLeadingPath, renderBuildBanner, renderBuildSummary, selectSink } from '@cmt/colorize'; + +nls.config({ messageFormat: nls.MessageFormat.bundle, bundleFormat: nls.BundleFormat.standalone })(); +const localize: nls.LocalizeFunc = nls.loadMessageBundle(); + +// Terminals require CRLF line endings; a bare '\n' causes staircasing. +const EOL = '\r\n'; + +/** Resolve a relative diagnostic path to an absolute one (if it exists), cached. */ +function resolveExistingPath(rel: string, baseDirs: string[], cache: Map): string | undefined { + if (cache.has(rel)) { + return cache.get(rel); + } + let abs: string | undefined; + for (const base of baseDirs) { + const candidate = util.resolvePath(rel, base); + if (util.checkFileExistsSync(candidate)) { + abs = candidate; + break; + } + } + cache.set(rel, abs); + return abs; +} + +/** Linkify (absolutize a relative leading path) then decorate a build-output line. */ +function decoratedLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle, baseDirs: string[], cache: Map): string { + const linked = baseDirs.length > 0 ? linkifyLeadingPath(line, rel => resolveExistingPath(rel, baseDirs, cache)) : line; + return decorateBuildLine(linked, mode, glyphs); +} + +/** Build the localized status text for the build-summary footer (rich mode). */ +function summaryStatusText(outcome: BuildOutcome, counts: { errors: number; warnings: number }, buildStart: number, glyphs: GlyphStyle): string { + const seconds = ((buildStart ? Date.now() - buildStart : 0) / 1000).toFixed(1); + const word = outcome === 'succeeded' + ? localize('build.colorized.succeeded', 'Build succeeded') + : outcome === 'cancelled' + ? localize('build.colorized.cancelled', 'Build cancelled') + : localize('build.colorized.failed', 'Build failed'); + const dash = glyphs === 'unicode' ? '—' : '-'; + const countsText = localize('build.colorized.counts', '{0} error(s), {1} warning(s)', counts.errors, counts.warnings); + return `${word} ${dash} ${countsText} (${seconds}s)`; +} + +const CHANNEL_NAME = localize('cmake.build.colorized.terminal.name', 'CMake Build'); + +class BuildOutputTerminal implements vscode.Pseudoterminal, ColorizedBuildSink { + private readonly writeEmitter = new vscode.EventEmitter(); + private readonly closeEmitter = new vscode.EventEmitter(); + readonly onDidWrite: vscode.Event = this.writeEmitter.event; + readonly onDidClose: vscode.Event = this.closeEmitter.event; + + private terminal?: vscode.Terminal; + private isOpen = false; + private pending: string[] = []; + private buildStart = 0; + // Base directories used to absolutize relative diagnostic paths so VS Code's + // built-in terminal link detection can make them clickable. Cached per build. + private baseDirs: string[] = []; + private readonly linkCache = new Map(); + + /** Reveal the build terminal; `focus` takes keyboard focus. Returns whether a + * terminal was actually revealed (`false` if it was closed/never created). */ + reveal(focus: boolean): boolean { + if (!this.terminal) { + return false; + } + this.terminal.show(!focus); + return true; + } + + // vscode.Pseudoterminal: called when the terminal is first shown. + open(): void { + this.isOpen = true; + if (this.pending.length > 0) { + this.writeEmitter.fire(this.pending.join('')); + this.pending = []; + } + } + + // vscode.Pseudoterminal: called when the user closes the terminal. + close(): void { + this.isOpen = false; + this.terminal = undefined; + this.pending = []; + } + + private ensureTerminal(): void { + if (!this.terminal) { + this.isOpen = false; + this.pending = []; + this.terminal = vscode.window.createTerminal({ name: CHANNEL_NAME, pty: this }); + } + } + + private emit(text: string): void { + if (this.isOpen) { + this.writeEmitter.fire(text); + } else { + this.pending.push(text); + } + } + + /** + * Open the terminal (creating it if needed) before the pre-build configure runs, so the + * "CMake Build" terminal appears immediately when the user starts a build instead of only + * once a (possibly slow) configure completes. Revealing is left to the caller so it can + * honor `cmake.revealLog` / `cmake.revealLogOnAutomaticTrigger`. + */ + prepareForConfigure(clear: boolean): void { + this.ensureTerminal(); + if (clear) { + this.emit('\u001b[2J\u001b[3J\u001b[H'); + } + this.emit(localize('build.colorized.preparing', 'Preparing build…') + EOL); + } + + /** + * Prepare the terminal at the start of a build: create it if needed, clear it + * when requested, record the start time, and optionally print a bold banner. + * Revealing is left to the caller so it can honor `cmake.revealLog`. + */ + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs: string[] = []): void { + this.ensureTerminal(); + this.baseDirs = baseDirs; + this.linkCache.clear(); + if (clear) { + // Clear screen + scrollback + move cursor home. + this.emit('\u001b[2J\u001b[3J\u001b[H'); + } + this.buildStart = Date.now(); + if (bannerTarget) { + const header = localize('build.colorized.building', 'Building: {0}', bannerTarget); + this.emit(renderBuildBanner(header, glyphs) + EOL); + } + } + + /** Write a single build-output line, decorated according to `mode`/`glyphs`. */ + writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void { + if (!this.terminal) { + // The user closed the terminal mid-build; drop output rather than + // recreate it hidden (which would surface stale output next build). + return; + } + this.emit(decoratedLine(line, mode, glyphs, this.baseDirs, this.linkCache) + EOL); + } + + /** Print the ruled, colored, localized build-summary footer (rich mode). */ + writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void { + if (!this.terminal) { + return; + } + const statusText = summaryStatusText(outcome, counts, this.buildStart, glyphs); + for (const line of renderBuildSummary(outcome, statusText, glyphs)) { + this.emit(line + EOL); + } + } + + dispose(): void { + this.writeEmitter.dispose(); + this.closeEmitter.dispose(); + this.terminal?.dispose(); + this.terminal = undefined; + } +} + +/** + * A {@link ColorizedBuildSink} that writes colorized build output to a VS Code + * Output channel. Only renders colors when the Output panel can render ANSI (see + * {@link canRenderAnsiInOutput}); kept ready so the colorization is portable to + * that surface with no other change. + */ +class OutputChannelBuildSink implements ColorizedBuildSink { + private channel?: vscode.OutputChannel; + private buildStart = 0; + private baseDirs: string[] = []; + private readonly linkCache = new Map(); + + private ensureChannel(): vscode.OutputChannel { + if (!this.channel) { + this.channel = vscode.window.createOutputChannel(CHANNEL_NAME); + } + return this.channel; + } + + reveal(focus: boolean): boolean { + if (!this.channel) { + return false; + } + this.channel.show(!focus); + return true; + } + + prepareForConfigure(clear: boolean): void { + const channel = this.ensureChannel(); + if (clear) { + channel.clear(); + } + channel.appendLine(localize('build.colorized.preparing', 'Preparing build…')); + } + + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs: string[] = []): void { + const channel = this.ensureChannel(); + this.baseDirs = baseDirs; + this.linkCache.clear(); + if (clear) { + channel.clear(); + } + this.buildStart = Date.now(); + if (bannerTarget) { + const header = localize('build.colorized.building', 'Building: {0}', bannerTarget); + channel.appendLine(renderBuildBanner(header, glyphs)); + } + } + + writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void { + if (!this.channel) { + return; + } + this.channel.appendLine(decoratedLine(line, mode, glyphs, this.baseDirs, this.linkCache)); + } + + writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void { + if (!this.channel) { + return; + } + const statusText = summaryStatusText(outcome, counts, this.buildStart, glyphs); + for (const line of renderBuildSummary(outcome, statusText, glyphs)) { + this.channel.appendLine(line); + } + } + + dispose(): void { + this.channel?.dispose(); + this.channel = undefined; + } +} + +let instance: ColorizedBuildSink | undefined; + +/** The shared colorized build-output sink (terminal today; Output channel if VS Code renders ANSI). */ +export function colorizedBuildSink(): ColorizedBuildSink { + if (!instance) { + instance = selectSink(canRenderAnsiInOutput(), () => new BuildOutputTerminal(), () => new OutputChannelBuildSink()); + } + return instance; +} + +/** Dispose the shared colorized build-output sink, if any. */ +export function disposeColorizedBuildSink(): void { + instance?.dispose(); + instance = undefined; +} diff --git a/src/cmakeProject.ts b/src/cmakeProject.ts index 7768dc91e..4308a2d3e 100644 --- a/src/cmakeProject.ts +++ b/src/cmakeProject.ts @@ -27,6 +27,7 @@ import { CTestDriver } from '@cmt/ctest'; import { CPackDriver } from '@cmt/cpack'; import { WorkflowDriver } from '@cmt/workflow'; import { CMakeBuildConsumer } from '@cmt/diagnostics/build'; +import { colorizedBuildSink } from '@cmt/buildOutputTerminal'; import { CMakeOutputConsumer } from '@cmt/diagnostics/cmake'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; import { expandStrings, expandString, ExpansionOptions } from '@cmt/expand'; @@ -90,6 +91,7 @@ export enum ConfigureTrigger { compilation = "compilation", launch = "launch", commandEditCacheUI = "commandEditCacheUI", + commandEditCache = "commandEditCache", commandConfigure = "commandConfigure", commandConfigureWithDebugger = "commandConfigureWithDebugger", projectOutlineConfigureWithDebugger = "projectOutlineConfigureWithDebugger", @@ -109,6 +111,32 @@ export enum ConfigureTrigger { selectKit = "selectKit" } +/** + * Classifies a {@link ConfigureTrigger} as automatic/programmatic (vs. an explicit user + * action). Automatic configures (configure-on-open, reconfigure on file change, build-induced + * reconfigure, API-driven configures, etc.) should not proactively reveal the output panel and + * steal it away from a terminal the user is working in, while user-initiated configures + * (command palette, kit/preset selection, Quick Start, launch) keep revealing as before. + */ +export function isAutomaticConfigureTrigger(trigger: ConfigureTrigger): boolean { + switch (trigger) { + case ConfigureTrigger.configureOnOpen: + case ConfigureTrigger.configureWithCache: + case ConfigureTrigger.cmakeListsChange: + case ConfigureTrigger.sourceDirectoryChange: + case ConfigureTrigger.compilation: + case ConfigureTrigger.api: + case ConfigureTrigger.taskProvider: + case ConfigureTrigger.workflow: + case ConfigureTrigger.runTests: + case ConfigureTrigger.package: + case ConfigureTrigger.badHomeDir: + return true; + default: + return false; + } +} + export interface DiagnosticsConfiguration { folder: string; cmakeVersion: string; @@ -1889,7 +1917,7 @@ export class CMakeProject { } if (type !== ConfigureType.ShowCommandOnly) { - log.showChannel(); + log.showChannel(undefined, isAutomaticConfigureTrigger(trigger)); log.info(localize('run.configure', 'Configuring project: {0}', this.folderName), extraArgs); } @@ -2335,9 +2363,11 @@ export class CMakeProject { /** * Implementation of `cmake.build` */ - async runBuild(targets?: string[], showCommandOnly?: boolean, taskConsumer?: proc.OutputConsumer, isBuildCommand?: boolean, cancellationToken?: vscode.CancellationToken): Promise { + async runBuild(targets?: string[], showCommandOnly?: boolean, taskConsumer?: proc.OutputConsumer, isBuildCommand?: boolean, cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { if (!showCommandOnly) { - log.showChannel(); + if (this.workspaceContext.config.colorizedBuildOutput === 'off') { + log.showChannel(undefined, isAutomatic); + } log.info(localize('run.build', 'Building folder: {0}', await this.binaryDir || this.folderName), (targets && targets.length > 0) ? targets.join(', ') : ''); } let drv: CMakeDriver | null; @@ -2358,6 +2388,20 @@ export class CMakeProject { }; } + // Open the colorized "CMake Build" terminal *before* the pre-build configure so it + // appears immediately when the user starts a build, instead of leaving them looking at + // configuration progress while a (possibly slow) configure runs first. Gated on the same + // reveal decision as the build-start reveal, so automatic/programmatic builds stay quiet. + const earlyColorMode = this.workspaceContext.config.colorizedBuildOutput; + if (earlyColorMode !== 'off') { + const earlyReveal = logging.revealLogDecision(undefined, isAutomatic); + if (earlyReveal.show) { + const sink = colorizedBuildSink(); + sink.prepareForConfigure(this.workspaceContext.config.clearOutputBeforeBuild); + sink.reveal(earlyReveal.focus); + } + } + const configResult = await this.ensureConfigured(cancellationToken); if (configResult === null) { throw new Error(localize('unable.to.configure', 'Build failed: Unable to configure the project')); @@ -2397,8 +2441,8 @@ export class CMakeProject { buildLogger.info(localize('starting.build', 'Starting build')); await setContextAndStore(isBuildingKey, true); rc = await drv!.build(newTargets, taskConsumer, isBuildCommand); - if (rc !== 0) { - log.showChannel(true); // in case build has failed + if (rc !== 0 && this.workspaceContext.config.colorizedBuildOutput === 'off') { + log.showChannel(true, isAutomatic); // in case build has failed } await setContextAndStore(isBuildingKey, false); if (rc === null) { @@ -2447,17 +2491,45 @@ export class CMakeProject { const combinedToken = util.createCombinedCancellationToken(cancel, cancellationToken); combinedToken.onCancellationRequested(() => rollbar.invokeAsync(localize('stop.on.cancellation', 'Stop on cancellation'), () => this.stop())); buildLogger.info(localize('starting.build', 'Starting build')); + const buildColorMode = drv!.config.colorizedBuildOutput; + if (buildColorMode !== 'off') { + // The colorized per-line stream is rendered in the "CMake Build" + // terminal (the Output panel cannot render ANSI). Leave a one-line + // pointer in the regular Output channel for users who watch it, without + // revealing the channel (which would defeat the single-surface design). + buildLogger.info(localize('build.colorized.in.terminal', 'Colorized build output is shown in the "CMake Build" terminal; plain output is written to the CMake Tools log file and diagnostics appear in the Problems panel.')); + const banner = buildColorMode === 'rich' ? targetName : undefined; + colorizedBuildSink().prepareForBuild(drv!.config.clearOutputBeforeBuild, drv!.config.buildOutputGlyphs, banner, [drv!.binaryDir, drv!.sourceDir]); + const startReveal = logging.revealLogDecision(undefined, isAutomatic); + if (startReveal.show) { + colorizedBuildSink().reveal(startReveal.focus); + } + } await setContextAndStore(isBuildingKey, true); const rc = await drv!.build(newTargets, consumer, isBuildCommand); await setContextAndStore(isBuildingKey, false); if (rc !== 0) { - log.showChannel(true); // in case build has failed + if (buildColorMode !== 'off') { + // Reveal the colorized build terminal (not the Output channel) + // so a failed build doesn't yank focus away from it. Honor the + // `cmake.revealLog` setting exactly as the channel reveal would. + const reveal = logging.revealLogDecision(true, isAutomatic); + if (reveal.show && !colorizedBuildSink().reveal(reveal.focus)) { + // The terminal was closed mid-build, so the failure has no + // visible surface — fall back to revealing the Output channel. + log.showChannel(true, isAutomatic); + } + } else { + log.showChannel(true, isAutomatic); // in case build has failed + } } if (rc === null) { buildLogger.info(localize('build.was.terminated', 'Build was terminated')); } else { buildLogger.info(localize('build.finished.with.code', 'Build finished with exit code {0}', rc)); } + let buildErrors = 0; + let buildWarnings = 0; if (drv!.config.parseBuildDiagnostics) { const fileDiags = await consumer!.compileConsumer.resolveDiagnostics(drv!.binaryDir, drv!.sourceDir); if (fileDiags.length > 0) { @@ -2466,6 +2538,16 @@ export class CMakeProject { // the incremental diagnostics added during the build. populateCollection(collections.build, fileDiags); } + // Count from the resolved diagnostics — already filtered by + // `enableOutputParsers`, so the rich-mode summary footer matches + // the Problems panel exactly. + for (const fileDiag of fileDiags) { + if (fileDiag.diag.severity === vscode.DiagnosticSeverity.Error) { + buildErrors++; + } else if (fileDiag.diag.severity === vscode.DiagnosticSeverity.Warning) { + buildWarnings++; + } + } // When empty: either the build succeeded (collection was // already cleared at build start), or the build ran through // the task path and diagnostics were populated by @@ -2476,6 +2558,10 @@ export class CMakeProject { // remain from a previous build that had parsing enabled. collections.build.clear(); } + if (buildColorMode === 'rich') { + const outcome = rc === null ? 'cancelled' : (rc === 0 && buildErrors === 0 ? 'succeeded' : 'failed'); + colorizedBuildSink().writeSummary(outcome, { errors: buildErrors, warnings: buildWarnings }, drv!.config.buildOutputGlyphs); + } await this.cTestController.refreshTests(drv!); await this.refreshCompileDatabase(drv!.expansionOptions); return { @@ -2498,8 +2584,8 @@ export class CMakeProject { /** * Implementation of `cmake.build` */ - async build(targets?: string[], showCommandOnly?: boolean, isBuildCommand: boolean = true, cancellationToken?: vscode.CancellationToken): Promise { - this.activeBuild = this.runBuild(targets, showCommandOnly, undefined, isBuildCommand, cancellationToken); + async build(targets?: string[], showCommandOnly?: boolean, isBuildCommand: boolean = true, cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { + this.activeBuild = this.runBuild(targets, showCommandOnly, undefined, isBuildCommand, cancellationToken, isAutomatic); return this.activeBuild; } @@ -2541,7 +2627,7 @@ export class CMakeProject { localize('project.not.yet.configured', 'This project has not yet been configured'), localize('configure.now.button', 'Configure Now'))); if (doConfigure) { - if ((await this.configureInternal()).exitCode !== 0) { + if ((await this.configureInternal(ConfigureTrigger.commandEditCache)).exitCode !== 0) { return; } } else { @@ -2729,11 +2815,11 @@ export class CMakeProject { return this.cTestController.runCTest(driver, true, testPreset, consumer); } - private async preTest(fromWorkflow: boolean = false): Promise { + private async preTest(fromWorkflow: boolean = false, isAutomatic: boolean = false): Promise { if (extensionManager !== undefined && extensionManager !== null && !fromWorkflow) { extensionManager.cleanOutputChannel(); } - const buildResult = await this.build(undefined, false, false); + const buildResult = await this.build(undefined, false, false, undefined, isAutomatic); if (buildResult.exitCode !== 0) { throw new Error(localize('build.failed', 'Build failed.')); } @@ -2745,9 +2831,9 @@ export class CMakeProject { return drv; } - async ctest(fromWorkflow: boolean = false, commandConsumer?: proc.CommandConsumer, testsToRun?: string[], cancellationToken?: vscode.CancellationToken): Promise { - const drv = await this.preTest(fromWorkflow); - const retc = await this.cTestController.runCTest(drv, undefined, undefined, commandConsumer, testsToRun, cancellationToken); + async ctest(fromWorkflow: boolean = false, commandConsumer?: proc.CommandConsumer, testsToRun?: string[], cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { + const drv = await this.preTest(fromWorkflow, isAutomatic); + const retc = await this.cTestController.runCTest(drv, undefined, undefined, commandConsumer, testsToRun, cancellationToken, isAutomatic); return retc; } diff --git a/src/cmakeTaskProvider.ts b/src/cmakeTaskProvider.ts index fb82028a6..be7476652 100644 --- a/src/cmakeTaskProvider.ts +++ b/src/cmakeTaskProvider.ts @@ -17,6 +17,7 @@ import * as util from '@cmt/util'; import * as expand from '@cmt/expand'; import { CommandResult } from 'vscode-cmake-tools'; import { CompileOutputConsumer } from '@cmt/diagnostics/build'; +import { BuildColorMode, GlyphStyle, decorateBuildLine, stripAnsi } from '@cmt/colorize'; import collections from '@cmt/diagnostics/collections'; import { addDiagnosticToCollection, diagnosticSeverity, populateCollection } from '@cmt/diagnostics/util'; @@ -368,6 +369,10 @@ export class CMakeTaskProvider implements vscode.TaskProvider { export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vscode.Pseudoterminal { private writeEmitter = new vscode.EventEmitter(); private closeEmitter = new vscode.EventEmitter(); + // How build-tool output is colorized in this terminal. Only set for build + // tasks (see runBuildTask); stays 'off' for config/test/package/workflow. + private colorMode: BuildColorMode = 'off'; + private glyphStyle: GlyphStyle = 'unicode'; public get onDidWrite(): vscode.Event { return this.writeEmitter.event; } @@ -382,12 +387,12 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc // These two override methods are used to write output and error messages to the terminal, as well // as call the parent class's output and error methods, which store the stdout and stderr messages for returning that info later. override output(line: string): void { - this.writeEmitter.fire(line + endOfLine); + this.writeEmitter.fire(decorateBuildLine(line, this.colorMode, this.glyphStyle) + endOfLine); super.output(line); } override error(error: string): void { - this.writeEmitter.fire(error + endOfLine); + this.writeEmitter.fire(decorateBuildLine(error, this.colorMode, this.glyphStyle) + endOfLine); super.error(error); } @@ -559,6 +564,8 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc let cmakePath: string; if (cmakeDriver) { cmakePath = cmakeDriver.getCMakeCommand(); + this.colorMode = cmakeDriver.config.colorizedBuildOutput; + this.glyphStyle = cmakeDriver.config.buildOutputGlyphs; if (!this.options) { this.options = {}; @@ -628,11 +635,11 @@ export class CustomBuildTaskTerminal extends proc.CommandConsumer implements vsc ? { output: (line: string) => { this.output(line); - compileConsumer!.output(line); + compileConsumer!.output(this.colorMode === 'off' ? line : stripAnsi(line)); }, error: (line: string) => { this.error(line); - compileConsumer!.error(line); + compileConsumer!.error(this.colorMode === 'off' ? line : stripAnsi(line)); } } : this; diff --git a/src/colorize.ts b/src/colorize.ts new file mode 100644 index 000000000..d7223e98d --- /dev/null +++ b/src/colorize.ts @@ -0,0 +1,314 @@ +/** + * Pure helpers for colorizing build output with ANSI SGR escape sequences. + * + * This module intentionally has NO dependency on the `vscode` API so it can be + * unit-tested directly under `test/unit-tests/backend`. + * + * Design notes (accessibility): + * - Only the basic named SGR colors (30-37 / 90-97) plus bold are used. VS Code + * remaps these to the active color theme's `terminal.ansi*` tokens, which High + * Contrast themes define explicitly for sufficient contrast. Hardcoded 24-bit + * (truecolor) or 256-color codes would ignore the theme and can render with + * poor contrast, so they are deliberately avoided. + * - Color never carries meaning on its own: the severity words ("error", + * "warning", "note") remain verbatim in the text, and the authoritative + * Problems panel and on-disk log file are unaffected. + * - Errors are additionally bold and notes use cyan (not green) so the red/green + * axis stays uncluttered for red-green color vision deficiency. + */ + +export type BuildColorMode = 'off' | 'severity' | 'rich' | 'compiler'; +export type GlyphStyle = 'unicode' | 'ascii'; + +export enum BuildLineSeverity { + Error, + Warning, + Note, + Success, + None +} + +const ESC = '\u001b'; +const RESET = `${ESC}[0m`; +const BOLD = `${ESC}[1m`; +const DIM = `${ESC}[2m`; + +/** Bold red. */ +const SGR_ERROR = `${ESC}[1;31m`; +/** Yellow. */ +const SGR_WARNING = `${ESC}[33m`; +/** Cyan. */ +const SGR_NOTE = `${ESC}[36m`; +/** Green. */ +const SGR_SUCCESS = `${ESC}[32m`; + +// Force text (non-emoji) presentation for glyphs that have an emoji variant, so +// they render single-width in the terminal across platforms/fonts. +const VS_TEXT = '\uFE0E'; +const GLYPHS: Record> = { + unicode: { Error: '✗', Warning: `⚠${VS_TEXT}`, Note: `ℹ${VS_TEXT}`, Success: '✓' }, + ascii: { Error: 'x', Warning: '!', Note: 'i', Success: '+' } +}; + +// Build-progress "noise" lines such as "[ 50%] ..." or "[12/34] ...". These are +// de-emphasized (dimmed) in rich mode so real diagnostics stand out. +const PROGRESS_RE = /^\s*\[\s*(?:\d+%|\d+\/\d+)\s*\]/; + +// Conservative, well-anchored patterns covering the common compiler/build tools. +// GCC/Clang/CMake use "::: :"; MSVC uses +// " C####:" / "LNK####" / "RC####"; Ninja prints "FAILED:". +const ERROR_RE = /(:\s*(fatal error|error):)|(\b(error|fatal error)\s+(C\d+|LNK\d+|RC\d+|MSB\d+)\s*:)|(^FAILED:)|(\bmake(\[\d+\])?:\s+\*\*\*\s)/i; +const WARNING_RE = /(:\s*warning:)|(\bwarning\s+(C\d+|LNK\d+|MSB\d+|RC\d+)\s*:)/i; +const NOTE_RE = /:\s*(note|remark):/i; +const SUCCESS_RE = /\bBuilt target\s\S/; + +/** + * Classify a single (clean, ANSI-free) build-output line by severity. The + * classification is purely cosmetic — it only drives coloring, never diagnostics. + */ +export function classifyBuildLine(line: string): BuildLineSeverity { + if (ERROR_RE.test(line)) { + return BuildLineSeverity.Error; + } + if (WARNING_RE.test(line)) { + return BuildLineSeverity.Warning; + } + if (NOTE_RE.test(line)) { + return BuildLineSeverity.Note; + } + if (SUCCESS_RE.test(line)) { + return BuildLineSeverity.Success; + } + return BuildLineSeverity.None; +} + +function sgrFor(severity: BuildLineSeverity): string | undefined { + switch (severity) { + case BuildLineSeverity.Error: + return SGR_ERROR; + case BuildLineSeverity.Warning: + return SGR_WARNING; + case BuildLineSeverity.Note: + return SGR_NOTE; + case BuildLineSeverity.Success: + return SGR_SUCCESS; + default: + return undefined; + } +} + +/** + * Return `line` wrapped in an ANSI SGR color sequence based on its severity. + * + * The line is returned unchanged when: + * - `mode` is `'off'`, + * - the line already contains an ANSI escape (a tool emitted its own colors — we + * pass it through so VS Code renders the tool's colors and we avoid nesting), + * - the line does not classify into a known severity. + */ +export function colorizeBuildLine(line: string, mode: BuildColorMode): string { + if (mode === 'off' || mode === 'compiler') { + return line; + } + if (line.includes(ESC)) { + return line; + } + const sgr = sgrFor(classifyBuildLine(line)); + return sgr ? `${sgr}${line}${RESET}` : line; +} + +const OSC_RE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g; +// CSI: ESC [ , parameter bytes (0x30-0x3F: 0-9 : ; < = > ?), intermediate bytes +// (0x20-0x2F), final byte (0x40-0x7E). The full parameter-byte class (not just +// "[0-9;?]") covers SGR colon sub-parameters such as ESC[4:3m. +const CSI_RE = /\u001b\[[0-9:;<=>?]*[ -/]*[@-~]/g; +const LONE_ESC_RE = /\u001b[@-Z\\-_]/g; + +/** + * Remove ANSI escape sequences (SGR colors, other CSI, OSC hyperlinks, and lone + * two-character escapes) from a string. Pure; used to feed the diagnostic parsers + * and the on-disk log clean text when a tool emits its own colors (e.g. the + * `compiler` mode forces `-fdiagnostics-color`). No-op fast path for the common + * case of a line without any escape. + */ +export function stripAnsi(s: string): string { + if (s.indexOf(ESC) === -1) { + return s; + } + return s.replace(OSC_RE, '').replace(CSI_RE, '').replace(LONE_ESC_RE, ''); +} + +function glyphFor(severity: BuildLineSeverity, glyphs: GlyphStyle): string | undefined { + switch (severity) { + case BuildLineSeverity.Error: + return GLYPHS[glyphs].Error; + case BuildLineSeverity.Warning: + return GLYPHS[glyphs].Warning; + case BuildLineSeverity.Note: + return GLYPHS[glyphs].Note; + case BuildLineSeverity.Success: + return GLYPHS[glyphs].Success; + default: + return undefined; + } +} + +/** Whether a line is build-progress noise (e.g. "[ 50%] ..." / "[12/34] ..."). */ +export function isProgressNoise(line: string): boolean { + return PROGRESS_RE.test(line); +} + +/** + * Decorate a single build-output line for display in the integrated terminal. + * + * - `off` / `severity`: identical to {@link colorizeBuildLine} (behavior unchanged). + * - `rich`: in addition to the severity color, prefixes an accessible severity + * glyph (the severity word stays in the text, so meaning is never color-only) + * and dims build-progress noise. Lines that already contain ANSI pass through. + */ +export function decorateBuildLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): string { + if (mode === 'off' || mode === 'compiler') { + // off: no decoration. compiler: the tool emits its own real ANSI colors, + // which we forward verbatim (no synthetic severity color or glyph). + return line; + } + if (mode !== 'rich') { + return colorizeBuildLine(line, mode); + } + if (line.includes(ESC)) { + return line; + } + const severity = classifyBuildLine(line); + const sgr = sgrFor(severity); + if (sgr) { + const glyph = glyphFor(severity, glyphs); + return `${sgr}${glyph ? `${glyph} ` : ''}${line}${RESET}`; + } + if (isProgressNoise(line)) { + return `${DIM}${line}${RESET}`; + } + return line; +} + +/** True if `p` looks like an absolute path (drive, UNC, or leading slash), platform-independently. */ +export function isAbsoluteLike(p: string): boolean { + return /^([A-Za-z]:[\\/]|[\\/])/.test(p); +} + +// Leading diagnostic location: GCC/Clang/Ninja ":[:]:" or MSVC "([,]):". +// The MSVC form requires the trailing ':' so a file literally named like "foo(1).cpp" is not mis-split. +const LEADING_LOCATION_RE = /^(\s*)(.+?)(:\d+(?::\d+)?:|\(\d+(?:,\d+)?\):)/; + +/** The leading "" token of a diagnostic location line, if present. */ +export function leadingPathToken(line: string): { file: string; start: number; end: number } | undefined { + const m = LEADING_LOCATION_RE.exec(line); + if (!m) { + return undefined; + } + const start = m[1].length; + const file = m[2]; + return { file, start, end: start + file.length }; +} + +/** + * Display-only: rewrite the leading *relative* source path of a diagnostic line to + * an absolute path so VS Code's built-in terminal link detection can make it + * clickable (a Pseudoterminal has no cwd, so relative paths are not linkable). + * + * `resolveExisting(rel)` must return the absolute path iff the file exists, else + * `undefined`. This keeps the module pure — all filesystem access is the caller's. + * Only error/warning/note lines are touched, and only when the path is relative + * and resolves to a real file, so it never corrupts non-location text. + */ +export function linkifyLeadingPath(line: string, resolveExisting: (rel: string) => string | undefined): string { + if (line.includes(ESC)) { + return line; + } + const severity = classifyBuildLine(line); + if (severity !== BuildLineSeverity.Error && severity !== BuildLineSeverity.Warning && severity !== BuildLineSeverity.Note) { + return line; + } + const tok = leadingPathToken(line); + if (!tok || isAbsoluteLike(tok.file)) { + return line; + } + const abs = resolveExisting(tok.file); + return abs ? line.slice(0, tok.start) + abs + line.slice(tok.end) : line; +} + +/** A bold header line printed at the start of a rich build. `headerText` is + * already localized by the caller (this module stays vscode-nls-free). */ +export function renderBuildBanner(headerText: string, glyphs: GlyphStyle): string { + const rule = (glyphs === 'unicode' ? '─' : '-').repeat(8); + return `${BOLD}${rule} ${headerText} ${rule}${RESET}`; +} + +export type BuildOutcome = 'succeeded' | 'failed' | 'cancelled'; + +/** + * A two-line, ruled summary printed at the end of a rich build. `statusText` is + * already localized by the caller; this function only applies a color, a glyph, + * and a rule. Color/glyph never carry meaning alone — `statusText` always + * contains the outcome word and the counts. + */ +export function renderBuildSummary(outcome: BuildOutcome, statusText: string, glyphs: GlyphStyle): string[] { + let sgr: string; + let severity: BuildLineSeverity; + switch (outcome) { + case 'succeeded': + sgr = `${ESC}[1;32m`; + severity = BuildLineSeverity.Success; + break; + case 'cancelled': + sgr = `${ESC}[1;33m`; + severity = BuildLineSeverity.Warning; + break; + default: + sgr = `${ESC}[1;31m`; + severity = BuildLineSeverity.Error; + break; + } + const glyph = glyphFor(severity, glyphs); + const rule = (glyphs === 'unicode' ? '─' : '-').repeat(60); + return [ + `${sgr}${rule}${RESET}`, + `${sgr}${glyph} ${statusText}${RESET}` + ]; +} + +/** + * A surface that renders colorized build output. Implemented by an integrated + * terminal (today) or — if VS Code can render ANSI in the Output panel — an + * Output channel. The ANSI strings produced by this module are identical for + * both surfaces, so the colorization is portable across them. + */ +export interface ColorizedBuildSink { + /** Open the build surface (creating it if needed) *before* the pre-build configure runs, + * optionally clearing it, so the surface appears immediately when the user starts a build + * rather than only once configuration finishes. Writes a short "preparing" notice; the + * caller is responsible for revealing via {@link reveal}. */ + prepareForConfigure(clear: boolean): void; + prepareForBuild(clear: boolean, glyphs: GlyphStyle, bannerTarget?: string, baseDirs?: string[]): void; + writeLine(line: string, mode: BuildColorMode, glyphs: GlyphStyle): void; + writeSummary(outcome: BuildOutcome, counts: { errors: number; warnings: number }, glyphs: GlyphStyle): void; + /** Reveal the sink's surface. Returns `true` if a surface was actually revealed + * (`false` if there is nothing to reveal, e.g. the terminal was closed mid-build). */ + reveal(focus: boolean): boolean; + dispose(): void; +} + +/** + * Whether the VS Code Output panel can render ANSI escape codes. Today this is + * `false`: the Output panel is a Monaco text editor that shows escapes as literal + * text, so colorized build output is rendered in an integrated terminal instead. + * If a future VS Code renders ANSI in the Output panel, flipping this to `true` + * routes the SAME colorized output to the Output channel with no other change. + */ +export function canRenderAnsiInOutput(): boolean { + return false; +} + +/** Pure sink selection: the Output channel when it can render ANSI, else a terminal. */ +export function selectSink(canRender: boolean, makeTerminal: () => ColorizedBuildSink, makeChannel: () => ColorizedBuildSink): ColorizedBuildSink { + return canRender ? makeChannel() : makeTerminal(); +} diff --git a/src/config.ts b/src/config.ts index eb6287e40..9c358151e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -193,6 +193,8 @@ export interface ExtensionConfigurationSettings { saveBeforeBuild: boolean; buildBeforeRun: boolean; clearOutputBeforeBuild: boolean; + colorizedBuildOutput: "off" | "severity" | "rich" | "compiler"; + buildOutputGlyphs: "unicode" | "ascii"; configureSettings: { [key: string]: boolean | number | string | string[] | util.CMakeValue }; cacheInit: string | string[] | null; preferredGenerators: string[]; @@ -392,6 +394,12 @@ export class ConfigurationReader implements vscode.Disposable { get clearOutputBeforeBuild(): boolean { return !!this.configData.clearOutputBeforeBuild; } + get colorizedBuildOutput(): "off" | "severity" | "rich" | "compiler" { + return this.configData.colorizedBuildOutput; + } + get buildOutputGlyphs(): "unicode" | "ascii" { + return this.configData.buildOutputGlyphs; + } get configureSettings(): {[key: string]: boolean | number | string | string[] | util.CMakeValue} { return this.configData.configureSettings; } @@ -712,6 +720,8 @@ export class ConfigurationReader implements vscode.Disposable { saveBeforeBuild: new vscode.EventEmitter(), buildBeforeRun: new vscode.EventEmitter(), clearOutputBeforeBuild: new vscode.EventEmitter(), + colorizedBuildOutput: new vscode.EventEmitter<"off" | "severity" | "rich" | "compiler">(), + buildOutputGlyphs: new vscode.EventEmitter<"unicode" | "ascii">(), configureSettings: new vscode.EventEmitter<{ [key: string]: any }>(), cacheInit: new vscode.EventEmitter(), preferredGenerators: new vscode.EventEmitter(), diff --git a/src/ctest.ts b/src/ctest.ts index 28e5955c7..e6c8a8959 100644 --- a/src/ctest.ts +++ b/src/ctest.ts @@ -483,10 +483,10 @@ export class CTestDriver implements vscode.Disposable { return ctestArgs; } - public async runCTest(driver: CMakeDriver, customizedTask: boolean = false, testPreset?: TestPreset, consumer?: proc.CommandConsumer, specificTestsToRun?: string[], cancellationToken?: vscode.CancellationToken): Promise { + public async runCTest(driver: CMakeDriver, customizedTask: boolean = false, testPreset?: TestPreset, consumer?: proc.CommandConsumer, specificTestsToRun?: string[], cancellationToken?: vscode.CancellationToken, isAutomatic: boolean = false): Promise { if (!customizedTask) { // We don't want to focus on log channel when running tasks. - log.showChannel(); + log.showChannel(undefined, isAutomatic); } if (this.ws.config.testExplorerIntegrationEnabled) { diff --git a/src/diagnostics/build.ts b/src/diagnostics/build.ts index a990e37a8..d47ca4eb4 100644 --- a/src/diagnostics/build.ts +++ b/src/diagnostics/build.ts @@ -7,6 +7,8 @@ import * as proc from '@cmt/proc'; import { OutputConsumer } from '@cmt/proc'; import * as util from '@cmt/util'; import * as vscode from 'vscode'; +import { BuildColorMode, GlyphStyle, stripAnsi } from '@cmt/colorize'; +import { colorizedBuildSink } from '@cmt/buildOutputTerminal'; import * as gcc from '@cmt/diagnostics/gcc'; import * as ghs from '@cmt/diagnostics/ghs'; @@ -324,6 +326,51 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D constructor(readonly logger: Logger | null, config: ConfigurationReader) { super(); this.compileConsumer = new CompileOutputConsumer(config); + this.colorMode = config.colorizedBuildOutput; + this.glyphStyle = config.buildOutputGlyphs; + } + /** + * How build output should be decorated in the integrated terminal. Read once + * per build (a fresh consumer is constructed for each build). + */ + private readonly colorMode: BuildColorMode; + private readonly glyphStyle: GlyphStyle; + /** + * Echo a build-output line. Parsing has already happened on the clean `line`, + * so the Problems panel is unaffected. + * + * In `off` mode the clean line goes to the regular CMake/Build Output channel and + * the on-disk log file (legacy behavior). When colorization is enabled, the raw + * (ANSI) line is written to the "CMake Build" terminal sink — the single visible + * build surface — and the clean line is written to the on-disk log file only + * (never the Output channel), so the stream isn't duplicated and the channel + * doesn't steal focus from the terminal. + */ + private echo(raw: string, clean: string, isError: boolean) { + if (this.colorMode !== 'off') { + // Colorized: the terminal is the single visible build surface and receives + // the RAW line (ANSI preserved). The plain (ANSI-stripped) line still goes to + // the on-disk log file and developer console, but NOT the Output channel — + // this avoids duplicating the stream and the channel stealing focus away from + // the terminal. + colorizedBuildSink().writeLine(raw, this.colorMode, this.glyphStyle); + if (this.logger) { + if (isError) { + this.logger.errorFileOnly(clean); + } else { + this.logger.infoFileOnly(clean); + } + } + return; + } + if (!this.logger) { + return; + } + if (isError) { + this.logger.error(clean); + } else { + this.logger.info(clean); + } } /** * Event fired when the progress changes @@ -349,20 +396,23 @@ export class CMakeBuildConsumer extends proc.CommandConsumer implements vscode.D } error(line: string) { - this.compileConsumer.error(line); - if (this.logger) { - this.logger.error(line); - } - super.error(line); + // In `off` mode, behave byte-identically to the shipped release: pass the raw line + // through to the parser, the Output channel/log, and the captured stdout/stderr. Only + // strip ANSI when colorization is enabled (where a tool may emit real colors, e.g. + // `compiler` mode forces CLICOLOR_FORCE), so the parser and on-disk log stay clean while + // the terminal still receives the raw colored line. + const clean = this.colorMode === 'off' ? line : stripAnsi(line); + this.compileConsumer.error(clean); + this.echo(line, clean, true); + super.error(clean); } output(line: string) { - this.compileConsumer.output(line); - if (this.logger) { - this.logger.info(line); - } - super.output(line); - const progress = this._percent_re.exec(line); + const clean = this.colorMode === 'off' ? line : stripAnsi(line); + this.compileConsumer.output(clean); + this.echo(line, clean, false); + super.output(clean); + const progress = this._percent_re.exec(clean); if (progress) { const percent = progress[1]; this._onProgressEmitter.fire({ diff --git a/src/drivers/cmakeDriver.ts b/src/drivers/cmakeDriver.ts index 376521f0f..f130b7e67 100644 --- a/src/drivers/cmakeDriver.ts +++ b/src/drivers/cmakeDriver.ts @@ -320,6 +320,15 @@ export abstract class CMakeDriver implements vscode.Disposable { if (extraEnvironmentVariables) { envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(extraEnvironmentVariables, envs)]); } + if (this.config.colorizedBuildOutput === 'compiler') { + // Compiler colorized mode: ask CMake to bake compiler color flags (e.g. + // -fdiagnostics-color / -fcolor-diagnostics) into the generated build system so + // the compiler emits real ANSI colors at build time. CMAKE_COLOR_DIAGNOSTICS is a + // configure-time cache variable, so toggling this mode requires a reconfigure to + // take effect. It is intentionally NOT set as CLICOLOR_FORCE here: configure + // output is routed to the Output channel, which cannot render ANSI. + envs = EnvironmentUtils.merge([envs, { CMAKE_COLOR_DIAGNOSTICS: 'ON' }]); + } return envs; } @@ -327,18 +336,25 @@ export abstract class CMakeDriver implements vscode.Disposable { * Get the environment variables that should be set at CMake-build time. */ async getCMakeBuildCommandEnvironment(in_env?: Environment): Promise { + let envs; if (this.useCMakePresets) { - let envs = EnvironmentUtils.merge([in_env, this._buildPreset?.environment]); + envs = EnvironmentUtils.merge([in_env, this._buildPreset?.environment]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.environment, envs)]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.buildEnvironment, envs)]); - return envs; } else { - let envs = EnvironmentUtils.merge([in_env, this._kitEnvironmentVariables]); + envs = EnvironmentUtils.merge([in_env, this._kitEnvironmentVariables]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.environment, envs)]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this.config.buildEnvironment, envs)]); envs = EnvironmentUtils.merge([envs, await this.computeExpandedEnvironment(this._variantEnv, envs)]); - return envs; } + if (this.config.colorizedBuildOutput === 'compiler') { + // Compiler colorized mode: force CLI build tools (cmake --build, Ninja, make) to + // emit ANSI colors even though their output is piped rather than attached to a + // TTY. This is read at invocation time, so it takes effect on the next build with + // no reconfigure required. + envs = EnvironmentUtils.merge([envs, { CLICOLOR_FORCE: '1' }]); + } + return envs; } /** @@ -1971,9 +1987,16 @@ export abstract class CMakeDriver implements vscode.Disposable { const timeEnd: number = new Date().getTime(); const duration: number = timeEnd - timeStart; log.info(localize('build.duration', 'Build completed: {0}', util.msToString(duration))); - const telemetryProperties: telemetry.Properties | undefined = this.useCMakePresets ? undefined : { - ConfigType: this.isMultiConfFast ? 'MultiConf' : this.currentBuildType || '' + // Track adoption of the colorized build output feature (cmake.colorizedBuildOutput). + // Both values are low-cardinality enums (no user data): off|severity|rich|compiler and + // unicode|ascii. ConfigType stays kits-mode-only as before. + const telemetryProperties: telemetry.Properties = { + colorizedBuildOutput: this.config.colorizedBuildOutput, + buildOutputGlyphs: this.config.buildOutputGlyphs }; + if (!this.useCMakePresets) { + telemetryProperties['ConfigType'] = this.isMultiConfFast ? 'MultiConf' : this.currentBuildType || ''; + } const telemetryMeasures: telemetry.Measures = { Duration: duration }; diff --git a/src/extension.ts b/src/extension.ts index 708458f89..797264e92 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import * as cpt from 'vscode-cpptools'; import * as nls from 'vscode-nls'; import * as api from 'vscode-cmake-tools'; import { CMakeCache } from '@cmt/cache'; +import { disposeColorizedBuildSink } from '@cmt/buildOutputTerminal'; import { CMakeProject, ConfigureType, ConfigureTrigger, DiagnosticsConfiguration, DiagnosticsSettings } from '@cmt/cmakeProject'; import { ConfigurationReader, getSettingsChangePromise, TouchBarConfig } from '@cmt/config'; import { CMakeDriver, CMakePreconditionProblems, ConfigureResult, ConfigureResultType } from '@cmt/drivers/cmakeDriver'; @@ -3122,6 +3123,7 @@ export async function deactivate() { if (taskProvider) { taskProvider.dispose(); } + disposeColorizedBuildSink(); } export function getStatusBar(): StatusBar | undefined { diff --git a/src/logging.ts b/src/logging.ts index 42439f95f..19237bcd0 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -23,7 +23,7 @@ enum LogLevel { Fatal, } -type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; +export type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; /** * Get the name of a logging level @@ -147,6 +147,18 @@ class SingletonLogger { } private _log(level: LogLevel, ...args: Stringable[]) { + this._emit(level, true, args); + } + + /** Like the level methods, but writes to the file/console only — never the Output channel. */ + infoFileOnly(...args: Stringable[]) { + this._emit(LogLevel.Info, false, args); + } + errorFileOnly(...args: Stringable[]) { + this._emit(LogLevel.Error, false, args); + } + + private _emit(level: LogLevel, toChannel: boolean, args: Stringable[]) { const trace = vscode.workspace.getConfiguration('cmake').get('enableTraceLogging', false); if (level === LogLevel.Trace && !trace) { return; @@ -175,8 +187,8 @@ class SingletonLogger { this._logStream.then(strm => strm.write(raw_message + '\n')).catch(e => { console.error('Unhandled error while writing CMakeTools log file', e); }); - // Write to our output channel - if (levelEnabled(level)) { + // Write to our output channel (skipped for file-only messages). + if (toChannel && levelEnabled(level)) { const showTimestamps = vscode.workspace.getConfiguration('cmake').get('showTimestampsInOutput', false); this._channel.appendLine(showTimestamps ? raw_message : user_message); } @@ -245,6 +257,13 @@ export class Logger { error(...args: Stringable[]) { SingletonLogger.instance().error(this.tag, ...args); } + /** Log at Info/Error level to the file and developer console only — never the Output channel. */ + infoFileOnly(...args: Stringable[]) { + SingletonLogger.instance().infoFileOnly(this.tag, ...args); + } + errorFileOnly(...args: Stringable[]) { + SingletonLogger.instance().errorFileOnly(this.tag, ...args); + } fatal(...args: Stringable[]) { SingletonLogger.instance().fatal(this.tag, ...args); } @@ -253,24 +272,14 @@ export class Logger { SingletonLogger.instance().clearOutputChannel(); } - showChannel(error_to_show?: boolean) { - const reveal_log = vscode.workspace.getConfiguration('cmake').get('revealLog', 'always'); + showChannel(error_to_show?: boolean, isAutomatic?: boolean) { + const config = vscode.workspace.getConfiguration('cmake'); + const reveal_log = config.get('revealLog', 'always'); + const reveal_on_automatic = config.get('revealLogOnAutomaticTrigger', false); - let should_show: boolean = false; - if (reveal_log === 'always') { - should_show = true; - } - // won't show if no target information - if (reveal_log === 'error' && error_to_show !== undefined) { - should_show = error_to_show; - } - const should_focus = (reveal_log === 'focus'); - if (should_focus) { - should_show = true; - } - - if (should_show) { - SingletonLogger.instance().showChannel(!should_focus); + const decision = decideReveal(reveal_log, error_to_show, isAutomatic ?? false, reveal_on_automatic); + if (decision.shouldShow) { + SingletonLogger.instance().showChannel(decision.preserveFocus); } } @@ -284,6 +293,60 @@ export function createLogger(tag: string) { return new Logger(tag); } +/** + * Decide, based on the `cmake.revealLog` setting, whether a surface (the Output + * channel or the colorized build terminal) should be revealed, and whether it + * should take focus, returning the colorized-build-terminal friendly `{ show, focus }` + * shape. Thin wrapper over {@link decideReveal} (which the Output-channel reveal in + * `showChannel` also uses), so the terminal honors `cmake.revealLog` and + * `cmake.revealLogOnAutomaticTrigger` exactly as the Output channel does. + */ +export function revealLogDecision(error_to_show?: boolean, isAutomatic: boolean = false): { show: boolean; focus: boolean } { + const config = vscode.workspace.getConfiguration('cmake'); + const reveal_log = config.get('revealLog', 'always'); + const reveal_on_automatic = config.get('revealLogOnAutomaticTrigger', false); + const decision = decideReveal(reveal_log, error_to_show, isAutomatic, reveal_on_automatic); + return { show: decision.shouldShow, focus: !decision.preserveFocus }; +} + +/** + * Pure decision logic for revealing the CMake output, factored out so it can be + * unit-tested without a VS Code instance. + * + * @param revealLog The `cmake.revealLog` value, controlling *how* a reveal happens. + * @param errorToShow When defined, indicates a build/configure result: `true` for failure. + * A failure reveal (`true`) always surfaces and is never gated. + * @param isAutomatic Whether the operation was triggered automatically/programmatically + * (e.g. configure-on-open, auto-reconfigure, or a build/test invoked through + * the CMake Tools API by another extension such as the C/C++ DevTools + * companion acting for Copilot) rather than by an explicit user action. + * @param revealOnAutomatic The `cmake.revealLogOnAutomaticTrigger` value. When `false`, + * automatic/programmatic proactive reveals are suppressed so they don't + * switch the panel away from a terminal the user is working in. + */ +export function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { shouldShow: boolean; preserveFocus: boolean } { + const isFailureReveal = errorToShow === true; + // Suppress the proactive reveal for automatic/programmatic operations unless the user + // opts in. Failures (errorToShow === true) always surface so errors are never hidden. + if (isAutomatic && !isFailureReveal && !revealOnAutomatic) { + return { shouldShow: false, preserveFocus: true }; + } + + let shouldShow: boolean = false; + if (revealLog === 'always') { + shouldShow = true; + } + // won't show if no target information + if (revealLog === 'error' && errorToShow !== undefined) { + shouldShow = errorToShow; + } + const shouldFocus = (revealLog === 'focus'); + if (shouldFocus) { + shouldShow = true; + } + return { shouldShow, preserveFocus: !shouldFocus }; +} + export async function showLogFile(): Promise { await vscode.commands.executeCommand('vscode.open', vscode.Uri.file(logFilePath())); } diff --git a/test/unit-tests/backend/colorize.test.ts b/test/unit-tests/backend/colorize.test.ts new file mode 100644 index 000000000..8b6ed7812 --- /dev/null +++ b/test/unit-tests/backend/colorize.test.ts @@ -0,0 +1,365 @@ +import { expect } from 'chai'; +import { classifyBuildLine, colorizeBuildLine, decorateBuildLine, isProgressNoise, renderBuildBanner, renderBuildSummary, linkifyLeadingPath, isAbsoluteLike, leadingPathToken, stripAnsi, canRenderAnsiInOutput, selectSink, ColorizedBuildSink, BuildLineSeverity } from '@cmt/colorize'; + +/** + * Tests for the pure build-output colorizer in src/colorize.ts. + * + * colorize.ts has no transitive dependency on 'vscode', so it is imported + * directly via the @cmt alias (per the backend-test import strategy). + */ + +const ESC = '\u001b'; +const RESET = `${ESC}[0m`; + +suite('[colorize] classifyBuildLine', () => { + test('GCC/Clang error line', () => { + expect(classifyBuildLine('/src/main.cpp:10:5: error: expected \';\'')).to.equal(BuildLineSeverity.Error); + }); + test('GCC/Clang fatal error line', () => { + expect(classifyBuildLine('/src/main.cpp:1:10: fatal error: foo.h: No such file or directory')).to.equal(BuildLineSeverity.Error); + }); + test('MSVC error code line', () => { + expect(classifyBuildLine('main.cpp(12): error C2065: \'x\': undeclared identifier')).to.equal(BuildLineSeverity.Error); + }); + test('Linker error (LNK) line', () => { + expect(classifyBuildLine('main.obj : error LNK2019: unresolved external symbol')).to.equal(BuildLineSeverity.Error); + }); + test('Ninja FAILED line', () => { + expect(classifyBuildLine('FAILED: CMakeFiles/app.dir/main.cpp.o')).to.equal(BuildLineSeverity.Error); + }); + test('MSBuild MSB error code line', () => { + expect(classifyBuildLine('Project.vcxproj : error MSB8066: Custom build exited with code 1')).to.equal(BuildLineSeverity.Error); + }); + test('GNU make error summary line', () => { + expect(classifyBuildLine('make: *** [Makefile:23: all] Error 2')).to.equal(BuildLineSeverity.Error); + }); + test('GNU make[1] error summary line', () => { + expect(classifyBuildLine('make[1]: *** [CMakeFiles/Makefile2:83: all] Error 2')).to.equal(BuildLineSeverity.Error); + }); + test('GCC/Clang warning line', () => { + expect(classifyBuildLine('/src/main.cpp:7:9: warning: unused variable \'y\'')).to.equal(BuildLineSeverity.Warning); + }); + test('MSVC warning code line', () => { + expect(classifyBuildLine('main.cpp(7): warning C4101: \'y\': unreferenced local variable')).to.equal(BuildLineSeverity.Warning); + }); + test('GCC/Clang note line', () => { + expect(classifyBuildLine('/src/main.cpp:9:3: note: in expansion of macro')).to.equal(BuildLineSeverity.Note); + }); + test('Built target success line', () => { + expect(classifyBuildLine('[100%] Built target app')).to.equal(BuildLineSeverity.Success); + }); + test('Plain progress line is not classified', () => { + expect(classifyBuildLine('[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o')).to.equal(BuildLineSeverity.None); + }); + test('Warning line is not misread as error', () => { + // Contains the word "error" only as part of -Werror, must stay a warning. + expect(classifyBuildLine('/src/a.cpp:3:1: warning: -Werror is enabled')).to.equal(BuildLineSeverity.Warning); + }); +}); + +suite('[colorize] colorizeBuildLine', () => { + test('off mode returns the line unchanged', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(colorizeBuildLine(line, 'off')).to.equal(line); + }); + test('severity mode wraps an error in bold red with a trailing reset', () => { + const line = '/src/main.cpp:10:5: error: boom'; + const out = colorizeBuildLine(line, 'severity'); + expect(out).to.equal(`${ESC}[1;31m${line}${RESET}`); + }); + test('severity mode wraps a warning in yellow', () => { + const line = '/src/main.cpp:7:9: warning: meh'; + expect(colorizeBuildLine(line, 'severity')).to.equal(`${ESC}[33m${line}${RESET}`); + }); + test('severity mode wraps a note in cyan', () => { + const line = '/src/main.cpp:9:3: note: here'; + expect(colorizeBuildLine(line, 'severity')).to.equal(`${ESC}[36m${line}${RESET}`); + }); + test('severity mode wraps a success line in green', () => { + const line = '[100%] Built target app'; + expect(colorizeBuildLine(line, 'severity')).to.equal(`${ESC}[32m${line}${RESET}`); + }); + test('unclassified line is returned unchanged in severity mode', () => { + const line = '[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o'; + expect(colorizeBuildLine(line, 'severity')).to.equal(line); + }); + test('line that already contains ANSI is passed through unchanged', () => { + const line = `${ESC}[31m/src/main.cpp:10:5: error: already colored${RESET}`; + expect(colorizeBuildLine(line, 'severity')).to.equal(line); + }); + test('every colorized line ends with a reset', () => { + const line = 'main.obj : error LNK2019: unresolved external symbol'; + expect(colorizeBuildLine(line, 'severity').endsWith(RESET)).to.equal(true); + }); +}); + +suite('[colorize] decorateBuildLine (rich)', () => { + test('off mode returns the line unchanged', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'off', 'unicode')).to.equal(line); + }); + test('severity mode is identical to colorizeBuildLine (no glyph)', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'severity', 'unicode')).to.equal(colorizeBuildLine(line, 'severity')); + }); + test('rich error: bold red + unicode glyph + trailing reset', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[1;31m\u2717 ${line}${RESET}`); + }); + test('rich warning: yellow + ascii glyph', () => { + const line = '/src/main.cpp:7:9: warning: meh'; + expect(decorateBuildLine(line, 'rich', 'ascii')).to.equal(`${ESC}[33m! ${line}${RESET}`); + }); + test('rich note: cyan + unicode glyph with text-presentation selector', () => { + const line = '/src/main.cpp:9:3: note: here'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[36m\u2139\uFE0E ${line}${RESET}`); + }); + test('rich success (Built target) is green with a glyph, not dimmed', () => { + const line = '[100%] Built target app'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[32m\u2713 ${line}${RESET}`); + }); + test('rich dims build-progress noise', () => { + const line = '[ 50%] Building CXX object foo.o'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(`${ESC}[2m${line}${RESET}`); + }); + test('rich leaves plain non-progress lines unchanged', () => { + const line = 'Scanning dependencies of target app'; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(line); + }); + test('rich passes through lines already containing ANSI', () => { + const line = `${ESC}[31malready colored${RESET}`; + expect(decorateBuildLine(line, 'rich', 'unicode')).to.equal(line); + }); +}); + +suite('[colorize] isProgressNoise', () => { + test('percent progress matches', () => { + expect(isProgressNoise('[ 50%] Building CXX object')).to.equal(true); + }); + test('ratio progress matches', () => { + expect(isProgressNoise('[12/34] Linking')).to.equal(true); + }); + test('a plain diagnostic line does not match', () => { + expect(isProgressNoise('/src/x.cpp:1:1: error: x')).to.equal(false); + }); +}); + +suite('[colorize] renderBuildBanner', () => { + test('bold, contains the (already-localized) header text, unicode rule, trailing reset', () => { + const out = renderBuildBanner('Building: app', 'unicode'); + expect(out.startsWith(`${ESC}[1m`)).to.equal(true); + expect(out).to.contain('Building: app'); + expect(out).to.contain('\u2500'); + expect(out.endsWith(RESET)).to.equal(true); + }); + test('ascii style uses dashes', () => { + expect(renderBuildBanner('Building: app', 'ascii')).to.contain('-------- Building: app --------'); + }); +}); + +suite('[colorize] renderBuildSummary', () => { + test('succeeded: green rule + success glyph + verbatim status text + trailing reset', () => { + const [rule, status] = renderBuildSummary('succeeded', 'Build succeeded — 0 error(s), 0 warning(s) (3.4s)', 'unicode'); + expect(rule.startsWith(`${ESC}[1;32m`)).to.equal(true); + expect(status).to.contain('\u2713'); + expect(status).to.contain('Build succeeded — 0 error(s), 0 warning(s) (3.4s)'); + expect(status.endsWith(RESET)).to.equal(true); + }); + test('failed: red rule + error glyph (ascii)', () => { + const [rule, status] = renderBuildSummary('failed', 'Build failed - 2 error(s), 1 warning(s) (1.0s)', 'ascii'); + expect(rule.startsWith(`${ESC}[1;31m`)).to.equal(true); + expect(status).to.contain('Build failed'); + expect(status.startsWith(`${ESC}[1;31mx `)).to.equal(true); + }); + test('cancelled: yellow rule + warning glyph', () => { + const [rule, status] = renderBuildSummary('cancelled', 'Build cancelled', 'unicode'); + expect(rule.startsWith(`${ESC}[1;33m`)).to.equal(true); + expect(status).to.contain('\u26A0'); + expect(status).to.contain('Build cancelled'); + }); +}); + +suite('[colorize] isAbsoluteLike', () => { + test('Windows drive path (backslash)', () => { + expect(isAbsoluteLike('C:\\a\\b')).to.equal(true); + }); + test('Windows drive path (forward slash)', () => { + expect(isAbsoluteLike('C:/a/b')).to.equal(true); + }); + test('POSIX leading slash', () => { + expect(isAbsoluteLike('/a/b')).to.equal(true); + }); + test('UNC / leading backslash', () => { + expect(isAbsoluteLike('\\\\srv\\share')).to.equal(true); + }); + test('relative with dotdot', () => { + expect(isAbsoluteLike('../a/b')).to.equal(false); + }); + test('relative plain', () => { + expect(isAbsoluteLike('src/a.cpp')).to.equal(false); + }); +}); + +suite('[colorize] linkifyLeadingPath', () => { + test('GCC relative path is absolutized when the file exists', () => { + const r = (rel: string) => rel === '../src/x.cpp' ? '/abs/src/x.cpp' : undefined; + expect(linkifyLeadingPath('../src/x.cpp:10:5: error: boom', r)).to.equal('/abs/src/x.cpp:10:5: error: boom'); + }); + test('GCC relative path without a column is absolutized', () => { + const r = (rel: string) => rel === 'src/x.cpp' ? '/abs/src/x.cpp' : undefined; + expect(linkifyLeadingPath('src/x.cpp:7: warning: meh', r)).to.equal('/abs/src/x.cpp:7: warning: meh'); + }); + test('MSVC relative path is absolutized', () => { + const r = (rel: string) => rel === 'src\\x.cpp' ? 'C:\\abs\\src\\x.cpp' : undefined; + expect(linkifyLeadingPath('src\\x.cpp(12): error C2065: x', r)).to.equal('C:\\abs\\src\\x.cpp(12): error C2065: x'); + }); + test('a filename containing "(n)" is not mis-split (GCC colon wins)', () => { + const r = (rel: string) => rel === 'foo(1).cpp' ? '/abs/foo(1).cpp' : undefined; + expect(linkifyLeadingPath('foo(1).cpp:12: error: e', r)).to.equal('/abs/foo(1).cpp:12: error: e'); + }); + test('relative path containing spaces is absolutized as a whole', () => { + const r = (rel: string) => rel === '../my src/x.cpp' ? '/abs/my src/x.cpp' : undefined; + expect(linkifyLeadingPath('../my src/x.cpp:3:1: error: e', r)).to.equal('/abs/my src/x.cpp:3:1: error: e'); + }); + test('missing file leaves the line unchanged (resolver called once)', () => { + let called = 0; + const r = (_rel: string) => { + called++; return undefined; + }; + const line = '../src/x.cpp:10:5: error: boom'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(1); + }); + test('absolute POSIX path is unchanged and the resolver is not called', () => { + let called = 0; + const r = (_rel: string) => { + called++; return '/nope'; + }; + const line = '/abs/src/x.cpp:1:1: error: boom'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(0); + }); + test('absolute Windows path is unchanged and the resolver is not called', () => { + let called = 0; + const r = (_rel: string) => { + called++; return 'nope'; + }; + const line = 'C:\\src\\x.cpp:1:1: error: boom'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(0); + }); + test('non-diagnostic (progress) line is unchanged and the resolver is not called', () => { + let called = 0; + const r = (_rel: string) => { + called++; return '/x'; + }; + const line = '[ 50%] Building CXX object foo.o'; + expect(linkifyLeadingPath(line, r)).to.equal(line); + expect(called).to.equal(0); + }); + test('a line already containing ANSI is passed through unchanged', () => { + const r = (_rel: string) => '/x'; + const line = `${ESC}[31m../src/x.cpp:1:1: error: e${RESET}`; + expect(linkifyLeadingPath(line, r)).to.equal(line); + }); +}); + +suite('[colorize] stripAnsi', () => { + test('no-op fast path: a line without any escape is returned by identity', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(stripAnsi(line)).to.equal(line); + }); + test('removes a leading SGR color and trailing reset', () => { + expect(stripAnsi(`${ESC}[1;31m/src/x.cpp:1:1: error: e${RESET}`)).to.equal('/src/x.cpp:1:1: error: e'); + }); + test('removes multiple interleaved SGR sequences', () => { + expect(stripAnsi(`${ESC}[33mwarn${ESC}[0m: ${ESC}[36mnote${ESC}[0m`)).to.equal('warn: note'); + }); + test('removes a non-SGR CSI sequence (cursor move)', () => { + expect(stripAnsi(`abc${ESC}[2Kdef`)).to.equal('abcdef'); + }); + test('removes an SGR using colon sub-parameters (e.g. underline color ESC[4:3m)', () => { + expect(stripAnsi(`${ESC}[4:3munderlined${ESC}[0m`)).to.equal('underlined'); + }); + test('removes an OSC 8 hyperlink wrapper (BEL-terminated)', () => { + expect(stripAnsi(`${ESC}]8;;file:///x\u0007label${ESC}]8;;\u0007`)).to.equal('label'); + }); + test('removes an OSC sequence terminated by ST (ESC backslash)', () => { + expect(stripAnsi(`${ESC}]0;title${ESC}\\rest`)).to.equal('rest'); + }); + test('is idempotent', () => { + const once = stripAnsi(`${ESC}[31m/src/x.cpp:9:3: note: here${RESET}`); + expect(stripAnsi(once)).to.equal(once); + }); + test('leaves a clean line byte-identical (===)', () => { + const line = 'Scanning dependencies of target app'; + expect(stripAnsi(line)).to.equal(line); + }); +}); + +suite('[colorize] compiler mode passthrough', () => { + test('decorateBuildLine in compiler mode returns the raw line unchanged (no synthetic color/glyph)', () => { + const line = '/src/main.cpp:10:5: error: boom'; + expect(decorateBuildLine(line, 'compiler', 'unicode')).to.equal(line); + }); + test('decorateBuildLine in compiler mode forwards a tool-colored line verbatim', () => { + const line = `${ESC}[0;1;31merror: ${ESC}[0mboom`; + expect(decorateBuildLine(line, 'compiler', 'unicode')).to.equal(line); + }); + test('colorizeBuildLine in compiler mode returns the line unchanged', () => { + const line = '/src/main.cpp:7:9: warning: meh'; + expect(colorizeBuildLine(line, 'compiler')).to.equal(line); + }); +}); + +suite('[colorize] strip-before-parse preserves classification', () => { + const samples = [ + '/src/main.cpp:10:5: error: expected \';\'', + '/src/main.cpp:7:9: warning: unused variable \'y\'', + '/src/main.cpp:9:3: note: in expansion of macro', + 'main.cpp(12): error C2065: \'x\': undeclared identifier', + '[ 50%] Building CXX object CMakeFiles/app.dir/main.cpp.o' + ]; + test('classifyBuildLine is identical with and without a forced compiler color wrapper', () => { + for (const s of samples) { + const colored = `${ESC}[1;31m${s}${RESET}`; + expect(classifyBuildLine(stripAnsi(colored))).to.equal(classifyBuildLine(s)); + } + }); + test('leadingPathToken (line/col positions) is identical after stripping a leading SGR', () => { + const line = '/src/main.cpp:10:5: error: boom'; + const colored = `${ESC}[1;31m${line}${RESET}`; + expect(leadingPathToken(stripAnsi(colored))).to.deep.equal(leadingPathToken(line)); + }); +}); + +suite('[colorize] sink selection', () => { + const fakeSink = (): ColorizedBuildSink => ({ + prepareForConfigure: () => {}, prepareForBuild: () => {}, writeLine: () => {}, writeSummary: () => {}, reveal: () => true, dispose: () => {} + }); + test('the Output panel cannot render ANSI today', () => { + expect(canRenderAnsiInOutput()).to.equal(false); + }); + test('selectSink(false, ...) builds the terminal sink and not the channel sink', () => { + let terminal = 0; + let channel = 0; + selectSink(false, () => { + terminal++; return fakeSink(); + }, () => { + channel++; return fakeSink(); + }); + expect(terminal).to.equal(1); + expect(channel).to.equal(0); + }); + test('selectSink(true, ...) builds the channel sink and not the terminal sink', () => { + let terminal = 0; + let channel = 0; + selectSink(true, () => { + terminal++; return fakeSink(); + }, () => { + channel++; return fakeSink(); + }); + expect(terminal).to.equal(0); + expect(channel).to.equal(1); + }); +}); diff --git a/test/unit-tests/backend/revealDecision.test.ts b/test/unit-tests/backend/revealDecision.test.ts new file mode 100644 index 000000000..7aff50f03 --- /dev/null +++ b/test/unit-tests/backend/revealDecision.test.ts @@ -0,0 +1,173 @@ +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Guards the "minimize interruptions" behavior: automatic/programmatic CMake operations + * (configure-on-open, automatic reconfigure, and builds/tests invoked through the CMake + * Tools API by e.g. Copilot via the C/C++ DevTools companion) must not proactively reveal + * the build output (Output channel or the colorized "CMake Build" terminal) and steal the + * panel from a terminal the user is using - unless `cmake.revealLogOnAutomaticTrigger` is set. + * Explicit user operations keep their `cmake.revealLog` behavior, and failures always surface. + * + * `src/logging.ts` and `src/cmakeProject.ts` transitively import `vscode`, so per the + * backend-test convention (see `expand.test.ts`, `shell-propagation.test.ts`) the pure + * decision logic is mirrored inline here. Keep these mirrors in sync with `decideReveal` + * (src/logging.ts) and `isAutomaticConfigureTrigger` (src/cmakeProject.ts). + */ + +type RevealLogKey = 'always' | 'never' | 'focus' | 'error'; + +// Mirror of `decideReveal` in src/logging.ts. +// Mirror of `decideReveal` in src/logging.ts (returns { shouldShow, preserveFocus }, matching +// the cmake.revealLogOnAutomaticTrigger gate shared with the #4988 work). +function decideReveal(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { shouldShow: boolean; preserveFocus: boolean } { + const isFailureReveal = errorToShow === true; + if (isAutomatic && !isFailureReveal && !revealOnAutomatic) { + return { shouldShow: false, preserveFocus: true }; + } + let shouldShow = false; + if (revealLog === 'always') { + shouldShow = true; + } + if (revealLog === 'error' && errorToShow !== undefined) { + shouldShow = errorToShow; + } + const shouldFocus = (revealLog === 'focus'); + if (shouldFocus) { + shouldShow = true; + } + return { shouldShow, preserveFocus: !shouldFocus }; +} + +// Mirror of the colorization-only `revealLogDecision` wrapper: maps decideReveal's +// { shouldShow, preserveFocus } to the { show, focus } shape the colorized terminal reveal uses. +function revealLogDecision(revealLog: RevealLogKey, errorToShow: boolean | undefined, isAutomatic: boolean, revealOnAutomatic: boolean): { show: boolean; focus: boolean } { + const d = decideReveal(revealLog, errorToShow, isAutomatic, revealOnAutomatic); + return { show: d.shouldShow, focus: !d.preserveFocus }; +} + +// Mirror of `isAutomaticConfigureTrigger` in src/cmakeProject.ts (ConfigureTrigger values). +function isAutomaticConfigureTrigger(trigger: string): boolean { + switch (trigger) { + case 'configureOnOpen': + case 'configureWithCache': + case 'cmakeListsChange': + case 'sourceDirectoryChange': + case 'compilation': + case 'api': + case 'taskProvider': + case 'workflow': + case 'runTests': + case 'package': + case 'badHomeDir': + return true; + default: + return false; + } +} + +function findExtensionDir(): string { + let dir = __dirname; + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, 'package.json'); + if (fs.existsSync(candidate)) { + const json = JSON.parse(fs.readFileSync(candidate, 'utf8')); + if (json?.contributes?.languages?.some((l: { id?: string }) => l.id === 'cmake')) { + return dir; + } + } + dir = path.dirname(dir); + } + throw new Error('CMake Tools package.json (with a `cmake` language contribution) was not found'); +} + +suite('[revealDecision] #4988 minimize interruptions on automatic triggers', () => { + suite('manifest', () => { + let manifest: any; + let nls: any; + suiteSetup(() => { + const dir = findExtensionDir(); + manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + nls = JSON.parse(fs.readFileSync(path.join(dir, 'package.nls.json'), 'utf8')); + }); + + test('declares cmake.revealLogOnAutomaticTrigger as a window-scoped boolean defaulting to false', () => { + const prop = manifest.contributes.configuration.properties['cmake.revealLogOnAutomaticTrigger']; + expect(prop, 'cmake.revealLogOnAutomaticTrigger property').to.be.an('object'); + expect(prop.type, 'type').to.equal('boolean'); + expect(prop.default, 'default').to.equal(false); + expect(prop.scope, 'scope').to.equal('window'); + expect(prop.markdownDescription, 'markdownDescription').to.equal('%cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription%'); + }); + + test('the NLS markdownDescription key exists', () => { + expect(nls['cmake-tools.configuration.cmake.revealLogOnAutomaticTrigger.markdownDescription'], 'nls key').to.be.a('string').and.not.empty; + }); + }); + + suite('decideReveal', () => { + test('user-initiated operations keep legacy revealLog behavior', () => { + expect(decideReveal('always', undefined, false, false)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('focus', undefined, false, false)).to.deep.equal({ shouldShow: true, preserveFocus: false }); + expect(decideReveal('never', undefined, false, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + expect(decideReveal('error', undefined, false, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + }); + + test('automatic operations are suppressed by default (the fix)', () => { + expect(decideReveal('always', undefined, true, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + // Even when the user chose `focus`, an automatic op must not steal focus. + expect(decideReveal('focus', undefined, true, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + }); + + test('automatic operations reveal when the user opts in', () => { + expect(decideReveal('always', undefined, true, true)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('focus', undefined, true, true)).to.deep.equal({ shouldShow: true, preserveFocus: false }); + }); + + test('failures always surface, even for automatic operations and even when opted out', () => { + expect(decideReveal('always', true, true, false)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('error', true, true, false)).to.deep.equal({ shouldShow: true, preserveFocus: true }); + expect(decideReveal('focus', true, true, false)).to.deep.equal({ shouldShow: true, preserveFocus: false }); + }); + + test('successful automatic results are not revealed under error mode', () => { + expect(decideReveal('error', false, true, false)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + }); + + test('never suppresses everything, including failures (subordinate to revealLog as today)', () => { + expect(decideReveal('never', true, false, true)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + expect(decideReveal('never', true, true, true)).to.deep.equal({ shouldShow: false, preserveFocus: true }); + }); + }); + + suite('revealLogDecision (colorized terminal mapping)', () => { + test('maps shouldShow -> show and preserveFocus -> !focus', () => { + // always/user: shown, focus preserved (no focus steal) + expect(revealLogDecision('always', undefined, false, false)).to.deep.equal({ show: true, focus: false }); + // focus/user: shown and takes focus + expect(revealLogDecision('focus', undefined, false, false)).to.deep.equal({ show: true, focus: true }); + // automatic suppressed by default + expect(revealLogDecision('always', undefined, true, false)).to.deep.equal({ show: false, focus: false }); + // failure always shows + expect(revealLogDecision('always', true, true, false)).to.deep.equal({ show: true, focus: false }); + }); + }); + + suite('isAutomaticConfigureTrigger', () => { + const automatic = ['configureOnOpen', 'configureWithCache', 'cmakeListsChange', 'sourceDirectoryChange', 'compilation', 'api', 'taskProvider', 'workflow', 'runTests', 'package', 'badHomeDir']; + const userInitiated = ['commandConfigure', 'commandCleanConfigure', 'commandConfigureWithDebugger', 'selectKit', 'selectConfigurePreset', 'quickStart', 'launch', 'setVariant', 'buttonNewKitsDefinition', 'commandEditCacheUI', 'commandEditCache']; + + test('automatic/programmatic triggers are classified automatic', () => { + for (const t of automatic) { + expect(isAutomaticConfigureTrigger(t), t).to.equal(true); + } + }); + + test('explicit user triggers are classified user-initiated', () => { + for (const t of userInitiated) { + expect(isAutomaticConfigureTrigger(t), t).to.equal(false); + } + }); + }); +}); diff --git a/test/unit-tests/config.test.ts b/test/unit-tests/config.test.ts index b7421ca13..3060af68a 100644 --- a/test/unit-tests/config.test.ts +++ b/test/unit-tests/config.test.ts @@ -13,6 +13,8 @@ function createConfig(conf: Partial): Configurat saveBeforeBuild: true, buildBeforeRun: true, clearOutputBeforeBuild: true, + colorizedBuildOutput: "off", + buildOutputGlyphs: "unicode", configureSettings: {}, cacheInit: null, preferredGenerators: [],