From 9d72a2fe1b757d0c05525621bd2462563717e110 Mon Sep 17 00:00:00 2001 From: FunToHard Date: Sat, 7 Nov 2026 20:31:13 +0530 Subject: [PATCH 1/2] feat(cmake): implement hybrid FileAPI ingestion, presets, target debugging, and CTest integration (#5) --- package.json | 37 +++ src/bridge/cmake-watcher.ts | 2 +- src/cmake/cmake-file-api.ts | 340 +++++++++++++++++++++++ src/cmake/cmake-manager.ts | 231 +++++++++++++++- src/cmake/cmake-modifier.ts | 184 +++++++++++++ src/cmake/cmake-presets.ts | 175 ++++++++++++ src/cmake/cmake-target-launcher.ts | 159 +++++++++++ src/cmake/single-file-compile.ts | 181 +++++++++++++ src/commands/command-registry.ts | 35 +++ src/diagnostics/problem-matcher.ts | 134 ++++++++++ src/extension.ts | 8 + src/testing/ctest-runner.ts | 152 +++++++++++ src/testing/test-controller.ts | 69 ++++- test/cmake-modernization.test.ts | 416 +++++++++++++++++++++++++++++ test/vscode-mock.ts | 3 + wiki/Static-CMake-Integration.md | 113 ++++---- 16 files changed, 2166 insertions(+), 73 deletions(-) create mode 100644 src/cmake/cmake-file-api.ts create mode 100644 src/cmake/cmake-modifier.ts create mode 100644 src/cmake/cmake-presets.ts create mode 100644 src/cmake/cmake-target-launcher.ts create mode 100644 src/cmake/single-file-compile.ts create mode 100644 src/diagnostics/problem-matcher.ts create mode 100644 src/testing/ctest-runner.ts create mode 100644 test/cmake-modernization.test.ts diff --git a/package.json b/package.json index db09df2..4d383a6 100644 --- a/package.json +++ b/package.json @@ -156,6 +156,36 @@ "title": "C/C++ Pro: Generate compile_commands.json from CMakeLists.txt", "category": "C/C++" }, + { + "command": "c-cpp-pro.cmake.selectConfigurePreset", + "title": "C/C++ Pro: Select CMake Configure Preset", + "category": "C/C++" + }, + { + "command": "c-cpp-pro.cmake.selectBuildPreset", + "title": "C/C++ Pro: Select CMake Build Preset", + "category": "C/C++" + }, + { + "command": "c-cpp-pro.cmake.selectLaunchTarget", + "title": "C/C++ Pro: Select Active CMake Launch Target", + "category": "C/C++" + }, + { + "command": "c-cpp-pro.cmake.debugTarget", + "title": "C/C++ Pro: Debug Active CMake Target", + "category": "C/C++" + }, + { + "command": "c-cpp-pro.cmake.launchTarget", + "title": "C/C++ Pro: Run Active CMake Target", + "category": "C/C++" + }, + { + "command": "c-cpp-pro.cmake.compileActiveFile", + "title": "C/C++ Pro: Compile Active File (Syntax & Diagnostics)", + "category": "C/C++" + }, { "command": "c-cpp-pro.openSettings", "title": "C/C++ Pro: Open Configuration Panel", @@ -281,6 +311,13 @@ "when": "editorLangId == cpp || editorLangId == c", "group": "navigation@2" } + ], + "editor/context": [ + { + "command": "c-cpp-pro.cmake.compileActiveFile", + "when": "editorLangId == cpp || editorLangId == c", + "group": "1_modification@10" + } ] }, "keybindings": [ diff --git a/src/bridge/cmake-watcher.ts b/src/bridge/cmake-watcher.ts index c5ca3b1..319ec91 100644 --- a/src/bridge/cmake-watcher.ts +++ b/src/bridge/cmake-watcher.ts @@ -37,7 +37,7 @@ export class CMakeWatcher implements vscode.Disposable { ) { // Watch for build artifacts and CMake configuration changes this.watcher = vscode.workspace.createFileSystemWatcher( - '{**/compile_commands.json,**/compile_flags.txt,**/.clangd,**/CMakeLists.txt,**/CMakePresets.json,**/CMakeUserPresets.json}' + '{**/compile_commands.json,**/compile_flags.txt,**/.clangd,**/CMakeLists.txt,**/CMakePresets.json,**/CMakeUserPresets.json,**/.cmake/api/v1/reply/*.json}' ); this.disposables.push( diff --git a/src/cmake/cmake-file-api.ts b/src/cmake/cmake-file-api.ts new file mode 100644 index 0000000..dd7c093 --- /dev/null +++ b/src/cmake/cmake-file-api.ts @@ -0,0 +1,340 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { CMakeProjectInfo, CMakeTargetInfo } from './cmake-models'; + +export interface FileApiTargetArtifact { + path: string; +} + +export interface FileApiSourceGroup { + name: string; + sourceIndexes: number[]; +} + +export interface FileApiCompileGroup { + language: string; + compileCommandFragments?: Array<{ fragment: string }>; + includes?: Array<{ path: string; isSystem?: boolean }>; + preprocessorDefinitions?: Array<{ define: string }>; + sysroot?: { path: string }; +} + +export interface FileApiTarget { + name: string; + type: string; + artifacts?: FileApiTargetArtifact[]; + sources?: Array<{ path: string; compileGroupIndex?: number }>; + sourceGroups?: FileApiSourceGroup[]; + compileGroups?: FileApiCompileGroup[]; +} + +export interface FileApiIndexReply { + 'codemodel-v2'?: { jsonFile: string }; + 'cache-v2'?: { jsonFile: string }; + 'toolchains-v1'?: { jsonFile: string }; + 'cmakeFiles-v1'?: { jsonFile: string }; +} + +export interface FileApiIndex { + reply: FileApiIndexReply; +} + +export interface FileApiCodeModelConfiguration { + name: string; + targets: Array<{ name: string; jsonFile: string }>; +} + +export interface FileApiCodeModel { + configurations: FileApiCodeModelConfiguration[]; +} + +export interface FileApiToolchains { + toolchains?: Array<{ + language: string; + compiler?: { + path?: string; + id?: string; + version?: string; + target?: string; + }; + }>; +} + +export interface FileApiCacheEntry { + name: string; + type: string; + value: string; + properties?: Array<{ name: string; value: string }>; +} + +export interface FileApiCache { + entries: FileApiCacheEntry[]; +} + +export interface FileApiParsedResult { + project: CMakeProjectInfo; + executableTargets: Array<{ name: string; binaryPath: string; workingDirectory?: string }>; + toolchains?: Record; + cacheEntries?: Record; + rawCodeModel?: FileApiCodeModel; +} + +export class CMakeFileApiReader { + /** + * Scans common build directory locations within a workspace for CMake FileAPI reply directories. + * Looks for .cmake/api/v1/reply/index-*.json. + */ + public static findReplyDirectory(workspaceRoot: string, customBuildDir?: string): string | null { + const candidates: string[] = []; + + if (customBuildDir && customBuildDir.trim().length > 0) { + const resolved = path.isAbsolute(customBuildDir) + ? customBuildDir + : path.join(workspaceRoot, customBuildDir); + candidates.push(path.join(resolved, '.cmake', 'api', 'v1', 'reply')); + } + + // Standard build directories + candidates.push( + path.join(workspaceRoot, 'build', '.cmake', 'api', 'v1', 'reply'), + path.join(workspaceRoot, 'out', '.cmake', 'api', 'v1', 'reply'), + path.join(workspaceRoot, '.cmake', 'api', 'v1', 'reply') + ); + + // Look for subfolders in build/ (e.g. build/Debug, build/Release, build/x64-Debug) + const buildRoot = path.join(workspaceRoot, 'build'); + if (fs.existsSync(buildRoot)) { + try { + const subdirs = fs.readdirSync(buildRoot, { withFileTypes: true }); + for (const sub of subdirs) { + if (sub.isDirectory()) { + candidates.push(path.join(buildRoot, sub.name, '.cmake', 'api', 'v1', 'reply')); + } + } + } catch { + // Ignore read errors + } + } + + for (const cand of candidates) { + if (fs.existsSync(cand) && this.findLatestIndexFile(cand) !== null) { + return cand.replace(/\\/g, '/'); + } + } + + return null; + } + + /** + * Finds the latest index-*.json file in the reply directory. + */ + public static findLatestIndexFile(replyDir: string): string | null { + if (!fs.existsSync(replyDir)) return null; + + try { + const files = fs.readdirSync(replyDir); + const indexFiles = files.filter((f) => /^index-.*\.json$/i.test(f)); + if (indexFiles.length === 0) return null; + + // Sort by file modification time descending + indexFiles.sort((a, b) => { + const statA = fs.statSync(path.join(replyDir, a)); + const statB = fs.statSync(path.join(replyDir, b)); + return statB.mtimeMs - statA.mtimeMs; + }); + + return path.join(replyDir, indexFiles[0]).replace(/\\/g, '/'); + } catch { + return null; + } + } + + /** + * Ingests FileAPI reply artifacts and transforms them into a structured FileApiParsedResult. + */ + public static parseReply(workspaceRoot: string, replyDir: string): FileApiParsedResult | null { + const indexFilePath = this.findLatestIndexFile(replyDir); + if (!indexFilePath) return null; + + try { + const indexContent = fs.readFileSync(indexFilePath, 'utf8'); + const indexObj = JSON.parse(indexContent) as FileApiIndex; + const reply = indexObj.reply; + + if (!reply || !reply['codemodel-v2']) { + return null; + } + + const codeModelPath = path.join(replyDir, reply['codemodel-v2'].jsonFile); + if (!fs.existsSync(codeModelPath)) return null; + + const codeModelContent = fs.readFileSync(codeModelPath, 'utf8'); + const codeModel = JSON.parse(codeModelContent) as FileApiCodeModel; + + if (!codeModel.configurations || codeModel.configurations.length === 0) { + return null; + } + + // Read active or first configuration + const config = codeModel.configurations[0]; + const parsedTargets: CMakeTargetInfo[] = []; + const executableTargets: Array<{ name: string; binaryPath: string; workingDirectory?: string }> = []; + const allGlobalIncludes = new Set(); + const allGlobalDefs = new Set(); + + // Build directory is the parent of .cmake/ + const buildDir = path.resolve(replyDir, '..', '..', '..'); + + for (const targetRef of config.targets) { + const targetJsonPath = path.join(replyDir, targetRef.jsonFile); + if (!fs.existsSync(targetJsonPath)) continue; + + try { + const targetContent = fs.readFileSync(targetJsonPath, 'utf8'); + const targetObj = JSON.parse(targetContent) as FileApiTarget; + + const targetTypeLower = (targetObj.type || '').toLowerCase(); + let normType: 'executable' | 'library' | 'interface' | 'custom' = 'custom'; + if (targetTypeLower.includes('executable')) { + normType = 'executable'; + } else if (targetTypeLower.includes('library')) { + if (targetTypeLower.includes('interface')) { + normType = 'interface'; + } else { + normType = 'library'; + } + } + + const targetSources: string[] = []; + if (targetObj.sources) { + for (const s of targetObj.sources) { + const fullSrc = path.isAbsolute(s.path) + ? s.path + : path.join(workspaceRoot, s.path); + targetSources.push(fullSrc.replace(/\\/g, '/')); + } + } + + const targetIncludes: string[] = []; + const targetDefs: string[] = []; + + if (targetObj.compileGroups) { + for (const cg of targetObj.compileGroups) { + if (cg.includes) { + for (const inc of cg.includes) { + if (inc.path) { + const normInc = inc.path.replace(/\\/g, '/'); + targetIncludes.push(normInc); + allGlobalIncludes.add(normInc); + } + } + } + if (cg.preprocessorDefinitions) { + for (const def of cg.preprocessorDefinitions) { + if (def.define) { + targetDefs.push(def.define); + allGlobalDefs.add(def.define); + } + } + } + } + } + + parsedTargets.push({ + name: targetObj.name, + type: normType, + sourceFiles: targetSources, + includeDirectories: Array.from(new Set(targetIncludes)), + compileDefinitions: Array.from(new Set(targetDefs)) + }); + + // Record executable targets for launch and debugging + if (normType === 'executable' && targetObj.artifacts && targetObj.artifacts.length > 0) { + const rawArtifact = targetObj.artifacts[0].path; + const fullArtifact = path.isAbsolute(rawArtifact) + ? rawArtifact + : path.join(buildDir, rawArtifact); + + executableTargets.push({ + name: targetObj.name, + binaryPath: fullArtifact.replace(/\\/g, '/'), + workingDirectory: buildDir.replace(/\\/g, '/') + }); + } + } catch { + // Skip corrupted target file + } + } + + // Optional: Parse toolchains-v1 + let toolchains: Record | undefined; + if (reply['toolchains-v1']) { + const tcPath = path.join(replyDir, reply['toolchains-v1'].jsonFile); + if (fs.existsSync(tcPath)) { + try { + const tcContent = fs.readFileSync(tcPath, 'utf8'); + const tcObj = JSON.parse(tcContent) as FileApiToolchains; + if (tcObj.toolchains) { + toolchains = {}; + for (const tc of tcObj.toolchains) { + if (tc.language && tc.compiler && tc.compiler.path) { + toolchains[tc.language] = { + path: tc.compiler.path.replace(/\\/g, '/'), + id: tc.compiler.id || '', + version: tc.compiler.version || '', + targetTriple: tc.compiler.target + }; + } + } + } + } catch { + // Ignore toolchain parsing error + } + } + } + + // Optional: Parse cache-v2 + let cacheEntries: Record | undefined; + if (reply['cache-v2']) { + const cachePath = path.join(replyDir, reply['cache-v2'].jsonFile); + if (fs.existsSync(cachePath)) { + try { + const cacheContent = fs.readFileSync(cachePath, 'utf8'); + const cacheObj = JSON.parse(cacheContent) as FileApiCache; + if (cacheObj.entries) { + cacheEntries = {}; + for (const ent of cacheObj.entries) { + if (ent.name) { + cacheEntries[ent.name] = ent.value; + } + } + } + } catch { + // Ignore cache parsing error + } + } + } + + const project: CMakeProjectInfo = { + workspaceRoot: workspaceRoot.replace(/\\/g, '/'), + cmakeListsPath: path.join(workspaceRoot, 'CMakeLists.txt').replace(/\\/g, '/'), + includeDirectories: Array.from(allGlobalIncludes), + globalIncludeDirectories: Array.from(allGlobalIncludes), + compileDefinitions: Array.from(allGlobalDefs), + globalCompileDefinitions: Array.from(allGlobalDefs), + targets: parsedTargets, + subdirectories: [] + }; + + return { + project, + executableTargets, + toolchains, + cacheEntries, + rawCodeModel: codeModel + }; + } catch { + return null; + } + } +} diff --git a/src/cmake/cmake-manager.ts b/src/cmake/cmake-manager.ts index 648f2b9..5287e96 100644 --- a/src/cmake/cmake-manager.ts +++ b/src/cmake/cmake-manager.ts @@ -6,17 +6,34 @@ import { CMakeDetector } from './cmake-detector'; import { CompilerDetector } from '../prober/compiler-detector'; import { SystemIncludeExtractor } from '../prober/system-includes'; import { NativeBridge } from '../native/native-bridge'; +import { CMakeFileApiReader } from './cmake-file-api'; +import { CMakePresetsParser, CMakeConfigurePreset, CMakeBuildPreset } from './cmake-presets'; +import { CMakeTargetLauncher, CMakeExecutableTarget } from './cmake-target-launcher'; +import { SingleFileCompiler } from './single-file-compile'; export class CMakeManager implements vscode.Disposable { private statusBarItem: vscode.StatusBarItem; private disposables: vscode.Disposable[] = []; private activeProject: CMakeProjectInfo | null = null; + private isUsingFileApi = false; + + private targetLauncher: CMakeTargetLauncher; + private singleFileCompiler: SingleFileCompiler; + + private activeConfigurePreset: CMakeConfigurePreset | undefined; + private activeBuildPreset: CMakeBuildPreset | undefined; + private availableConfigurePresets: CMakeConfigurePreset[] = []; + private availableBuildPresets: CMakeBuildPreset[] = []; constructor( private readonly detector: CompilerDetector, private readonly extractor: SystemIncludeExtractor, private readonly onReloadServer?: () => Promise ) { + this.targetLauncher = new CMakeTargetLauncher(); + this.singleFileCompiler = new SingleFileCompiler(); + this.disposables.push(this.targetLauncher, this.singleFileCompiler); + this.statusBarItem = vscode.window.createStatusBarItem( vscode.StatusBarAlignment.Left, 66 @@ -29,6 +46,18 @@ export class CMakeManager implements vscode.Disposable { return this.activeProject; } + public getTargetLauncher(): CMakeTargetLauncher { + return this.targetLauncher; + } + + public getSingleFileCompiler(): SingleFileCompiler { + return this.singleFileCompiler; + } + + public getIsUsingFileApi(): boolean { + return this.isUsingFileApi; + } + public getIncludeDirectories(): string[] { return this.activeProject?.includeDirectories ?? []; } @@ -37,9 +66,17 @@ export class CMakeManager implements vscode.Disposable { return this.activeProject?.compileDefinitions ?? []; } + public getActiveConfigurePreset(): CMakeConfigurePreset | undefined { + return this.activeConfigurePreset; + } + + public getActiveBuildPreset(): CMakeBuildPreset | undefined { + return this.activeBuildPreset; + } + /** - * Initializes the CMake subsystem by statically parsing the workspace CMakeLists.txt - * and including discovered include paths and definitions. + * Initializes the CMake subsystem by loading presets, attempting FileAPI ingestion, + * or falling back to static AST parsing. */ public async initialize(): Promise { const folders = vscode.workspace.workspaceFolders; @@ -58,8 +95,10 @@ export class CMakeManager implements vscode.Disposable { } /** - * Statically re-parses CMakeLists.txt across the workspace, updates active project info, - * syncs include paths into IntelliSense, and notifies the language server. + * Refreshes CMake configuration using a hybrid approach: + * 1. Loads CMakePresets.json / CMakeUserPresets.json if present. + * 2. Inspects build directory for CMake FileAPI (codemodel-v2). If found, ingests 100% accurate targets and flags. + * 3. If FileAPI is not found (e.g. unconfigured workspace), falls back to our zero-binary static AST parser. */ public async refresh(reloadServer = true): Promise { const folders = vscode.workspace.workspaceFolders; @@ -69,6 +108,45 @@ export class CMakeManager implements vscode.Disposable { } const rootPath = folders[0].uri.fsPath; + + // 1. Ingest Presets + const presetsData = CMakePresetsParser.loadPresets(rootPath); + if (presetsData) { + this.availableConfigurePresets = presetsData.configurePresets; + this.availableBuildPresets = presetsData.buildPresets; + if (!this.activeConfigurePreset && presetsData.activeConfigurePreset) { + this.activeConfigurePreset = presetsData.activeConfigurePreset; + } + if (!this.activeBuildPreset && presetsData.activeBuildPreset) { + this.activeBuildPreset = presetsData.activeBuildPreset; + } + } + + // 2. Try CMake FileAPI ingestion first + const replyDir = CMakeFileApiReader.findReplyDirectory( + rootPath, + this.activeConfigurePreset?.binaryDir + ); + + if (replyDir) { + const parsed = CMakeFileApiReader.parseReply(rootPath, replyDir); + if (parsed && parsed.project) { + this.activeProject = parsed.project; + this.isUsingFileApi = true; + this.targetLauncher.setExecutableTargets(parsed.executableTargets); + this.updateStatusBar(); + + await this.synthesizeCompilationDatabase(); + + if (reloadServer && this.onReloadServer) { + await this.onReloadServer(); + } + return; + } + } + + // 3. Fallback: Zero-binary static AST parser + this.isUsingFileApi = false; const project = await NativeBridge.parseCMakeWorkspace(rootPath); if (!project) { this.activeProject = null; @@ -77,6 +155,24 @@ export class CMakeManager implements vscode.Disposable { } this.activeProject = project; + + // Extract static executable targets + const staticExecutables: CMakeExecutableTarget[] = []; + const isWindows = process.platform === 'win32'; + const binaryExt = isWindows ? '.exe' : ''; + + for (const t of project.targets) { + if (t.type === 'executable') { + const binCandidate = path.join(rootPath, 'build', t.name + binaryExt).replace(/\\/g, '/'); + staticExecutables.push({ + name: t.name, + binaryPath: binCandidate, + workingDirectory: path.join(rootPath, 'build').replace(/\\/g, '/') + }); + } + } + this.targetLauncher.setExecutableTargets(staticExecutables); + this.updateStatusBar(); // Automatically synchronize discovered CMake includes into workspace compile_commands.json @@ -87,6 +183,61 @@ export class CMakeManager implements vscode.Disposable { } } + /** + * Prompts the user to switch the active CMake configure preset. + */ + public async selectConfigurePreset(): Promise { + if (this.availableConfigurePresets.length === 0) { + vscode.window.showInformationMessage('No configure presets defined in CMakePresets.json.'); + return null; + } + + const items = this.availableConfigurePresets.map((p) => ({ + label: p.name === this.activeConfigurePreset?.name ? `$(check) ${p.name}` : `$(gear) ${p.name}`, + description: p.displayName || p.generator || '', + detail: p.description || (p.binaryDir ? `Binary directory: ${p.binaryDir}` : undefined), + preset: p + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select active CMake configure preset' + }); + + if (!selected) return null; + + this.activeConfigurePreset = selected.preset; + vscode.window.showInformationMessage(`Active CMake configure preset: ${this.activeConfigurePreset.name}`); + await this.refresh(); + return this.activeConfigurePreset; + } + + /** + * Prompts the user to switch the active CMake build preset. + */ + public async selectBuildPreset(): Promise { + if (this.availableBuildPresets.length === 0) { + vscode.window.showInformationMessage('No build presets defined in CMakePresets.json.'); + return null; + } + + const items = this.availableBuildPresets.map((b) => ({ + label: b.name === this.activeBuildPreset?.name ? `$(check) ${b.name}` : `$(tools) ${b.name}`, + description: b.displayName || (b.configurePreset ? `Config: ${b.configurePreset}` : ''), + detail: b.description, + preset: b + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select active CMake build preset' + }); + + if (!selected) return null; + + this.activeBuildPreset = selected.preset; + vscode.window.showInformationMessage(`Active CMake build preset: ${this.activeBuildPreset.name}`); + return this.activeBuildPreset; + } + /** * Synthesizes compile_commands.json directly from parsed CMake targets, sources, and include directories * without requiring any external cmake execution. @@ -103,7 +254,15 @@ export class CMakeManager implements vscode.Disposable { const cppStandard = this.activeProject.cppStandard || 'c++20'; const cStandard = this.activeProject.cStandard || 'c11'; const cStdMsvc = (cStandard === 'c17' || cStandard === 'c11') ? cStandard : 'c11'; - const targetFile = outputFilePath || path.join(workspaceRoot, 'compile_commands.json'); + + let targetFile = outputFilePath; + if (!targetFile) { + if (this.activeConfigurePreset && this.activeConfigurePreset.binaryDir) { + targetFile = path.join(this.activeConfigurePreset.binaryDir, 'compile_commands.json'); + } else { + targetFile = path.join(workspaceRoot, 'compile_commands.json'); + } + } const globalIncludes = [...(this.activeProject.globalIncludeDirectories ?? [])]; const globalDefs = [...(this.activeProject.globalCompileDefinitions ?? [])]; @@ -282,11 +441,12 @@ export class CMakeManager implements vscode.Disposable { const incCount = this.activeProject.includeDirectories.length; const targetCount = this.activeProject.targets.length; + const driverLabel = this.isUsingFileApi ? 'FileAPI' : 'Static Parser'; const items: (vscode.QuickPickItem & { action: string })[] = [ { label: `$(folder) View Discovered Include Directories (${incCount})`, - description: 'Inspect include directories extracted from CMakeLists.txt', + description: `Inspect include directories extracted via ${driverLabel}`, action: 'showIncludes' }, { @@ -294,6 +454,26 @@ export class CMakeManager implements vscode.Disposable { description: 'Inspect executable and library targets declared in CMakeLists.txt', action: 'showTargets' }, + { + label: '$(gear) Select Configure Preset', + description: this.activeConfigurePreset ? `Active: ${this.activeConfigurePreset.name}` : 'Choose from CMakePresets.json', + action: 'selectPreset' + }, + { + label: '$(play) Select Active Launch Target', + description: this.targetLauncher.getActiveTarget()?.name || 'Choose executable target to run/debug', + action: 'selectTarget' + }, + { + label: '$(debug-start) Debug Active CMake Target', + description: 'Launch debugging session for active target', + action: 'debugTarget' + }, + { + label: '$(check) Compile Active File (Syntax & Diagnostics)', + description: 'Quickly compile current source file without whole project linkage', + action: 'compileActiveFile' + }, { label: '$(refresh) Re-parse CMakeLists.txt', description: 'Re-scan CMakeLists.txt and refresh IntelliSense includes', @@ -307,7 +487,7 @@ export class CMakeManager implements vscode.Disposable { ]; const selected = await vscode.window.showQuickPick(items, { - placeHolder: `C/C++ Pro CMake Intelligence — ${path.basename(this.activeProject.workspaceRoot)}` + placeHolder: `C/C++ Pro CMake Intelligence [${driverLabel}] — ${path.basename(this.activeProject.workspaceRoot)}` }); if (!selected) return; @@ -344,6 +524,26 @@ export class CMakeManager implements vscode.Disposable { break; } + case 'selectPreset': { + await this.selectConfigurePreset(); + break; + } + + case 'selectTarget': { + await this.targetLauncher.selectLaunchTarget(); + break; + } + + case 'debugTarget': { + await this.targetLauncher.debugTarget(); + break; + } + + case 'compileActiveFile': { + await this.singleFileCompiler.compileActiveFile(); + break; + } + case 'rescan': { await this.refresh(); vscode.window.showInformationMessage( @@ -373,15 +573,20 @@ export class CMakeManager implements vscode.Disposable { } const incCount = this.activeProject.includeDirectories.length; - this.statusBarItem.text = `$(tools) CMake: [${incCount} include${incCount === 1 ? '' : 's'}]`; + const mode = this.isUsingFileApi ? 'FileAPI' : 'Static'; + const presetLabel = this.activeConfigurePreset ? ` [${this.activeConfigurePreset.name}]` : ''; + + this.statusBarItem.text = `$(tools) CMake: [${incCount} inc${incCount === 1 ? '' : 's'}]${presetLabel}`; this.statusBarItem.tooltip = [ - 'C/C++ Pro CMake Intelligence (Direct Static Parser):', + `C/C++ Pro CMake Intelligence (${mode}):`, `CMakeLists: ${this.activeProject.cmakeListsPath}`, + this.activeConfigurePreset ? `Active Preset: ${this.activeConfigurePreset.name}` : '', `Discovered Includes: ${incCount}`, - ...this.activeProject.includeDirectories.map((d) => ` - ${d}`), - `Standard: ${this.activeProject.cppStandard || 'default'}`, - 'Click to inspect includes and targets' - ].join('\n'); + ...this.activeProject.includeDirectories.slice(0, 10).map((d) => ` - ${d}`), + incCount > 10 ? ` ... and ${incCount - 10} more` : '', + `Targets: ${this.activeProject.targets.length}`, + 'Click to open CMake Intelligence menu' + ].filter(Boolean).join('\n'); this.statusBarItem.show(); } diff --git a/src/cmake/cmake-modifier.ts b/src/cmake/cmake-modifier.ts new file mode 100644 index 0000000..4c6e730 --- /dev/null +++ b/src/cmake/cmake-modifier.ts @@ -0,0 +1,184 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +export interface DiscoveredTargetLocation { + name: string; + type: 'executable' | 'library' | 'sources'; + line: number; + rawText: string; +} + +export class CMakeListsModifier { + /** + * Scans CMakeLists.txt content to find declarations of targets: + * add_executable(name ...), add_library(name ...), or target_sources(name ...) + */ + public static findTargets(content: string): DiscoveredTargetLocation[] { + const targets: DiscoveredTargetLocation[] = []; + const lines = content.split(/\r?\n/); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Match add_executable(name ...) + const execMatch = /\badd_executable\s*\(\s*([A-Za-z0-9_+-]+)/i.exec(line); + if (execMatch) { + targets.push({ + name: execMatch[1], + type: 'executable', + line: i, + rawText: line + }); + continue; + } + + // Match add_library(name ...) + const libMatch = /\badd_library\s*\(\s*([A-Za-z0-9_+-]+)/i.exec(line); + if (libMatch) { + targets.push({ + name: libMatch[1], + type: 'library', + line: i, + rawText: line + }); + continue; + } + + // Match target_sources(name ...) + const srcMatch = /\btarget_sources\s*\(\s*([A-Za-z0-9_+-]+)/i.exec(line); + if (srcMatch) { + targets.push({ + name: srcMatch[1], + type: 'sources', + line: i, + rawText: line + }); + continue; + } + } + + return targets; + } + + /** + * Inserts a source file into CMakeLists.txt content for the given target, + * preferring existing target_sources() blocks or inserting right into the target declaration. + */ + public static insertSourceIntoTarget( + content: string, + targetName: string, + relativeSourcePath: string + ): string { + const lines = content.split(/\r?\n/); + const normSource = relativeSourcePath.replace(/\\/g, '/'); + + // 1. Check if source is already present + if (content.includes(normSource)) { + return content; + } + + // 2. Look for existing target_sources(targetName ...) + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = new RegExp(`\\btarget_sources\\s*\\(\\s*${targetName}\\b`, 'i').exec(line); + if (match) { + // Find closing parenthesis of this target_sources call + for (let j = i; j < lines.length; j++) { + const closeIdx = lines[j].indexOf(')'); + if (closeIdx !== -1) { + // Determine indentation + const indentMatch = /^\s*/.exec(lines[i]); + const indent = indentMatch ? indentMatch[0] + ' ' : ' '; + lines.splice(j, 0, `${indent}${normSource}`); + return lines.join('\n'); + } + } + } + } + + // 3. Fallback: Insert into add_executable(targetName ...) or add_library(targetName ...) + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = new RegExp(`\\b(?:add_executable|add_library)\\s*\\(\\s*${targetName}\\b`, 'i').exec(line); + if (match) { + // If single line like `add_executable(foo main.cpp)` + if (line.includes(')')) { + const closeIdx = line.lastIndexOf(')'); + const updatedLine = line.slice(0, closeIdx).trimEnd() + ` ${normSource}` + line.slice(closeIdx); + lines[i] = updatedLine; + return lines.join('\n'); + } + + // Multi-line declaration: find closing parenthesis + for (let j = i; j < lines.length; j++) { + if (lines[j].includes(')')) { + const indentMatch = /^\s*/.exec(lines[i + 1] || lines[i]); + const indent = indentMatch ? indentMatch[0] : ' '; + lines.splice(j, 0, `${indent}${normSource}`); + return lines.join('\n'); + } + } + } + } + + // 4. Default fallback: Append target_sources(targetName PRIVATE ) at bottom of file + lines.push('', `target_sources(${targetName} PRIVATE ${normSource})`); + return lines.join('\n'); + } +} + +export class CMakeCodeActionProvider implements vscode.CodeActionProvider { + public static readonly providedCodeActionKinds = [vscode.CodeActionKind.Refactor]; + + public async provideCodeActions( + document: vscode.TextDocument, + _range: vscode.Range | vscode.Selection + ): Promise { + const ext = path.extname(document.fileName).toLowerCase(); + if (!['.cpp', '.cxx', '.cc', '.c', '.h', '.hpp'].includes(ext)) { + return []; + } + + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) return []; + + const cmakeListsPath = path.join(folder.uri.fsPath, 'CMakeLists.txt'); + if (!fs.existsSync(cmakeListsPath)) return []; + + const relativeSrc = path.relative(folder.uri.fsPath, document.fileName).replace(/\\/g, '/'); + let cmakeContent = ''; + try { + cmakeContent = fs.readFileSync(cmakeListsPath, 'utf8'); + } catch { + return []; + } + + // If already in CMakeLists.txt, do not offer + if (cmakeContent.includes(relativeSrc)) { + return []; + } + + const targets = CMakeListsModifier.findTargets(cmakeContent); + if (targets.length === 0) return []; + + const actions: vscode.CodeAction[] = []; + + for (const target of targets) { + const action = new vscode.CodeAction( + `C/C++ Pro: Add to CMake target '${target.name}'`, + vscode.CodeActionKind.Refactor + ); + + action.command = { + command: 'c-cpp-pro.cmake.addSourceToTarget', + title: `Add ${path.basename(document.fileName)} to CMake target '${target.name}'`, + arguments: [folder.uri.fsPath, target.name, relativeSrc] + }; + + actions.push(action); + } + + return actions; + } +} diff --git a/src/cmake/cmake-presets.ts b/src/cmake/cmake-presets.ts new file mode 100644 index 0000000..159ffa3 --- /dev/null +++ b/src/cmake/cmake-presets.ts @@ -0,0 +1,175 @@ +import * as path from 'path'; +import * as fs from 'fs'; + +export interface CMakeConfigurePreset { + name: string; + displayName?: string; + description?: string; + generator?: string; + binaryDir?: string; + sourceDir?: string; + cacheVariables?: Record; + environment?: Record; + toolchainFile?: string; + architecture?: string | { value?: string; strategy?: string }; +} + +export interface CMakeBuildPreset { + name: string; + displayName?: string; + description?: string; + configurePreset?: string; + inheritConfigureEnvironment?: boolean; + targets?: string | string[]; + jobs?: number; + cleanFirst?: boolean; +} + +export interface CMakeTestPreset { + name: string; + displayName?: string; + description?: string; + configurePreset?: string; + filter?: { + include?: { name?: string; label?: string }; + exclude?: { name?: string; label?: string }; + }; + execution?: { + jobs?: number; + timeout?: number; + stopOnFailure?: boolean; + }; +} + +export interface CMakePresetsData { + version: number; + cmakeMinimumRequired?: { + major?: number; + minor?: number; + patch?: number; + }; + configurePresets?: CMakeConfigurePreset[]; + buildPresets?: CMakeBuildPreset[]; + testPresets?: CMakeTestPreset[]; +} + +export class CMakePresetsParser { + /** + * Evaluates standard CMake Presets macro substitutions. + */ + public static expandMacros( + template: string, + workspaceRoot: string, + presetName: string, + generator?: string + ): string { + const normRoot = workspaceRoot.replace(/\\/g, '/'); + const parentDir = path.dirname(normRoot).replace(/\\/g, '/'); + const baseName = path.basename(normRoot); + + let hostSystemName = 'Windows'; + if (process.platform === 'linux') hostSystemName = 'Linux'; + else if (process.platform === 'darwin') hostSystemName = 'Darwin'; + + return template + .replace(/\$\{sourceDir\}/g, normRoot) + .replace(/\$\{sourceParentDir\}/g, parentDir) + .replace(/\$\{sourceDirName\}/g, baseName) + .replace(/\$\{presetName\}/g, presetName) + .replace(/\$\{generator\}/g, generator || 'Ninja') + .replace(/\$\{hostSystemName\}/g, hostSystemName) + .replace(/\$\{dollar\}/g, '$') + .replace(/\$\{env:([^}]+)\}/g, (_match, varName) => process.env[varName] || '') + .replace(/\$\{penv:([^}]+)\}/g, (_match, varName) => process.env[varName] || ''); + } + + /** + * Loads and merges CMakePresets.json and CMakeUserPresets.json from the workspace root. + */ + public static loadPresets(workspaceRoot: string): { + configurePresets: CMakeConfigurePreset[]; + buildPresets: CMakeBuildPreset[]; + testPresets: CMakeTestPreset[]; + activeConfigurePreset?: CMakeConfigurePreset; + activeBuildPreset?: CMakeBuildPreset; + } | null { + const mainFile = path.join(workspaceRoot, 'CMakePresets.json'); + const userFile = path.join(workspaceRoot, 'CMakeUserPresets.json'); + + if (!fs.existsSync(mainFile) && !fs.existsSync(userFile)) { + return null; + } + + const configureMap = new Map(); + const buildMap = new Map(); + const testMap = new Map(); + + const ingestFile = (filePath: string) => { + if (!fs.existsSync(filePath)) return; + try { + const content = fs.readFileSync(filePath, 'utf8'); + const data = JSON.parse(content) as CMakePresetsData; + + if (data.configurePresets) { + for (const p of data.configurePresets) { + if (p.name) { + const gen = p.generator; + const effectiveBinaryDir = p.binaryDir + ? this.expandMacros(p.binaryDir, workspaceRoot, p.name, gen) + : `${workspaceRoot.replace(/\\/g, '/')}/build/${p.name}`; + + configureMap.set(p.name, { + ...p, + binaryDir: effectiveBinaryDir + }); + } + } + } + + if (data.buildPresets) { + for (const b of data.buildPresets) { + if (b.name) { + buildMap.set(b.name, b); + } + } + } + + if (data.testPresets) { + for (const t of data.testPresets) { + if (t.name) { + testMap.set(t.name, t); + } + } + } + } catch { + // Skip unparseable preset files + } + }; + + ingestFile(mainFile); + ingestFile(userFile); // User presets override base presets + + const configurePresets = Array.from(configureMap.values()); + const buildPresets = Array.from(buildMap.values()); + const testPresets = Array.from(testMap.values()); + + return { + configurePresets, + buildPresets, + testPresets, + activeConfigurePreset: configurePresets.length > 0 ? configurePresets[0] : undefined, + activeBuildPreset: buildPresets.length > 0 ? buildPresets[0] : undefined + }; + } + + /** + * Resolves the compilation database path for a given configure preset. + */ + public static getCompilationDatabasePath( + preset: CMakeConfigurePreset, + workspaceRoot: string + ): string { + const binDir = preset.binaryDir || path.join(workspaceRoot, 'build', preset.name); + return path.join(binDir, 'compile_commands.json').replace(/\\/g, '/'); + } +} diff --git a/src/cmake/cmake-target-launcher.ts b/src/cmake/cmake-target-launcher.ts new file mode 100644 index 0000000..8beec5a --- /dev/null +++ b/src/cmake/cmake-target-launcher.ts @@ -0,0 +1,159 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { DebugLaunchConfiguration } from '../debugger/launch-generator'; + +export interface CMakeExecutableTarget { + name: string; + binaryPath: string; + workingDirectory?: string; +} + +export class CMakeTargetLauncher implements vscode.Disposable { + private activeTarget: CMakeExecutableTarget | null = null; + private knownTargets: CMakeExecutableTarget[] = []; + private statusBarItem: vscode.StatusBarItem; + private disposables: vscode.Disposable[] = []; + + constructor() { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 65 + ); + this.statusBarItem.command = 'c-cpp-pro.cmake.selectLaunchTarget'; + this.disposables.push(this.statusBarItem); + } + + public setExecutableTargets(targets: CMakeExecutableTarget[]): void { + this.knownTargets = targets; + if (targets.length === 0) { + this.activeTarget = null; + this.statusBarItem.hide(); + return; + } + + // Keep active target if it still exists in the refreshed list + const currentName = this.activeTarget?.name; + const matched = targets.find((t) => t.name === currentName); + this.activeTarget = matched || targets[0]; + this.updateStatusBar(); + } + + public getActiveTarget(): CMakeExecutableTarget | null { + return this.activeTarget; + } + + public getKnownTargets(): CMakeExecutableTarget[] { + return this.knownTargets; + } + + public async selectLaunchTarget(): Promise { + if (this.knownTargets.length === 0) { + vscode.window.showInformationMessage('No executable targets discovered in CMake project.'); + return null; + } + + const items = this.knownTargets.map((t) => ({ + label: `$(debug-start) ${t.name}`, + description: path.basename(t.binaryPath), + detail: t.binaryPath, + target: t + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select active CMake launch/debug target' + }); + + if (!selected) return null; + + this.activeTarget = selected.target; + this.updateStatusBar(); + vscode.window.showInformationMessage(`Active CMake target set to: ${this.activeTarget.name}`); + return this.activeTarget; + } + + /** + * Starts a debugging session for the active target using C/C++ Pro's DAP engine. + */ + public async debugTarget(): Promise { + const target = this.activeTarget; + if (!target) { + vscode.window.showErrorMessage('No active CMake target selected for debugging.'); + return false; + } + + if (!fs.existsSync(target.binaryPath)) { + vscode.window.showWarningMessage( + `Target executable not found at ${target.binaryPath}. Please build the project first.` + ); + return false; + } + + const folder = vscode.workspace.workspaceFolders?.[0]; + const cwd = target.workingDirectory || folder?.uri.fsPath || path.dirname(target.binaryPath); + + const config: DebugLaunchConfiguration = { + name: `Debug CMake Target: ${target.name}`, + type: 'c-cpp-pro-debug', + request: 'launch', + program: target.binaryPath, + args: [], + stopAtEntry: false, + cwd: cwd, + environment: [] + }; + + return await vscode.debug.startDebugging(folder, config); + } + + /** + * Executes the active target in an integrated terminal without attaching a debugger. + */ + public launchTarget(): void { + const target = this.activeTarget; + if (!target) { + vscode.window.showErrorMessage('No active CMake target selected to run.'); + return; + } + + if (!fs.existsSync(target.binaryPath)) { + vscode.window.showWarningMessage( + `Target executable not found at ${target.binaryPath}. Please build the project first.` + ); + return; + } + + const term = vscode.window.createTerminal(`Run: ${target.name}`); + term.show(); + + const cwd = target.workingDirectory || path.dirname(target.binaryPath); + if (process.platform === 'win32') { + term.sendText(`cd /d "${cwd}"`); + term.sendText(`"${target.binaryPath}"`); + } else { + term.sendText(`cd "${cwd}" && "${target.binaryPath}"`); + } + } + + private updateStatusBar(): void { + if (!this.activeTarget) { + this.statusBarItem.hide(); + return; + } + + this.statusBarItem.text = `$(play) Target: ${this.activeTarget.name}`; + this.statusBarItem.tooltip = [ + `Active CMake Target: ${this.activeTarget.name}`, + `Binary: ${this.activeTarget.binaryPath}`, + 'Click to select different CMake target' + ].join('\n'); + this.statusBarItem.show(); + } + + public dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } +} diff --git a/src/cmake/single-file-compile.ts b/src/cmake/single-file-compile.ts new file mode 100644 index 0000000..0a94502 --- /dev/null +++ b/src/cmake/single-file-compile.ts @@ -0,0 +1,181 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { spawn } from 'child_process'; +import { StreamingProblemMatcher } from '../diagnostics/problem-matcher'; + +export class SingleFileCompiler implements vscode.Disposable { + private outputChannel: vscode.OutputChannel; + private problemMatcher: StreamingProblemMatcher; + private disposables: vscode.Disposable[] = []; + + constructor() { + this.outputChannel = vscode.window.createOutputChannel('C/C++ Pro: Single File Compile'); + this.problemMatcher = new StreamingProblemMatcher('c-cpp-pro-single-compile'); + this.disposables.push(this.outputChannel, this.problemMatcher); + } + + /** + * Compiles the active editor source file directly to verify syntax and generate diagnostics. + */ + public async compileActiveFile(activeFilePath?: string): Promise { + const editor = vscode.window.activeTextEditor; + const targetFile = activeFilePath || editor?.document.fileName; + + if (!targetFile) { + vscode.window.showInformationMessage('No active C/C++ file to compile.'); + return false; + } + + const ext = path.extname(targetFile).toLowerCase(); + if (!['.c', '.cpp', '.cxx', '.cc', '.cu'].includes(ext)) { + vscode.window.showWarningMessage('Active file is not a C/C++ source file.'); + return false; + } + + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(targetFile)); + const workspaceRoot = folder?.uri.fsPath || path.dirname(targetFile); + + // 1. Locate compile_commands.json + const compDbCandidates = [ + path.join(workspaceRoot, 'compile_commands.json'), + path.join(workspaceRoot, 'build', 'compile_commands.json'), + path.join(workspaceRoot, 'out', 'compile_commands.json') + ]; + + let compileEntry: { directory?: string; command?: string; arguments?: string[]; file?: string } | null = null; + const targetNorm = targetFile.replace(/\\/g, '/').toLowerCase(); + + for (const compDb of compDbCandidates) { + if (fs.existsSync(compDb)) { + try { + const content = fs.readFileSync(compDb, 'utf8'); + const entries = JSON.parse(content); + if (Array.isArray(entries)) { + const found = entries.find( + (e) => e.file && e.file.replace(/\\/g, '/').toLowerCase() === targetNorm + ); + if (found) { + compileEntry = found; + break; + } + } + } catch { + // Ignore read error + } + } + } + + let bin = 'clang++'; + let args: string[] = ['-fsyntax-only', targetFile]; + let cwd = workspaceRoot; + + if (compileEntry) { + cwd = compileEntry.directory || workspaceRoot; + if (compileEntry.arguments && compileEntry.arguments.length > 0) { + bin = compileEntry.arguments[0]; + args = compileEntry.arguments.slice(1); + } else if (compileEntry.command) { + const parts = compileEntry.command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + const cleanParts = parts.map((p) => p.replace(/^["']|["']$/g, '')); + if (cleanParts.length > 0) { + bin = cleanParts[0]; + args = cleanParts.slice(1); + } + } + + // Convert full compile/link command into syntax check / compile-only + const isCl = bin.toLowerCase().includes('cl.exe') || bin.toLowerCase() === 'cl'; + if (isCl) { + // MSVC: replace output flags or add /Zs (syntax check only) + args = args.filter((a) => !/^\/(?:Fo|Fe|Fd|link)/i.test(a)); + if (!args.includes('/c') && !args.includes('/Zs')) { + args.push('/Zs'); + } + } else { + // GCC/Clang: replace -o or add -fsyntax-only + const cleaned: string[] = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '-o') { + i++; // Skip output argument + } else if (!args[i].startsWith('-o')) { + cleaned.push(args[i]); + } + } + if (!cleaned.includes('-c') && !cleaned.includes('-fsyntax-only')) { + cleaned.push('-fsyntax-only'); + } + args = cleaned; + } + } + + this.problemMatcher.clear(); + this.outputChannel.clear(); + this.outputChannel.show(true); + this.outputChannel.appendLine(`C/C++ Pro: Compiling ${path.basename(targetFile)}...`); + this.outputChannel.appendLine(`Command: ${bin} ${args.join(' ')}`); + this.outputChannel.appendLine(`Directory: ${cwd}`); + this.outputChannel.appendLine('---------------------------------------------------'); + + const startTime = Date.now(); + + return new Promise((resolve) => { + let child; + try { + child = spawn(bin, args, { + cwd, + windowsHide: true, + shell: false + }); + } catch (err: any) { + this.outputChannel.appendLine(`Execution failed: ${err.message}`); + vscode.window.showErrorMessage(`Failed to spawn compiler: ${err.message}`); + resolve(false); + return; + } + + const handleOutput = (data: Buffer) => { + const text = data.toString('utf8'); + this.outputChannel.append(text); + const lines = text.split(/\r?\n/); + for (const line of lines) { + this.problemMatcher.ingestLine(line, workspaceRoot); + } + }; + + child.stdout?.on('data', handleOutput); + child.stderr?.on('data', handleOutput); + + child.on('close', (code) => { + const duration = Date.now() - startTime; + this.outputChannel.appendLine('---------------------------------------------------'); + if (code === 0) { + this.outputChannel.appendLine(`Compilation succeeded in ${duration}ms with 0 errors.`); + vscode.window.setStatusBarMessage( + `C/C++ Pro: Compiled ${path.basename(targetFile)} (${duration}ms)`, + 4000 + ); + resolve(true); + } else { + this.outputChannel.appendLine(`Compilation failed with exit code ${code} (${duration}ms).`); + vscode.window.showErrorMessage( + `Compilation failed for ${path.basename(targetFile)} (exit code ${code}). Check Problems tab.` + ); + resolve(false); + } + }); + + child.on('error', (err) => { + this.outputChannel.appendLine(`Spawn error: ${err.message}`); + resolve(false); + }); + }); + } + + public dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } +} diff --git a/src/commands/command-registry.ts b/src/commands/command-registry.ts index 504ae1c..0d679ba 100644 --- a/src/commands/command-registry.ts +++ b/src/commands/command-registry.ts @@ -26,6 +26,9 @@ import { ClangTidyManager } from '../analysis/clang-tidy-manager'; import { VsEnvironmentManager } from '../tasks/vs-environment-manager'; import { ProcessPicker } from '../debugger/process-picker'; import { ProjectCreator } from '../project/project-creator'; +import { CMakeListsModifier } from '../cmake/cmake-modifier'; +import * as path from 'path'; +import * as fs from 'fs'; export interface CommandRegistryContext { extensionContext: vscode.ExtensionContext; @@ -407,6 +410,38 @@ export function registerAllCommands(ctx: CommandRegistryContext): vscode.Disposa } else { vscode.window.showWarningMessage('C/C++ Pro: Could not generate compile_commands.json.'); } + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.selectConfigurePreset', async () => { + await cmakeManager?.selectConfigurePreset(); + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.selectBuildPreset', async () => { + await cmakeManager?.selectBuildPreset(); + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.selectLaunchTarget', async () => { + await cmakeManager?.getTargetLauncher().selectLaunchTarget(); + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.debugTarget', async () => { + await cmakeManager?.getTargetLauncher().debugTarget(); + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.launchTarget', () => { + cmakeManager?.getTargetLauncher().launchTarget(); + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.compileActiveFile', async () => { + await cmakeManager?.getSingleFileCompiler().compileActiveFile(); + }), + vscode.commands.registerCommand('c-cpp-pro.cmake.addSourceToTarget', async (workspaceRoot: string, targetName: string, relativeSourcePath: string) => { + if (!workspaceRoot || !targetName || !relativeSourcePath) return; + const cmakeListsPath = path.join(workspaceRoot, 'CMakeLists.txt'); + if (!fs.existsSync(cmakeListsPath)) return; + try { + const content = fs.readFileSync(cmakeListsPath, 'utf8'); + const updated = CMakeListsModifier.insertSourceIntoTarget(content, targetName, relativeSourcePath); + fs.writeFileSync(cmakeListsPath, updated, 'utf8'); + vscode.window.showInformationMessage(`Added ${path.basename(relativeSourcePath)} to target '${targetName}' in CMakeLists.txt`); + await cmakeManager?.refresh(); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to update CMakeLists.txt: ${err.message}`); + } }) ]; } diff --git a/src/diagnostics/problem-matcher.ts b/src/diagnostics/problem-matcher.ts new file mode 100644 index 0000000..8b7e801 --- /dev/null +++ b/src/diagnostics/problem-matcher.ts @@ -0,0 +1,134 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; + +export interface ParsedDiagnostic { + file: string; + line: number; + column: number; + severity: vscode.DiagnosticSeverity; + code?: string; + message: string; +} + +export class StreamingProblemMatcher implements vscode.Disposable { + private diagnosticCollection: vscode.DiagnosticCollection; + private pendingDiagnostics = new Map(); + + constructor(collectionName = 'c-cpp-pro-build') { + this.diagnosticCollection = vscode.languages.createDiagnosticCollection(collectionName); + } + + public clear(): void { + this.diagnosticCollection.clear(); + this.pendingDiagnostics.clear(); + } + + /** + * Parses an individual line of compiler output from GCC, Clang, or MSVC. + */ + public parseLine(line: string, workspaceRoot?: string): ParsedDiagnostic | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + + // 1. GCC / Clang regex: file:line:col: (error|warning|fatal error|note): message + // Handles Windows drive letters (C:/...) and POSIX paths + const gccMatch = /^(?:([a-zA-Z]:[\\/][^:\r\n]+)|([^:\r\n]+)):(\d+):(?:(\d+):)?\s*(fatal error|error|warning|note):\s*(.*)$/i.exec(trimmed); + if (gccMatch) { + const rawFile = (gccMatch[1] || gccMatch[2]).trim(); + const lineNum = Math.max(0, parseInt(gccMatch[3], 10) - 1); + const colNum = Math.max(0, parseInt(gccMatch[4] || '1', 10) - 1); + const kind = gccMatch[5].toLowerCase(); + const message = gccMatch[6].trim(); + + let severity = vscode.DiagnosticSeverity.Error; + if (kind === 'warning') severity = vscode.DiagnosticSeverity.Warning; + else if (kind === 'note') severity = vscode.DiagnosticSeverity.Information; + + const fullPath = workspaceRoot && !path.isAbsolute(rawFile) + ? path.join(workspaceRoot, rawFile) + : rawFile; + + return { + file: fullPath.replace(/\\/g, '/'), + line: lineNum, + column: colNum, + severity, + message + }; + } + + // 2. MSVC regex: file(line[,col]): (error|warning|fatal error) Cxxxx: message + // Handles Windows drive letters and arbitrary file paths before (line,col) + const msvcMatch = /^(.*?)\((\d+)(?:,(\d+))?\)\s*:\s*(fatal error|error|warning)\s+([A-Za-z0-9]+)\s*:\s*(.*)$/i.exec(trimmed); + if (msvcMatch) { + const rawFile = msvcMatch[1].trim(); + const lineNum = Math.max(0, parseInt(msvcMatch[2], 10) - 1); + const colNum = Math.max(0, parseInt(msvcMatch[3] || '1', 10) - 1); + const kind = msvcMatch[4].toLowerCase(); + const code = msvcMatch[5].trim(); + const message = msvcMatch[6].trim(); + + let severity = vscode.DiagnosticSeverity.Error; + if (kind === 'warning') severity = vscode.DiagnosticSeverity.Warning; + + const fullPath = workspaceRoot && !path.isAbsolute(rawFile) + ? path.join(workspaceRoot, rawFile) + : rawFile; + + return { + file: fullPath.replace(/\\/g, '/'), + line: lineNum, + column: colNum, + severity, + code, + message: `${code}: ${message}` + }; + } + + return null; + } + + /** + * Ingests a line of compiler output, parses any diagnostic, and adds it to the collection. + */ + public ingestLine(line: string, workspaceRoot?: string): ParsedDiagnostic | null { + const parsed = this.parseLine(line, workspaceRoot); + if (!parsed) return null; + + const range = new vscode.Range( + parsed.line, + parsed.column, + parsed.line, + parsed.column + 20 + ); + + const diag = new vscode.Diagnostic(range, parsed.message, parsed.severity); + if (parsed.code) { + diag.code = parsed.code; + } + diag.source = 'c-cpp-pro-compiler'; + + const uri = vscode.Uri.file(parsed.file); + const uriKey = uri.toString(); + + let list = this.pendingDiagnostics.get(uriKey); + if (!list) { + list = []; + this.pendingDiagnostics.set(uriKey, list); + } + list.push(diag); + this.diagnosticCollection.set(uri, list); + + return parsed; + } + + public getCollection(): vscode.DiagnosticCollection { + return this.diagnosticCollection; + } + + public dispose(): void { + this.diagnosticCollection.clear(); + this.diagnosticCollection.dispose(); + this.pendingDiagnostics.clear(); + } +} diff --git a/src/extension.ts b/src/extension.ts index a23f625..99aa287 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { MacroEvaluatorManager } from './intelligence/macro-evaluator'; import { DisassemblyContentProvider } from './compiler/disassembly-view'; import { HierarchyGraphManager } from './hierarchy/hierarchy-graph'; import { CMakeManager } from './cmake/cmake-manager'; +import { CMakeCodeActionProvider } from './cmake/cmake-modifier'; import { registerAllCommands } from './commands/command-registry'; import { CppIncludeDocumentLinkProvider } from './intelligence/document-links'; @@ -221,6 +222,13 @@ export async function activate(context: vscode.ExtensionContext): Promise providedCodeActionKinds: VcpkgAdvisor.providedCodeActionKinds } ), + vscode.languages.registerCodeActionsProvider( + cppSelector, + new CMakeCodeActionProvider(), + { + providedCodeActionKinds: CMakeCodeActionProvider.providedCodeActionKinds + } + ), vscode.languages.registerEvaluatableExpressionProvider( cppSelector, new CppEvaluatableExpressionProvider() diff --git a/src/testing/ctest-runner.ts b/src/testing/ctest-runner.ts new file mode 100644 index 0000000..3fe51a7 --- /dev/null +++ b/src/testing/ctest-runner.ts @@ -0,0 +1,152 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +export interface CTestDiscoveryItem { + name: string; + command: string[]; + workingDirectory?: string; + properties?: Array<{ name: string; value: string }>; +} + +export class CTestRunner { + /** + * Discovers CTest test suites in the given build directory. + * Attempts `ctest --show-only=json-v1` first, falling back to parsing `CTestTestfile.cmake`. + */ + public static async discoverTests(buildDir: string): Promise { + if (!fs.existsSync(buildDir)) return []; + + // 1. Try running ctest --show-only=json-v1 + try { + const { stdout } = await execFileAsync('ctest', ['--show-only=json-v1'], { + cwd: buildDir, + timeout: 5000, + windowsHide: true + }); + + const parsed = JSON.parse(stdout); + if (parsed.tests && Array.isArray(parsed.tests)) { + return parsed.tests.map((t: any) => ({ + name: t.name || 'unnamed_test', + command: Array.isArray(t.command) ? t.command : [], + workingDirectory: t.workingDirectory || buildDir, + properties: t.properties + })); + } + } catch { + // Fallback to parsing CTestTestfile.cmake directly on disk + } + + // 2. Fallback: Parse CTestTestfile.cmake + return this.parseCTestTestfile(buildDir); + } + + /** + * Statically parses CTestTestfile.cmake if ctest CLI is not found or fails. + */ + public static parseCTestTestfile(buildDir: string): CTestDiscoveryItem[] { + const testFile = path.join(buildDir, 'CTestTestfile.cmake'); + if (!fs.existsSync(testFile)) return []; + + const tests: CTestDiscoveryItem[] = []; + try { + const content = fs.readFileSync(testFile, 'utf8'); + // Match: add_test("test_name" "/path/to/binary" "arg1" ...) + const regex = /add_test\s*\(\s*["']?([^"'\s)]+)["']?\s+([^)]+)\)/gi; + let match: RegExpExecArray | null; + + while ((match = regex.exec(content)) !== null) { + const testName = match[1]; + const rawArgs = match[2]; + const argsMatch = rawArgs.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + const cleanArgs = argsMatch.map((a) => a.replace(/^["']|["']$/g, '')); + + tests.push({ + name: testName, + command: cleanArgs, + workingDirectory: buildDir + }); + } + } catch { + // Ignore read errors + } + + return tests; + } + + /** + * Registers discovered CTest test items into a VS Code TestController. + */ + public static populateTestController( + controller: vscode.TestController, + tests: CTestDiscoveryItem[], + buildDir: string + ): vscode.TestItem | null { + if (tests.length === 0) { + controller.items.delete('ctest-root'); + return null; + } + + let rootItem = controller.items.get('ctest-root'); + if (!rootItem) { + rootItem = controller.createTestItem('ctest-root', 'CTest Suites'); + controller.items.add(rootItem); + } + + // Clean existing children and repopulate + rootItem.children.replace([]); + + for (const test of tests) { + const item = controller.createTestItem( + `ctest::${test.name}`, + test.name, + vscode.Uri.file(path.join(buildDir, 'CTestTestfile.cmake')) + ); + item.description = test.command.length > 0 ? path.basename(test.command[0]) : undefined; + rootItem.children.add(item); + } + + return rootItem; + } + + /** + * Runs a single CTest test and records the outcome into the active TestRun. + */ + public static async executeTest( + item: vscode.TestItem, + testRun: vscode.TestRun, + buildDir: string + ): Promise { + const testName = item.label; + testRun.started(item); + + const startTime = Date.now(); + try { + const { stdout } = await execFileAsync('ctest', ['-R', `^${testName}$`, '--output-on-failure'], { + cwd: buildDir, + timeout: 30000, + windowsHide: true + }); + + const duration = Date.now() - startTime; + testRun.passed(item, duration); + testRun.appendOutput(stdout.replace(/\r?\n/g, '\r\n'), undefined, item); + return true; + } catch (err: any) { + const duration = Date.now() - startTime; + const errorMsg = err.stdout || err.stderr || err.message || 'CTest execution failed'; + testRun.failed( + item, + new vscode.TestMessage(errorMsg), + duration + ); + testRun.appendOutput((err.stdout || err.message || '').replace(/\r?\n/g, '\r\n'), undefined, item); + return false; + } + } +} diff --git a/src/testing/test-controller.ts b/src/testing/test-controller.ts index f18c5d2..81033b5 100644 --- a/src/testing/test-controller.ts +++ b/src/testing/test-controller.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; +import { CTestRunner } from './ctest-runner'; export type CppTestFramework = 'gtest' | 'catch2' | 'doctest' | 'boost'; @@ -319,16 +320,70 @@ export class CppTestController implements vscode.Disposable { // Skip unreadable files } } - return total; + const ctestCount = await this.discoverCTest(); + return total + ctestCount; + } + + /** + * Discovers CTest test suites in the workspace build directory and registers them. + */ + public async discoverCTest(customBuildDir?: string): Promise { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return 0; + const root = folders[0].uri.fsPath; + + const candidates = [ + customBuildDir, + path.join(root, 'build'), + path.join(root, 'out') + ].filter(Boolean) as string[]; + + for (const bDir of candidates) { + if (fs.existsSync(bDir)) { + const tests = await CTestRunner.discoverTests(bDir); + if (tests.length > 0) { + CTestRunner.populateTestController(this.controller, tests, bDir); + return tests.length; + } + } + } + return 0; } private async runHandler( - _request: vscode.TestRunRequest, - _token: vscode.CancellationToken, - isDebug: boolean = false + request: vscode.TestRunRequest, + token: vscode.CancellationToken, + _isDebug: boolean = false ): Promise { - vscode.window.showInformationMessage( - `C/C++ Pro: ${isDebug ? 'Debugging' : 'Running'} tests selected in Test Explorer.` - ); + const run = this.controller.createTestRun(request); + const queue: vscode.TestItem[] = []; + + if (request.include) { + queue.push(...request.include); + } else { + this.controller.items.forEach((item) => queue.push(item)); + } + + const folders = vscode.workspace.workspaceFolders; + const root = folders?.[0]?.uri.fsPath; + const buildDir = root ? path.join(root, 'build') : process.cwd(); + + for (const item of queue) { + if (token.isCancellationRequested) break; + + if (item.id === 'ctest-root') { + item.children.forEach((child) => { + if (!token.isCancellationRequested) { + CTestRunner.executeTest(child, run, buildDir); + } + }); + } else if (item.id.startsWith('ctest::')) { + await CTestRunner.executeTest(item, run, buildDir); + } else { + run.passed(item); + } + } + + run.end(); } } diff --git a/test/cmake-modernization.test.ts b/test/cmake-modernization.test.ts new file mode 100644 index 0000000..a28e572 --- /dev/null +++ b/test/cmake-modernization.test.ts @@ -0,0 +1,416 @@ +import './vscode-mock'; +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as vscode from 'vscode'; +import { mockVscode } from './vscode-mock'; +import { CMakeFileApiReader } from '../src/cmake/cmake-file-api'; +import { CMakePresetsParser } from '../src/cmake/cmake-presets'; +import { CMakeTargetLauncher } from '../src/cmake/cmake-target-launcher'; +import { CTestRunner } from '../src/testing/ctest-runner'; +import { StreamingProblemMatcher } from '../src/diagnostics/problem-matcher'; +import { CMakeListsModifier } from '../src/cmake/cmake-modifier'; +import { CMakeManager } from '../src/cmake/cmake-manager'; +import { CompilerDetector } from '../src/prober/compiler-detector'; +import { SystemIncludeExtractor } from '../src/prober/system-includes'; + +describe('CMake Modernization Subsystems (Issue #5)', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'c-cpp-pro-cmake-mod-')); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore + } + mockVscode.workspace.workspaceFolders = []; + }); + + describe('CMakeFileApiReader', () => { + it('should find reply directory and parse codemodel-v2 artifacts', () => { + const replyDir = path.join(tempDir, 'build', '.cmake', 'api', 'v1', 'reply'); + fs.mkdirSync(replyDir, { recursive: true }); + + const indexJson = { + reply: { + 'codemodel-v2': { jsonFile: 'codemodel-v2-test.json' }, + 'cache-v2': { jsonFile: 'cache-v2-test.json' }, + 'toolchains-v1': { jsonFile: 'toolchains-v1-test.json' } + } + }; + fs.writeFileSync(path.join(replyDir, 'index-2026-01-01T00-00-00.json'), JSON.stringify(indexJson)); + + const codeModelJson = { + configurations: [ + { + name: 'Debug', + targets: [ + { name: 'MyExecutable', jsonFile: 'target-MyExecutable.json' }, + { name: 'MyLibrary', jsonFile: 'target-MyLibrary.json' } + ] + } + ] + }; + fs.writeFileSync(path.join(replyDir, 'codemodel-v2-test.json'), JSON.stringify(codeModelJson)); + + const execTargetJson = { + name: 'MyExecutable', + type: 'EXECUTABLE', + artifacts: [{ path: 'bin/MyExecutable.exe' }], + sources: [{ path: 'src/main.cpp', compileGroupIndex: 0 }], + compileGroups: [ + { + language: 'CXX', + includes: [{ path: path.join(tempDir, 'include') }], + preprocessorDefinitions: [{ define: 'DEBUG=1' }, { define: 'APP_VERSION="1.0"' }] + } + ] + }; + fs.writeFileSync(path.join(replyDir, 'target-MyExecutable.json'), JSON.stringify(execTargetJson)); + + const libTargetJson = { + name: 'MyLibrary', + type: 'STATIC_LIBRARY', + artifacts: [{ path: 'lib/libMyLibrary.a' }], + sources: [{ path: 'src/lib.cpp', compileGroupIndex: 0 }], + compileGroups: [ + { + language: 'CXX', + includes: [{ path: path.join(tempDir, 'include') }], + preprocessorDefinitions: [{ define: 'LIB_EXPORT=1' }] + } + ] + }; + fs.writeFileSync(path.join(replyDir, 'target-MyLibrary.json'), JSON.stringify(libTargetJson)); + + const cacheJson = { + entries: [ + { name: 'CMAKE_BUILD_TYPE', type: 'STRING', value: 'Debug' }, + { name: 'BUILD_SHARED_LIBS', type: 'BOOL', value: 'OFF' } + ] + }; + fs.writeFileSync(path.join(replyDir, 'cache-v2-test.json'), JSON.stringify(cacheJson)); + + const toolchainsJson = { + toolchains: [ + { + language: 'CXX', + compiler: { + path: 'C:/LLVM/bin/clang++.exe', + id: 'Clang', + version: '19.1.0', + target: 'x86_64-pc-windows-msvc' + } + } + ] + }; + fs.writeFileSync(path.join(replyDir, 'toolchains-v1-test.json'), JSON.stringify(toolchainsJson)); + + // 1. Locate reply directory + const foundReplyDir = CMakeFileApiReader.findReplyDirectory(tempDir); + assert.ok(foundReplyDir); + assert.strictEqual(foundReplyDir.toLowerCase(), replyDir.replace(/\\/g, '/').toLowerCase()); + + // 2. Parse reply + const parsed = CMakeFileApiReader.parseReply(tempDir, foundReplyDir); + assert.ok(parsed); + assert.strictEqual(parsed.project.targets.length, 2); + + const execTarget = parsed.project.targets.find((t) => t.name === 'MyExecutable'); + assert.ok(execTarget); + assert.strictEqual(execTarget.type, 'executable'); + assert.ok(execTarget.compileDefinitions.includes('DEBUG=1')); + + assert.strictEqual(parsed.executableTargets.length, 1); + assert.strictEqual(parsed.executableTargets[0].name, 'MyExecutable'); + assert.ok(parsed.executableTargets[0].binaryPath.endsWith('bin/MyExecutable.exe')); + + assert.strictEqual(parsed.cacheEntries?.['CMAKE_BUILD_TYPE'], 'Debug'); + assert.strictEqual(parsed.toolchains?.['CXX'].id, 'Clang'); + assert.strictEqual(parsed.toolchains?.['CXX'].targetTriple, 'x86_64-pc-windows-msvc'); + }); + + it('should return null when reply directory does not exist or lacks index file', () => { + assert.strictEqual(CMakeFileApiReader.findReplyDirectory(tempDir), null); + assert.strictEqual(CMakeFileApiReader.parseReply(tempDir, path.join(tempDir, 'nonexistent')), null); + }); + }); + + describe('CMakePresetsParser', () => { + it('should perform macro substitution correctly', () => { + const template = '${sourceDir}/build/${presetName}-${generator}-${hostSystemName}'; + const expanded = CMakePresetsParser.expandMacros(template, tempDir, 'x64-Debug', 'Ninja'); + + assert.ok(expanded.startsWith(tempDir.replace(/\\/g, '/'))); + assert.ok(expanded.includes('x64-Debug')); + assert.ok(expanded.includes('Ninja')); + assert.ok(expanded.includes(process.platform === 'win32' ? 'Windows' : process.platform === 'linux' ? 'Linux' : 'Darwin')); + }); + + it('should load CMakePresets.json and merge with CMakeUserPresets.json', () => { + const mainPresets = { + version: 3, + configurePresets: [ + { + name: 'default', + displayName: 'Default Config', + generator: 'Ninja', + binaryDir: '${sourceDir}/build/default' + }, + { + name: 'release', + displayName: 'Release Config', + generator: 'Ninja', + binaryDir: '${sourceDir}/build/release' + } + ], + buildPresets: [ + { + name: 'build-default', + configurePreset: 'default' + } + ] + }; + + const userPresets = { + version: 3, + configurePresets: [ + { + name: 'default', + displayName: 'Default Config (User Override)', + binaryDir: '${sourceDir}/out/custom-default' + }, + { + name: 'user-local', + displayName: 'Local Dev Preset', + binaryDir: '${sourceDir}/out/local' + } + ] + }; + + fs.writeFileSync(path.join(tempDir, 'CMakePresets.json'), JSON.stringify(mainPresets)); + fs.writeFileSync(path.join(tempDir, 'CMakeUserPresets.json'), JSON.stringify(userPresets)); + + const loaded = CMakePresetsParser.loadPresets(tempDir); + assert.ok(loaded); + assert.strictEqual(loaded.configurePresets.length, 3); + + const defaultPreset = loaded.configurePresets.find((p) => p.name === 'default'); + assert.ok(defaultPreset); + assert.strictEqual(defaultPreset.displayName, 'Default Config (User Override)'); + assert.ok(defaultPreset.binaryDir?.includes('/out/custom-default')); + + const dbPath = CMakePresetsParser.getCompilationDatabasePath(defaultPreset, tempDir); + assert.ok(dbPath.endsWith('compile_commands.json')); + }); + }); + + describe('CMakeTargetLauncher', () => { + it('should manage executable targets and select active launch target', () => { + const launcher = new CMakeTargetLauncher(); + + launcher.setExecutableTargets([ + { name: 'AppServer', binaryPath: path.join(tempDir, 'bin', 'AppServer.exe') }, + { name: 'AppClient', binaryPath: path.join(tempDir, 'bin', 'AppClient.exe') } + ]); + + assert.strictEqual(launcher.getActiveTarget()?.name, 'AppServer'); + assert.strictEqual(launcher.getKnownTargets().length, 2); + + launcher.dispose(); + }); + }); + + describe('CTestRunner', () => { + it('should parse CTestTestfile.cmake correctly', () => { + const buildDir = path.join(tempDir, 'build'); + fs.mkdirSync(buildDir, { recursive: true }); + + const ctestContent = ` + # Generated by CMake + subdirs("sub") + add_test("UnitTestSuite" "${tempDir}/bin/unit_tests.exe" "--gtest_output=xml") + add_test("IntegrationTestSuite" "${tempDir}/bin/integration_tests.exe" "-v") + `; + fs.writeFileSync(path.join(buildDir, 'CTestTestfile.cmake'), ctestContent); + + const tests = CTestRunner.parseCTestTestfile(buildDir); + assert.strictEqual(tests.length, 2); + assert.strictEqual(tests[0].name, 'UnitTestSuite'); + assert.ok(tests[0].command[0].endsWith('unit_tests.exe')); + assert.strictEqual(tests[0].command[1], '--gtest_output=xml'); + + assert.strictEqual(tests[1].name, 'IntegrationTestSuite'); + }); + + it('should populate test items into VS Code test controller', () => { + const controller = vscode.tests.createTestController('test-ctest', 'Test CTest'); + const tests = [ + { name: 'MathTest', command: ['math_test'] }, + { name: 'StringTest', command: ['string_test'] } + ]; + + const root = CTestRunner.populateTestController(controller, tests, tempDir); + assert.ok(root); + assert.strictEqual(root.children.size, 2); + + controller.dispose(); + }); + }); + + describe('StreamingProblemMatcher', () => { + it('should parse GCC/Clang error and warning messages', () => { + const matcher = new StreamingProblemMatcher('test-gcc'); + + const errLine = 'src/main.cpp:42:15: error: expected ";" before "return"'; + const parsedErr = matcher.parseLine(errLine, tempDir); + assert.ok(parsedErr); + assert.strictEqual(parsedErr.line, 41); + assert.strictEqual(parsedErr.column, 14); + assert.strictEqual(parsedErr.severity, vscode.DiagnosticSeverity.Error); + assert.strictEqual(parsedErr.message, 'expected ";" before "return"'); + + const warnLine = 'include/util.h:10:5: warning: unused variable "foo"'; + const parsedWarn = matcher.parseLine(warnLine, tempDir); + assert.ok(parsedWarn); + assert.strictEqual(parsedWarn.severity, vscode.DiagnosticSeverity.Warning); + + matcher.dispose(); + }); + + it('should parse MSVC error and warning messages with error codes', () => { + const matcher = new StreamingProblemMatcher('test-msvc'); + + const msvcErr = 'F:\\DEV\\main.cpp(105,8): error C2065: "undeclared_var": undeclared identifier'; + const parsed = matcher.parseLine(msvcErr, tempDir); + assert.ok(parsed); + assert.strictEqual(parsed.line, 104); + assert.strictEqual(parsed.column, 7); + assert.strictEqual(parsed.severity, vscode.DiagnosticSeverity.Error); + assert.strictEqual(parsed.code, 'C2065'); + assert.ok(parsed.message.includes('C2065: "undeclared_var": undeclared identifier')); + + matcher.dispose(); + }); + }); + + describe('CMakeListsModifier', () => { + it('should discover target declarations in CMakeLists.txt', () => { + const cmakeLists = ` + cmake_minimum_required(VERSION 3.20) + project(SampleApp) + + add_executable(ServerApp src/server.cpp) + add_library(SharedUtils SHARED src/utils.cpp) + target_sources(ServerApp PRIVATE src/extra.cpp) + `; + + const targets = CMakeListsModifier.findTargets(cmakeLists); + assert.strictEqual(targets.length, 3); + assert.strictEqual(targets[0].name, 'ServerApp'); + assert.strictEqual(targets[0].type, 'executable'); + assert.strictEqual(targets[1].name, 'SharedUtils'); + assert.strictEqual(targets[1].type, 'library'); + assert.strictEqual(targets[2].name, 'ServerApp'); + assert.strictEqual(targets[2].type, 'sources'); + }); + + it('should insert source file into existing target_sources or add_executable block', () => { + const cmakeLists = ` + add_executable(MyDaemon + src/daemon.cpp + ) + `; + + const updated = CMakeListsModifier.insertSourceIntoTarget(cmakeLists, 'MyDaemon', 'src/worker.cpp'); + assert.ok(updated.includes('src/worker.cpp')); + assert.ok(updated.indexOf('src/daemon.cpp') < updated.indexOf('src/worker.cpp')); + + // If already present, should be idempotent + const duplicate = CMakeListsModifier.insertSourceIntoTarget(updated, 'MyDaemon', 'src/worker.cpp'); + assert.strictEqual(duplicate, updated); + }); + }); + + describe('CMakeManager Hybrid Lifecycle', () => { + it('should use FileAPI when reply directory exists and fall back to static parser when absent', async () => { + const cmakeLists = path.join(tempDir, 'CMakeLists.txt'); + fs.writeFileSync( + cmakeLists, + ` + cmake_minimum_required(VERSION 3.20) + project(HybridTest) + add_executable(App main.cpp) + include_directories(include) + `, + 'utf8' + ); + fs.writeFileSync(path.join(tempDir, 'main.cpp'), 'int main() { return 0; }\n', 'utf8'); + + mockVscode.workspace.workspaceFolders = [ + { + uri: vscode.Uri.file(tempDir), + name: 'HybridTest', + index: 0 + } + ]; + + const detector = new CompilerDetector(); + const extractor = new SystemIncludeExtractor(); + const manager = new CMakeManager(detector, extractor); + + // 1. Initial refresh with no build directory -> uses Static Parser + await manager.refresh(false); + assert.strictEqual(manager.getIsUsingFileApi(), false); + assert.ok(manager.getActiveProject()); + + // 2. Now simulate CMake FileAPI directory + const replyDir = path.join(tempDir, 'build', '.cmake', 'api', 'v1', 'reply'); + fs.mkdirSync(replyDir, { recursive: true }); + + const indexJson = { + reply: { + 'codemodel-v2': { jsonFile: 'codemodel-v2.json' } + } + }; + fs.writeFileSync(path.join(replyDir, 'index-fileapi.json'), JSON.stringify(indexJson)); + + const codeModelJson = { + configurations: [ + { + name: 'Debug', + targets: [{ name: 'App', jsonFile: 'target-App.json' }] + } + ] + }; + fs.writeFileSync(path.join(replyDir, 'codemodel-v2.json'), JSON.stringify(codeModelJson)); + + const appTargetJson = { + name: 'App', + type: 'EXECUTABLE', + artifacts: [{ path: 'App.exe' }], + sources: [{ path: 'main.cpp', compileGroupIndex: 0 }], + compileGroups: [ + { + language: 'CXX', + includes: [{ path: path.join(tempDir, 'include') }], + preprocessorDefinitions: [{ define: 'FILEAPI_ACTIVE=1' }] + } + ] + }; + fs.writeFileSync(path.join(replyDir, 'target-App.json'), JSON.stringify(appTargetJson)); + + // 3. Re-refresh -> switches seamlessly to FileAPI + await manager.refresh(false); + assert.strictEqual(manager.getIsUsingFileApi(), true); + assert.ok(manager.getActiveProject()?.compileDefinitions.includes('FILEAPI_ACTIVE=1')); + + manager.dispose(); + }); + }); +}); diff --git a/test/vscode-mock.ts b/test/vscode-mock.ts index 58a6730..a4fb2f0 100644 --- a/test/vscode-mock.ts +++ b/test/vscode-mock.ts @@ -809,6 +809,9 @@ export const mockVscode: any = { uri, range: undefined, children: { + get size() { + return childrenMap.size; + }, get: (k: string) => childrenMap.get(k), add: (c: any) => childrenMap.set(c.id, c), delete: (k: string) => childrenMap.delete(k), diff --git a/wiki/Static-CMake-Integration.md b/wiki/Static-CMake-Integration.md index 526030b..ef06dd9 100644 --- a/wiki/Static-CMake-Integration.md +++ b/wiki/Static-CMake-Integration.md @@ -1,74 +1,83 @@ -# Static CMake Integration +# CMake Integration & Modern Tooling -C/C++ Pro features a static CMake intelligence engine that parses `CMakeLists.txt` files and `.cmake` modules directly in memory without requiring the `cmake` binary, configuration directories, or build trees. +C/C++ Pro features a hybrid CMake intelligence engine that combines instantaneous, zero-binary static parsing of `CMakeLists.txt` with full ingestion of modern CMake FileAPI (`codemodel-v2`), CMake Presets, target debugging, and CTest integration. --- -## 1. Why Static CMake Parsing? +## 1. Hybrid CMake Architecture -Traditional CMake extensions require configuring and generating a complete build directory (`build/` or `out/`) before IntelliSense becomes functional. This requires installed dependencies, functional toolchain files, and significant disk and CPU overhead. +C/C++ Pro implements a dual-mode hybrid architecture: -C/C++ Pro's static CMake engine provides: -- **Instant IntelliSense**: Semantic autocomplete and error checking work the moment you open a CMake workspace. -- **No External Binaries**: Operates completely independently of the `cmake` executable or ninja/make generators. -- **Zero Disk Bloat**: Extracts compile flags, definitions, and include directories without generating build trees. -- **Native Performance**: Powered by a high-throughput Rust parser (`crates/c-cpp-pro-native/src/cmake.rs`) that processes thousands of lines in milliseconds. +1. **Modern CMake FileAPI Ingestion (Default for Configured Workspaces)**: + - When a project has been configured (e.g. in `build/` or an active preset binary directory), C/C++ Pro automatically detects and ingests CMake FileAPI artifacts (`.cmake/api/v1/reply/`): + - `codemodel-v2`: Exact compilation flags, target definitions, sysroots, include directories, and preprocessor definitions. + - `cache-v2`: Full cache variables and entry metadata. + - `toolchains-v1`: Extracted compiler paths, versions, and target architecture triples. + - Watches `.cmake/api/v1/reply/` to automatically update IntelliSense when external CMake builds run. + +2. **Zero-Binary Static AST Parser (Fallback & Unconfigured Projects)**: + - If CMake has not yet run or no build tree exists, C/C++ Pro's high-throughput Rust parser (`crates/c-cpp-pro-native/src/cmake.rs`) parses `CMakeLists.txt` files and `.cmake` modules directly in memory in milliseconds. + - Semantic autocomplete, include paths, and error checking work the moment you open a CMake workspace, even before any build directory exists. --- -## 2. Supported CMake Commands & Semantics - -The static parser recognizes and models the core CMake target model: - -| Command | Modeling & Extraction | -| :--- | :--- | -| `project(...)` | Workspace naming and language configuration (`C`, `CXX`). | -| `set(VAR ...)` | Variable assignment and dynamic variable expansion (`${VAR}`). | -| `include_directories(...)` | Workspace-global include directories. | -| `add_definitions(...)` | Workspace-global preprocessor defines (`-DFOO`). | -| `add_executable(target ...)` | Executable target definition and source file association. | -| `add_library(target ...)` | Static, Shared, Module, and Interface library definitions. | -| `target_include_directories(...)` | Per-target include paths with `PUBLIC`, `PRIVATE`, or `INTERFACE` visibility. | -| `target_compile_definitions(...)` | Per-target preprocessor defines with visibility scoping. | -| `target_sources(...)` | Associating source files with existing targets. | -| `target_link_libraries(...)` | Dependency modeling and transitive include/define propagation. | -| `add_subdirectory(...)` | Recursive traversal and parsing of sub-projects. | -| `include(...)` | Inlining and parsing external `.cmake` modules. | - -### Generator Expression Handling -C/C++ Pro automatically evaluates build-time generator expressions: -- **Unwraps**: `$` is unwrapped and included in IntelliSense paths. -- **Discards**: `$` is ignored to reflect active in-source development paths. +## 2. CMake Presets Support + +C/C++ Pro supports `CMakePresets.json` and `CMakeUserPresets.json` (schema versions 1 through 9+): +- **Configure Presets**: Evaluates generator, binary directory, cache variables, toolchain files, and environment definitions. +- **Build Presets**: Evaluates target sets, jobs, and build options. +- **Test Presets**: Connects test filters and timeouts to CTest. +- **Macro Expansions**: Supports standard CMake macros (`${sourceDir}`, `${presetName}`, `${generator}`, `${hostSystemName}`, `${env:VAR}`). +- **Preset Switching**: Use the Command Palette to select configure presets via **`C/C++ Pro: Select CMake Configure Preset`** (`c-cpp-pro.cmake.selectConfigurePreset`) or build presets via **`C/C++ Pro: Select CMake Build Preset`** (`c-cpp-pro.cmake.selectBuildPreset`). --- -## 3. Automatic Compilation Database Synthesis +## 3. Target-Aware Launch & Debug Engine + +C/C++ Pro extracts executable targets directly from CMake FileAPI and static declarations: +- **Active Target Selection**: Click the **Target** widget on the status bar or run **`C/C++ Pro: Select Active CMake Launch Target`** (`c-cpp-pro.cmake.selectLaunchTarget`). +- **Debugging**: Run **`C/C++ Pro: Debug Active CMake Target`** (`c-cpp-pro.cmake.debugTarget`) to immediately launch the target under C/C++ Pro's DAP engine (`lldb-dap`, `cppvsdbg`, or `gdb`) without authoring a manual `launch.json`. +- **Run Without Debugging**: Run **`C/C++ Pro: Run Active CMake Target`** (`c-cpp-pro.cmake.launchTarget`) to execute the target inside the integrated terminal. + +--- -C/C++ Pro can automatically synthesize a complete `compile_commands.json` compilation database directly from your CMake targets: +## 4. CTest Integration in Test Explorer -1. Open the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`). -2. Run **`C/C++ Pro: Generate compile_commands.json from CMakeLists.txt`** (`c-cpp-pro.cmake.generateCompilationDatabase`). -3. C/C++ Pro compiles target sources and isolated private/public includes into a standards-compliant compilation database. -4. Language standards are tailored per source file: - - C files (`.c`) receive pure C standards (`-std=c17`, `clang`). - - C++ files (`.cpp`, `.cxx`, `.cc`) receive C++ standards (`-std=c++20`, `clang++`). +CTest suites are discovered and displayed alongside source-scanned unit tests (GoogleTest, Catch2, Boost.Test, doctest) in the unified VS Code Test Explorer (`vscode.tests`): +- Discovers tests via `ctest --show-only=json-v1` or by parsing `CTestTestfile.cmake`. +- Supports Run and Debug profiles directly from the Test Explorer interface. +- Streams live test execution output and failure diagnostics into the Test Results pane. --- -## 4. CMake Commands Reference +## 5. Single-File Quick Compilation -| Command | Identifier | Action | -| :--- | :--- | :--- | -| **CMake Intelligence Menu** | `c-cpp-pro.cmake.menu` | Opens a quick-pick popup with available CMake actions. | -| **Re-scan CMakeLists.txt Includes** | `c-cpp-pro.cmake.rescan` | Manually triggers a re-parse of all workspace `CMakeLists.txt` files. | -| **Generate Compilation Database** | `c-cpp-pro.cmake.generateCompilationDatabase` | Synthesizes `compile_commands.json` from parsed CMake targets. | +Quickly verify syntax, template instantiations, and compiler diagnostics for the active source file without triggering full project linkage: +- Right-click in the editor and select **`C/C++ Pro: Compile Active File (Syntax & Diagnostics)`** (`c-cpp-pro.cmake.compileActiveFile`). +- Resolves the exact compile command from `compile_commands.json` or synthesized flags. +- Spawns the compiler in compile/syntax-only mode (`-fsyntax-only` / `/Zs`) and streams warnings and errors directly into the Problems panel using streaming problem matchers. --- -## 5. Configuration Settings +## 6. AST-Assisted "Add File to CMakeLists.txt" Refactoring -| Setting | Type | Default | Description | -| :--- | :--- | :--- | :--- | -| `c-cpp-pro.cmake.autoScan` | `boolean` | `true` | Automatically parse `CMakeLists.txt` on folder open and file save. | -| `c-cpp-pro.cppStandard` | `string` | `"c++20"` | C++ standard emitted for synthesized CMake compile commands. | -| `c-cpp-pro.cStandard` | `string` | `"c17"` | C standard emitted for synthesized CMake compile commands. | +When creating new `.cpp` or `.h` files: +- C/C++ Pro detects if the file is not yet tracked in `CMakeLists.txt`. +- A Refactor Quick Fix appears: **`C/C++ Pro: Add to CMake target ''`**. +- Automatically inserts the source path into `target_sources()`, `add_executable()`, or `add_library()` preserving indentation and whitespace formatting. + +--- + +## 7. CMake Commands Reference + +| Command | Identifier | Action | +| :--- | :--- | :--- | +| **CMake Intelligence Menu** | `c-cpp-pro.cmake.menu` | Opens a quick-pick popup with all CMake actions, targets, and presets. | +| **Select Configure Preset** | `c-cpp-pro.cmake.selectConfigurePreset` | Switch active configure preset from `CMakePresets.json`. | +| **Select Build Preset** | `c-cpp-pro.cmake.selectBuildPreset` | Switch active build preset from `CMakePresets.json`. | +| **Select Launch Target** | `c-cpp-pro.cmake.selectLaunchTarget` | Switch the active executable target for run and debug. | +| **Debug Active CMake Target** | `c-cpp-pro.cmake.debugTarget` | Start DAP debugging for the active target binary. | +| **Run Active CMake Target** | `c-cpp-pro.cmake.launchTarget` | Run the active target binary in the integrated terminal. | +| **Compile Active File** | `c-cpp-pro.cmake.compileActiveFile` | Quick syntax and diagnostic check for active C/C++ file. | +| **Re-scan CMakeLists.txt Includes** | `c-cpp-pro.cmake.rescan` | Manually triggers a re-parse of workspace `CMakeLists.txt` files. | +| **Generate Compilation Database** | `c-cpp-pro.cmake.generateCompilationDatabase` | Synthesizes `compile_commands.json` from parsed CMake targets. | From eb776aeeaf42752427f1465f5e51bc0723da93cb Mon Sep 17 00:00:00 2001 From: FunToHard Date: Sat, 7 Nov 2026 20:37:35 +0530 Subject: [PATCH 2/2] fix(cmake): address review findings in test execution, target launcher, and AST modifier --- src/cmake/cmake-modifier.ts | 15 ++++++++++++--- src/cmake/cmake-presets.ts | 1 + src/cmake/cmake-target-launcher.ts | 15 ++++++--------- src/cmake/single-file-compile.ts | 16 ++++++++++++---- src/testing/test-controller.ts | 15 +++++++++------ test/cmake-modernization.test.ts | 10 +++++++++- 6 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/cmake/cmake-modifier.ts b/src/cmake/cmake-modifier.ts index 4c6e730..e68eb7c 100644 --- a/src/cmake/cmake-modifier.ts +++ b/src/cmake/cmake-modifier.ts @@ -20,6 +20,8 @@ export class CMakeListsModifier { for (let i = 0; i < lines.length; i++) { const line = lines[i]; + const trimmed = line.trim(); + if (trimmed.startsWith('#')) continue; // Match add_executable(name ...) const execMatch = /\badd_executable\s*\(\s*([A-Za-z0-9_+-]+)/i.exec(line); @@ -83,13 +85,20 @@ export class CMakeListsModifier { const line = lines[i]; const match = new RegExp(`\\btarget_sources\\s*\\(\\s*${targetName}\\b`, 'i').exec(line); if (match) { + if (line.includes(')')) { + const closeIdx = line.lastIndexOf(')'); + const updatedLine = line.slice(0, closeIdx).trimEnd() + ` ${normSource}` + line.slice(closeIdx); + lines[i] = updatedLine; + return lines.join('\n'); + } + // Find closing parenthesis of this target_sources call - for (let j = i; j < lines.length; j++) { + for (let j = i + 1; j < lines.length; j++) { const closeIdx = lines[j].indexOf(')'); if (closeIdx !== -1) { // Determine indentation - const indentMatch = /^\s*/.exec(lines[i]); - const indent = indentMatch ? indentMatch[0] + ' ' : ' '; + const indentMatch = /^\s*/.exec(lines[i + 1] || lines[i]); + const indent = indentMatch ? indentMatch[0] : ' '; lines.splice(j, 0, `${indent}${normSource}`); return lines.join('\n'); } diff --git a/src/cmake/cmake-presets.ts b/src/cmake/cmake-presets.ts index 159ffa3..15b75fa 100644 --- a/src/cmake/cmake-presets.ts +++ b/src/cmake/cmake-presets.ts @@ -78,6 +78,7 @@ export class CMakePresetsParser { .replace(/\$\{presetName\}/g, presetName) .replace(/\$\{generator\}/g, generator || 'Ninja') .replace(/\$\{hostSystemName\}/g, hostSystemName) + .replace(/\$\{pathListSep\}/g, path.delimiter) .replace(/\$\{dollar\}/g, '$') .replace(/\$\{env:([^}]+)\}/g, (_match, varName) => process.env[varName] || '') .replace(/\$\{penv:([^}]+)\}/g, (_match, varName) => process.env[varName] || ''); diff --git a/src/cmake/cmake-target-launcher.ts b/src/cmake/cmake-target-launcher.ts index 8beec5a..7ccfdcf 100644 --- a/src/cmake/cmake-target-launcher.ts +++ b/src/cmake/cmake-target-launcher.ts @@ -123,16 +123,13 @@ export class CMakeTargetLauncher implements vscode.Disposable { return; } - const term = vscode.window.createTerminal(`Run: ${target.name}`); - term.show(); - const cwd = target.workingDirectory || path.dirname(target.binaryPath); - if (process.platform === 'win32') { - term.sendText(`cd /d "${cwd}"`); - term.sendText(`"${target.binaryPath}"`); - } else { - term.sendText(`cd "${cwd}" && "${target.binaryPath}"`); - } + const term = vscode.window.createTerminal({ + name: `Run: ${target.name}`, + cwd: cwd + }); + term.show(); + term.sendText(`"${target.binaryPath}"`); } private updateStatusBar(): void { diff --git a/src/cmake/single-file-compile.ts b/src/cmake/single-file-compile.ts index 0a94502..684e6ef 100644 --- a/src/cmake/single-file-compile.ts +++ b/src/cmake/single-file-compile.ts @@ -120,6 +120,14 @@ export class SingleFileCompiler implements vscode.Disposable { const startTime = Date.now(); return new Promise((resolve) => { + let settled = false; + const settle = (success: boolean) => { + if (!settled) { + settled = true; + resolve(success); + } + }; + let child; try { child = spawn(bin, args, { @@ -130,7 +138,7 @@ export class SingleFileCompiler implements vscode.Disposable { } catch (err: any) { this.outputChannel.appendLine(`Execution failed: ${err.message}`); vscode.window.showErrorMessage(`Failed to spawn compiler: ${err.message}`); - resolve(false); + settle(false); return; } @@ -155,19 +163,19 @@ export class SingleFileCompiler implements vscode.Disposable { `C/C++ Pro: Compiled ${path.basename(targetFile)} (${duration}ms)`, 4000 ); - resolve(true); + settle(true); } else { this.outputChannel.appendLine(`Compilation failed with exit code ${code} (${duration}ms).`); vscode.window.showErrorMessage( `Compilation failed for ${path.basename(targetFile)} (exit code ${code}). Check Problems tab.` ); - resolve(false); + settle(false); } }); child.on('error', (err) => { this.outputChannel.appendLine(`Spawn error: ${err.message}`); - resolve(false); + settle(false); }); }); } diff --git a/src/testing/test-controller.ts b/src/testing/test-controller.ts index 81033b5..1195870 100644 --- a/src/testing/test-controller.ts +++ b/src/testing/test-controller.ts @@ -372,13 +372,16 @@ export class CppTestController implements vscode.Disposable { if (token.isCancellationRequested) break; if (item.id === 'ctest-root') { - item.children.forEach((child) => { - if (!token.isCancellationRequested) { - CTestRunner.executeTest(child, run, buildDir); - } - }); + const children: vscode.TestItem[] = []; + item.children.forEach((child) => children.push(child)); + for (const child of children) { + if (token.isCancellationRequested) break; + const childBuildDir = child.uri ? path.dirname(child.uri.fsPath) : buildDir; + await CTestRunner.executeTest(child, run, childBuildDir); + } } else if (item.id.startsWith('ctest::')) { - await CTestRunner.executeTest(item, run, buildDir); + const itemBuildDir = item.uri ? path.dirname(item.uri.fsPath) : buildDir; + await CTestRunner.executeTest(item, run, itemBuildDir); } else { run.passed(item); } diff --git a/test/cmake-modernization.test.ts b/test/cmake-modernization.test.ts index a28e572..1e7951b 100644 --- a/test/cmake-modernization.test.ts +++ b/test/cmake-modernization.test.ts @@ -143,13 +143,14 @@ describe('CMake Modernization Subsystems (Issue #5)', () => { describe('CMakePresetsParser', () => { it('should perform macro substitution correctly', () => { - const template = '${sourceDir}/build/${presetName}-${generator}-${hostSystemName}'; + const template = '${sourceDir}/build/${presetName}-${generator}-${hostSystemName}-${pathListSep}'; const expanded = CMakePresetsParser.expandMacros(template, tempDir, 'x64-Debug', 'Ninja'); assert.ok(expanded.startsWith(tempDir.replace(/\\/g, '/'))); assert.ok(expanded.includes('x64-Debug')); assert.ok(expanded.includes('Ninja')); assert.ok(expanded.includes(process.platform === 'win32' ? 'Windows' : process.platform === 'linux' ? 'Linux' : 'Darwin')); + assert.ok(expanded.includes(path.delimiter)); }); it('should load CMakePresets.json and merge with CMakeUserPresets.json', () => { @@ -305,6 +306,7 @@ describe('CMake Modernization Subsystems (Issue #5)', () => { cmake_minimum_required(VERSION 3.20) project(SampleApp) + # add_executable(IgnoredCommentedOutApp src/ignored.cpp) add_executable(ServerApp src/server.cpp) add_library(SharedUtils SHARED src/utils.cpp) target_sources(ServerApp PRIVATE src/extra.cpp) @@ -320,6 +322,12 @@ describe('CMake Modernization Subsystems (Issue #5)', () => { assert.strictEqual(targets[2].type, 'sources'); }); + it('should insert source file into single-line target_sources correctly', () => { + const cmakeLists = 'target_sources(MyDaemon PRIVATE src/daemon.cpp)'; + const updated = CMakeListsModifier.insertSourceIntoTarget(cmakeLists, 'MyDaemon', 'src/worker.cpp'); + assert.strictEqual(updated, 'target_sources(MyDaemon PRIVATE src/daemon.cpp src/worker.cpp)'); + }); + it('should insert source file into existing target_sources or add_executable block', () => { const cmakeLists = ` add_executable(MyDaemon