diff --git a/CHANGELOG.md b/CHANGELOG.md index 0015cde..f72d00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 更新日志 +## 0.3.0 - 2026-08-11 + +- 将编译数据库刷新迁移到 `mcpp build --configure-only`:不解析 stdout 人类文本,以退出码 + 和可解析 CDB 作为成功条件,失败时保留 last-known-good CDB。 +- 删除旧 `mcpp ide configure --format ndjson` 解析层和重复的 `mcpp.configureIde` 命令; + configure-only 与 build/run/test 共用项目操作锁,manifest 与 `mcpp.path` 变化按工程协调。 +- CDB watcher 只重读已发布数据库,不反向触发 configure-only;多根工作区按事件 URI 路由, + 显式刷新会分别报告 CDB 生成与 clangd 协调结果。 +- 对齐 mcpp #387 的最终 workspace 契约:virtual workspace 根不作为单一 clangd 工程, + 扩展消费当前活动 member 根的 CDB;rooted workspace 仍按根 package 处理。 + ## 0.2.7 - 修复「一键配置模块代码提示」在标准 mcpp 安装(install.sh / AUR)下无法发现 mcpp 内置 diff --git a/README.md b/README.md index 3b0792d..0e2a655 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 把 mcpp 工程、C++ 模块语法和官方 clangd 扩展接入 VS Code。 -当前版本为 `0.2.7`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 +当前版本为 `0.3.0`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的构建逻辑。 > 当前完整的模块语义能力只支持 LLVM/Clang 工具链。GCC 和 MSVC 工程仍可使用 @@ -37,7 +37,7 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 VSIX,然后在 VS Code 中执行 **Extensions: Install from VSIX...**,或者运行: ```sh -code --install-extension /path/to/mcpp-vscode-0.2.7.vsix +code --install-extension /path/to/mcpp-vscode-0.3.0.vsix ``` 安装后确认当前 VS Code profile 中同时存在 `mcpp-community.mcpp-vscode` 和 @@ -47,7 +47,9 @@ code --install-extension /path/to/mcpp-vscode-0.2.7.vsix - VS Code 1.90 或更高版本。 - 包含 `mcpp.toml` 的 mcpp 工程。 -- 可执行的 mcpp。它可以由 xlings、官方独立安装脚本或其他受支持方式安装。 +- 包含 mcpp #387(合并提交 `3f237ed`)的可执行文件;在该能力进入正式版本前, + 可使用对应主线构建。旧版 mcpp 不支持 configure-only 时,扩展会保留原有 CDB 并提示升级。 + 它可以由 xlings、官方独立安装脚本或其他受支持方式安装。 - 官方 `llvm-vs-code-extensions.vscode-clangd` 扩展。它是本扩展的功能依赖。 - 要启用模块语义,需要 LLVM mcpp 工具链、工程编译数据库,以及与该工具链来自 同一 LLVM revision 的 clangd。 @@ -57,16 +59,21 @@ xlings 安装。 ## 快速开始 -1. 使用 LLVM 工具链构建一次项目,使 mcpp 在工程根目录生成 - `compile_commands.json` 和所需 PCM。 -2. 使用 VS Code 打开包含 `mcpp.toml` 的目录。 -3. 扩展会自动分析编译数据库、选择匹配的 clangd、写入工作区配置并检查模块支持。 +1. 使用 VS Code 打开包含 `mcpp.toml` 的目录,并按需信任工作区。 +2. 工程没有有效 `compile_commands.json` 时,扩展会运行 + `mcpp build --configure-only` 生成配置阶段数据,不编译普通对象或链接产物。 +3. 扩展会分析编译数据库、选择匹配的 clangd、写入工作区配置并检查模块支持;缺少 + 项目 PCM 或需要验证正式产物时,仍应执行完整构建或使用一键配置向导。 4. 查看状态栏中的 `mcpp: 模块可用`、`mcpp: 模块不可用` 或 `mcpp: 缺少模块 CDB` 状态。 工程还没有编译数据库时,可以点击“缺少模块 CDB”,或执行 -**mcpp: 刷新编译数据库**。这个命令当前实际执行一次 `mcpp build`,并在任务结束后 -重新协调 CDB 和 clangd。 +**mcpp: 刷新编译数据库**。这个命令执行 `mcpp build --configure-only`,只在退出码为 +0 且新 CDB 可解析时将其视为成功;失败时继续保留并使用原有可用 CDB。 + +virtual workspace 根不对应单一 CDB:mcpp 会把数据库分别发布到各 member 根。扩展 +因此只接管当前活动 member;请先打开该 member 的源码或 `mcpp.toml`,不会把多个 member +的 CDB 合并到虚拟根。 ## 已实现功能 @@ -78,7 +85,8 @@ xlings 安装。 - 打开已有有效 CDB 的 LLVM 工程后自动配置并检查,不需要先点击状态栏或 Reload Window。 -扩展激活本身不会静默运行 `mcpp build`,也不会自动下载、安装或切换工具链。 +扩展激活不会静默运行完整 `mcpp build`,也不会自动下载、安装或切换工具链;受信任 +工程缺少有效 CDB 时会自动运行配置阶段的 `mcpp build --configure-only`。 ### 语法高亮 @@ -141,8 +149,9 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB - 只接受当前工程最新一次模块检查的结果,迟到结果不会覆盖新状态。 - 多根工作区中只有活动 mcpp 工程接管窗口级 clangd 配置;后台工程只失效缓存,切换 过去时再协调。 -- 修改 `mcpp.clangd.path` 或 `mcpp.modulesSupport` 后自动重新配置和检查。 -- 构建即使因普通源码错误退出,只要留下可用 CDB,扩展仍会尝试恢复 IDE 状态,并把 +- 修改资源域 `mcpp.path` 时按工程标记重新生成 CDB;修改 `mcpp.clangd.path` 或 + `mcpp.modulesSupport` 时只重新配置和检查,不重复运行 mcpp。 +- 完整构建即使因普通源码错误退出,只要留下可用 CDB,扩展仍会尝试恢复 IDE 状态,并把 “构建失败”和“IDE 数据可用”分别报告。 ### mcpp CLI 与工具链管理 @@ -175,7 +184,7 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB | **mcpp: 安装工具链** | 确认后执行 `mcpp toolchain install ` | | **mcpp: 选择全局默认工具链** | 确认后执行 `mcpp toolchain default ` | | **mcpp: 配置 clangd** | 手动重新应用当前 LLVM 工程的 clangd 配置 | -| **mcpp: 刷新编译数据库** | 执行 `mcpp build`,随后重新读取 CDB 并协调 clangd | +| **mcpp: 刷新编译数据库** | 执行 `mcpp build --configure-only`,验证 CDB 后协调 clangd;失败时保留原有可用 CDB | | **mcpp: 检查模块支持** | 立即执行 clangd 直接检查并刷新模块状态 | | **mcpp: 一键配置模块代码提示** | 一次确认后自动安装/切换 host LLVM、执行 `mcpp build`,通过 xlings 补齐匹配 llvm-tools,重载 CDB 并配置 clangd;项目显式固定非 LLVM 工具链时停止 | @@ -233,11 +242,12 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 不能直接消费;当前版本没有 GCC 原生模块语言服务器、cpptools 模块后端或 LLVM 影子 分析副本。因此 GCC/MSVC 工程只保证模块语法高亮和 mcpp 操作。 -### 首次语义配置仍需要构建数据 +### 首次语义配置仍需要 mcpp 生成的数据 当前插件只读取 `compile_commands.json`,不解析 `mcpp.toml`,也不自行推导依赖和 -模块图。工程没有 CDB 和 PCM 时,必须先显式执行构建或“刷新编译数据库”。插件不会 -因为打开工程就自动构建,也不会自行生成、改写或修复 mcpp 没有输出的 CDB。 +模块图。受信任工程没有有效 CDB 时,插件会调用 `mcpp build --configure-only`,但不会 +自行生成或改写 mcpp 没有输出的 CDB。完整模块语义仍取决于 CDB 引用的兼容 PCM;正式 +构建、链接和测试仍由普通 `mcpp build`、`run` 或 `test` 负责。 ### clangd 模块支持仍是实验能力 @@ -253,7 +263,9 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 未受信任工作区只启用文件关联和语法高亮,不执行 CDB 中的编译器、mcpp、clangd 或 工程指定程序,也不接管 clangd 配置。授予信任后,扩展会自动重新协调当前工程;仍不 -会在没有用户操作时下载工具链或发起构建。 +会在没有用户操作时下载或切换工具链,也不会发起完整构建;缺少有效 CDB 时可以执行 +配置阶段的 configure-only。该命令不是只读操作:mcpp 仍可能执行 `build.mcpp`、解析或 +安装缺失依赖和工具链,并写入 lock、构建目录与相关元数据。 ## 当前版本能达到的效果 @@ -265,8 +277,9 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 - 在重新构建或 CDB 变化后自动刷新,不要求 Reload Window; - 通过 VS Code 任务完成常用 mcpp 项目和工具链操作。 -这些能力受 clangd 实验模块实现和 PCM 兼容性约束。工程没有 CDB 时,当前版本只能 -提供语法高亮、mcpp 命令和引导操作,不能仅凭插件恢复完整模块语义。 +这些能力受 clangd 实验模块实现和 PCM 兼容性约束。工程没有 CDB 时,扩展可以请求 +mcpp 生成配置阶段 CDB;若 mcpp 版本不支持该命令、配置失败或仍缺少兼容 PCM,则只能 +提供语法高亮、mcpp 命令和引导操作。 ## 理想最终效果 @@ -288,12 +301,13 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 ## 达到理想效果所需支持 -下面按完成程度区分必要工作。接口名称只是设计示例,不是当前 mcpp 已提供的命令。 +下面按完成程度区分必要工作。`mcpp build --configure-only` 是当前扩展采用的已实现接口; +其余机器可读工程模型和结构化诊断仍是后续协议工作。 | 支持项 | 当前程度 | 理想完成标准 | 优先级 | | --- | --- | --- | --- | | 机器可读工程模型 | 插件只能读取 CDB,mcpp 主要输出面向人的文本 | mcpp 输出带版本 schema 的成员、目标、源码、模块、依赖、工具链、缓存与诊断 | 必需 | -| IDE 配置阶段 | 必须通过正式构建间接生成 CDB | 提供类似 `mcpp resolve --message-format=json --emit-cdb` 或 `mcpp configure --ide` 的可取消命令 | 必需 | +| IDE 配置阶段 | 已使用 `mcpp build --configure-only` 生成并验证 CDB | 增加结构化进度、取消和能力协商,不把人类 stdout 当协议解析 | 部分完成 | | IDE 专用模块产物 | PCM 是正式构建的副产物 | 只构建 `std`、依赖和项目模块 PCM,不编译普通对象或链接,并按工具链身份隔离缓存 | 必需 | | 完整 CDB 与模块图 | LLVM CDB 已有部分显式 PCM 参数 | producer 和 consumer 都使用确定的绝对 PCM 路径,覆盖分区、依赖包、成员和测试目标 | 必需 | | 结构化诊断 | 未知导入等信息主要来自构建或 clangd 文本 | mcpp 输出文件、行、列、严重级别和稳定错误码,插件可精确定位配置期错误 | 必需 | @@ -311,21 +325,24 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 ### 阶段 1:当前版本 -- 使用用户显式构建后产生的 LLVM CDB 和 PCM。 +- 缺少有效 CDB 时,在受信任工作区自动执行 `mcpp build --configure-only`;失败时保留 + last-known-good CDB。 +- 使用 configure-only 或用户完整构建产生的 LLVM CDB 和已有 PCM。 - 自动配置 clangd、检查模块、监听变化并提供 CLI/工具链菜单。 - 一键自动安装匹配的 llvm-tools 并配置模块代码提示。 - GCC/MSVC 保持语法高亮和 mcpp 构建操作,不增加语义后端。 -### 阶段 2:mcpp IDE 协议 +### 阶段 2:机器可读工程协议增强 -- 在 mcpp 核心增加带版本的机器可读工程模型和 IDE 配置命令。 -- 生成完整模块图、结构化诊断、IDE CDB 和专用 PCM。 +- 在 mcpp 核心增加带版本的机器可读工程模型,不改变 configure-only 的退出码 + CDB + 客户端契约。 +- 补充完整模块图、结构化诊断、IDE 专用 PCM 和能力协商。 - 明确定义工作区成员、配置身份、缓存路径、进度和取消协议。 -### 阶段 3:打开即用的 LLVM 体验 +### 阶段 3:完善打开即用的 LLVM 体验 -- 插件在工作区信任后自动调用 IDE 配置阶段,而不是完整构建。 -- 支持离线缓存、last-known-good、增量刷新、过期状态和多根隔离。 +- 在已有自动 configure-only 基础上增加可取消进度、离线缓存、增量刷新和过期状态。 +- 完善按工程隔离的 last-known-good 数据与多根集成验证。 - 以真实 VS Code 端到端测试作为模块诊断和跳转能力的发布门槛。 ### 未来重新评估 GCC/MSVC @@ -355,10 +372,10 @@ macOS 从图形界面启动 VS Code 时可能没有继承终端 `PATH`。将 `mc ### 缺少模块 CDB -当前版本不会在打开工程时静默构建。执行 **mcpp: 刷新编译数据库**,或在终端运行 -`mcpp build`。无论构建成功还是失败,只要工程根目录仍没有 -`compile_commands.json`,插件都会保持“缺少模块 CDB”状态;请查看 mcpp 输出和任务 -终端,确认当前构建是否实际生成了 CDB。 +受信任工程缺少有效 CDB 时会自动尝试 `mcpp build --configure-only`,也可以手动执行 +**mcpp: 刷新编译数据库**。若 mcpp 版本不支持该参数、命令失败或生成的 CDB 无法解析, +插件会保持“缺少模块 CDB”状态,并保留原有可用 CDB;请查看 `mcpp` 输出频道。需要 +编译普通对象、生成完整项目产物或验证链接时,再在终端运行 `mcpp build`。 ### clangd 未安装或无法匹配 @@ -402,8 +419,8 @@ API、状态栏、任务和 clangd 集成。 版本完全一致的 tag: ```sh -git tag -a v0.2.7 -m "mcpp-vscode 0.2.7" -git push origin v0.2.7 +git tag -a v0.3.0 -m "mcpp-vscode 0.3.0" +git push origin v0.3.0 ``` `.github/workflows/release.yml` 会校验 tag,执行测试和打包,生成 VSIX 与 SHA-256 文件, diff --git a/package-lock.json b/package-lock.json index 4d170ad..0033314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.2.7", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.2.7", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index d0232c2..1cb55bc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "mcpp", "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.2.7", + "version": "0.3.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", diff --git a/src/cliController.ts b/src/cliController.ts index 2416b72..12cbaf0 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -15,7 +15,8 @@ import { type ToolchainItem, } from "./cli"; import type { McppProjectDiscovery } from "./discovery"; -import { runProcess } from "./process"; +import { runConfigureOnly as runConfigureOnlyProcess } from "./configureOnly"; +import { runProcess, type ProcessResult } from "./process"; import { McppOperationRegistry, classifyTaskExit, @@ -118,6 +119,30 @@ export class McppCliController { return this.operations.hasActive(); } + public async runConfigureOnly( + project: McppProjectDiscovery, + ): Promise { + if (!this.options.isTrusted()) { + return undefined; + } + + const token: OperationToken = {}; + if (this.operations.beginProject(project.root, token) !== undefined) { + return undefined; + } + + const executable = this.mcppExecutable(project); + const args = ["build", "--configure-only"]; + try { + const result = await runConfigureOnlyProcess(project.root, executable); + this.appendShortCommand("刷新编译数据库", executable, args, result); + return result; + } finally { + // configure-only 与 build/run/test 共用项目锁,异常时也必须释放。 + this.operations.finishProject(project.root, token); + } + } + public async runAutomaticModuleSetup( plan: Extract, ): Promise { diff --git a/src/configureOnly.ts b/src/configureOnly.ts new file mode 100644 index 0000000..a677a74 --- /dev/null +++ b/src/configureOnly.ts @@ -0,0 +1,11 @@ +import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; + +export const configureOnlyArguments = ["build", "--configure-only"] as const; + +export function runConfigureOnly( + projectRoot: string, + executable = "mcpp", + runner: ProcessRunner = runProcess, +): Promise { + return runner(executable, [...configureOnlyArguments], projectRoot); +} diff --git a/src/discovery.ts b/src/discovery.ts index e5d0efc..64c461c 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -1,15 +1,30 @@ import { existsSync, + readFileSync, statSync, } from "node:fs"; import path from "node:path"; +import { parseMcppToml } from "./mcppTomlParser"; + export interface McppProjectDiscovery { root: string; manifestPath: string; compilationDatabasePath: string; } +export function manifestProjectRoot(manifestPath: string): string { + return path.dirname(path.resolve(manifestPath)); +} + +export function shouldReconcileDeletedManifest( + currentProjectRoot: string | undefined, + manifestPath: string, +): boolean { + return currentProjectRoot !== undefined + && path.resolve(currentProjectRoot) === manifestProjectRoot(manifestPath); +} + function isWindowsPath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\"); } @@ -18,7 +33,7 @@ function unique(values: string[]): string[] { return values.filter((value, index) => values.indexOf(value) === index); } -function isPathWithin(candidate: string, root: string): boolean { +export function isPathWithinProject(candidate: string, root: string): boolean { const relative = path.relative(root, candidate); return relative === "" || ( relative !== ".." @@ -27,6 +42,58 @@ function isPathWithin(candidate: string, root: string): boolean { ); } +interface ManifestShape { + hasPackage: boolean; + hasWorkspace: boolean; +} + +function readManifestShape(manifestPath: string): ManifestShape { + try { + const document = parseMcppToml(readFileSync(manifestPath, "utf8").split(/\r?\n/)); + let hasPackage = false; + let hasWorkspace = false; + let insideSection = false; + for (const node of document.nodes) { + if (node.type === "section") { + insideSection = true; + if (node.segments.length === 1) { + hasPackage ||= node.segments[0].name === "package"; + hasWorkspace ||= node.segments[0].name === "workspace"; + } + continue; + } + // mcpp 同样接受顶层 dotted key 和 inline table 写法。 + if (!insideSection) { + hasPackage ||= node.keyPath[0]?.name === "package"; + hasWorkspace ||= node.keyPath[0]?.name === "workspace"; + } + } + return { hasPackage, hasWorkspace }; + } catch { + // 无法读取时保留原发现结果,让 mcpp 自己给出清单诊断。 + return { hasPackage: false, hasWorkspace: false }; + } +} + +export function projectAffectedByManifest( + manifestPath: string, + currentProject: McppProjectDiscovery | undefined, + manifestProject: McppProjectDiscovery | undefined, +): McppProjectDiscovery | undefined { + const manifestRoot = manifestProjectRoot(manifestPath); + const shape = readManifestShape(manifestPath); + if ( + shape.hasWorkspace + && currentProject !== undefined + && currentProject.root !== manifestRoot + && isPathWithinProject(currentProject.root, manifestRoot) + ) { + // member 会继承 workspace 根配置,根清单变化必须刷新当前 member 的 CDB。 + return currentProject; + } + return manifestProject; +} + export function findNearestMcppProject( startPath: string, workspaceRoot?: string, @@ -42,13 +109,19 @@ export function findNearestMcppProject( // 新创建的 VS Code 工作区路径可能尚不存在,此时按目录处理。 } - if (boundary !== undefined && !isPathWithin(current, boundary)) { + if (boundary !== undefined && !isPathWithinProject(current, boundary)) { return undefined; } while (true) { const manifestPath = path.join(current, "mcpp.toml"); if (existsSync(manifestPath)) { + // #387 的 virtual workspace fan-out 只在各 member 根发布 CDB; + // 虚拟根不是 clangd 可消费的单一 package 工程。 + const shape = readManifestShape(manifestPath); + if (shape.hasWorkspace && !shape.hasPackage) { + return undefined; + } return { root: current, manifestPath, diff --git a/src/extension.ts b/src/extension.ts index 23fb2de..fdb7476 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,10 @@ import { import { deriveClangdCandidates, findNearestMcppProject, + isPathWithinProject, + manifestProjectRoot, + projectAffectedByManifest, + shouldReconcileDeletedManifest, type McppProjectDiscovery, } from "./discovery"; import { @@ -25,13 +29,16 @@ import { } from "./llvmTools"; import { CLI_COMMANDS } from "./commands"; import { McppCliController } from "./cliController"; -import { runClangdCheck, runToolVersion, type ToolVersionResult } from "./process"; +import { runClangdCheck, runToolVersion, type ProcessResult, type ToolVersionResult } from "./process"; +import { ensureIdeConfigured } from "./ideWorkflow"; import { configurationReadyAfterRestart, + configurationAffectsMcppExecution, configurationAffectsModuleSupport, createKeyedSingleFlightReconciler, createLatestOperationTracker, createSerialExecutor, + describeConfigureOnlyOutcome, describeRefreshOutcome, moduleSupportState, registerCompilationDatabaseReconciliation, @@ -84,6 +91,9 @@ type ConfigureMode = "automatic" | "interactive"; const moduleStatusByProject = new Map(); const moduleCheckOperations = createLatestOperationTracker(); +// 记录 manifest/settings 变化触发的 IDE 配置请求;CDB watcher 只重读 +// 已发布快照,避免写 CDB 后再次启动 mcpp 形成重入。 +const forceConfigureOnlyByProject = new Set(); let lastReconciledProjectRoot: string | undefined; function findCurrentProject(): McppProjectDiscovery | undefined { @@ -101,7 +111,10 @@ function findCurrentProject(): McppProjectDiscovery | undefined { } for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const project = findNearestMcppProject(workspaceFolder.uri.fsPath); + const project = findNearestMcppProject( + workspaceFolder.uri.fsPath, + workspaceFolder.uri.fsPath, + ); if (project !== undefined) { return project; } @@ -109,6 +122,33 @@ function findCurrentProject(): McppProjectDiscovery | undefined { return undefined; } +function findProjectForUri(uri: vscode.Uri): McppProjectDiscovery | undefined { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + if (workspaceFolder === undefined) { + return undefined; + } + return findNearestMcppProject(uri.fsPath, workspaceFolder.uri.fsPath); +} + +function findWorkspaceProjects( + currentProject: McppProjectDiscovery | undefined, +): McppProjectDiscovery[] { + const projects = new Map(); + if (currentProject !== undefined) { + projects.set(currentProject.root, currentProject); + } + for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { + const project = findNearestMcppProject( + workspaceFolder.uri.fsPath, + workspaceFolder.uri.fsPath, + ); + if (project !== undefined) { + projects.set(project.root, project); + } + } + return [...projects.values()]; +} + function loadProjectContext(project: McppProjectDiscovery | undefined = findCurrentProject()): ProjectContext | undefined { if (project === undefined) { return undefined; @@ -142,6 +182,10 @@ function loadProjectContext(project: McppProjectDiscovery | undefined = findCurr } } +function hasUsableCompilationDatabase(project: McppProjectDiscovery): boolean { + return loadProjectContext(project)?.analysis.capability !== "unavailable"; +} + function moduleSetupBlockedMessage(reason: ModuleSetupBlockedReason): string { switch (reason) { case "project-toolchain-override": @@ -872,6 +916,8 @@ const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { export async function activate(extensionContext: vscode.ExtensionContext): Promise { moduleStatusByProject.clear(); moduleCheckOperations.clear(); + forceConfigureOnlyByProject.clear(); + lastReconciledProjectRoot = undefined; const output = vscode.window.createOutputChannel("mcpp"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); @@ -885,6 +931,31 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi void vscode.window.showErrorMessage(`mcpp:${message}`); } }; + let cliController: McppCliController; + const runConfigureOnlyForProject = async ( + project: McppProjectDiscovery, + interactive: boolean, + ): Promise => { + if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { + appendOutputLine(output, "[CDB 配置] 工作区未受信任,跳过 mcpp build --configure-only。"); + if (interactive) { + await vscode.window.showWarningMessage( + "当前工作区未受信任,不会刷新编译数据库。请先信任工作区。", + ); + } + return undefined; + } + const result = await cliController.runConfigureOnly(project); + if (result === undefined) { + appendOutputLine(output, "[CDB 配置] 已有 mcpp 操作正在运行,本次刷新已跳过。"); + if (interactive) { + await vscode.window.showWarningMessage( + "已有 mcpp 操作正在运行,暂不能刷新编译数据库。请等待当前操作完成后重试。", + ); + } + } + return result; + }; const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); const compilationDatabaseWatcher = vscode.workspace.createFileSystemWatcher("**/compile_commands.json"); const inProjectContext = registerInProjectContext({ @@ -901,10 +972,12 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const reconcileProjectContext = async ( project: McppProjectDiscovery | undefined, forceRestart: boolean, + forceConfigureOnly: boolean = false, + allowConfigureOnly: boolean = true, ): Promise => { - const context = loadProjectContext(project); - updateStatusBar(status, context); + let context = loadProjectContext(project); if (context === undefined) { + updateStatusBar(status, context); return { context, databaseFound: false, @@ -912,6 +985,39 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi }; } + // 缺少可用 CDB 时只运行配置阶段;完整 build/test 后则只重读已有 CDB。 + if (allowConfigureOnly) { + try { + const outcome = await ensureIdeConfigured({ + projectRoot: context.project.root, + compilationDatabasePath: context.project.compilationDatabasePath, + trusted: vscode.workspace.isTrusted, + force: forceConfigureOnly, + databaseValid: () => hasUsableCompilationDatabase(context!.project), + configure: async () => { + const result = await runConfigureOnlyForProject(context!.project, false); + if (result === undefined) { + throw new Error("已有 mcpp 操作正在运行"); + } + if (forceConfigureOnly) { + forceConfigureOnlyByProject.delete(context!.project.root); + } + return result; + }, + }); + if (outcome.state === "configured") { + context = loadProjectContext(context.project) ?? context; + } else if (outcome.state === "failed") { + appendOutputLine(output, `[自动配置] mcpp configure-only 失败(退出码 ${outcome.exitCode})。`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendOutputLine(output, `[自动配置] ${message}`); + } + } + + updateStatusBar(status, context); + const configured = await configureClangd( context, status, @@ -939,7 +1045,26 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi configured: false, }; } - return reconcileProjectContext(project, forceRestart); + return reconcileProjectContext( + project, + forceRestart, + forceConfigureOnlyByProject.has(projectRoot), + ); + }, + ), + ); + const reconcilePublishedCdbByRoot = createKeyedSingleFlightReconciler( + (projectRoot: string, forceRestart) => executeWithWorkspaceClangd( + async () => { + const project = findNearestMcppProject(projectRoot, projectRoot); + if (project === undefined) { + return { + context: undefined, + databaseFound: false, + configured: false, + }; + } + return reconcileProjectContext(project, forceRestart, false, false); }, ), ); @@ -954,7 +1079,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi compilationDatabase: vscode.Uri, forceRestart: boolean, ): void => { - const project = findNearestMcppProject(compilationDatabase.fsPath); + const project = findProjectForUri(compilationDatabase); if (project === undefined) { return; } @@ -962,7 +1087,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; } - void reconcileProject(project, forceRestart).catch((error: unknown) => { + // CDB watcher 只重读已经发布的数据库,避免配置阶段写 CDB 后再次启动 mcpp。 + void reconcilePublishedCdbByRoot(project.root, forceRestart).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); appendOutputLine(output, `[自动配置] ${message}`); }); @@ -970,20 +1096,79 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const requestCurrentProjectReconciliation = (forceRestart: boolean): void => { const project = findCurrentProject(); if (project === undefined) { + lastReconciledProjectRoot = undefined; refreshStatus(); return; } + lastReconciledProjectRoot = project.root; void reconcileProject(project, forceRestart).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); appendOutputLine(output, `[自动配置] ${message}`); }); }; - const configurationWatcher = vscode.workspace.onDidChangeConfiguration((event) => { - const project = findCurrentProject(); + const requestManifestReconciliation = (manifestUri: vscode.Uri): void => { + refreshStatus(); + cliController.refreshStatus(); + const currentProject = findCurrentProject(); + const project = projectAffectedByManifest( + manifestUri.fsPath, + currentProject, + findProjectForUri(manifestUri), + ); if (project === undefined) { return; } - const uri = vscode.Uri.file(project.root); + forceConfigureOnlyByProject.add(project.root); + if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { + return; + } + lastReconciledProjectRoot = project.root; + void reconcileProject(project, true).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + appendOutputLine(output, `[自动配置] ${message}`); + }); + }; + const requestDeletedManifestReconciliation = (manifestUri: vscode.Uri): void => { + refreshStatus(); + cliController.refreshStatus(); + const deletedProjectRoot = manifestProjectRoot(manifestUri.fsPath); + forceConfigureOnlyByProject.delete(deletedProjectRoot); + invalidateModuleStatus(deletedProjectRoot); + if (!shouldReconcileDeletedManifest(lastReconciledProjectRoot, manifestUri.fsPath)) { + return; + } + + lastReconciledProjectRoot = undefined; + const fallbackProject = findCurrentProject(); + if ( + fallbackProject !== undefined + && isPathWithinProject(manifestProjectRoot(manifestUri.fsPath), fallbackProject.root) + ) { + forceConfigureOnlyByProject.add(fallbackProject.root); + } + requestCurrentProjectReconciliation(true); + }; + const configurationWatcher = vscode.workspace.onDidChangeConfiguration((event) => { + const currentProject = findCurrentProject(); + let currentExecutionChanged = false; + for (const project of findWorkspaceProjects(currentProject)) { + const uri = vscode.Uri.file(project.root); + if (!configurationAffectsMcppExecution( + (section) => event.affectsConfiguration(section, uri), + )) { + continue; + } + forceConfigureOnlyByProject.add(project.root); + currentExecutionChanged ||= project.root === currentProject?.root; + } + if (currentExecutionChanged) { + requestCurrentProjectReconciliation(true); + return; + } + if (currentProject === undefined) { + return; + } + const uri = vscode.Uri.file(currentProject.root); if (configurationAffectsModuleSupport( (section) => event.affectsConfiguration(section, uri), )) { @@ -991,6 +1176,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi } }); const trustWatcher = vscode.workspace.onDidGrantWorkspaceTrust(() => { + const project = findCurrentProject(); + if (project !== undefined) forceConfigureOnlyByProject.add(project.root); requestCurrentProjectReconciliation(true); }); @@ -1005,7 +1192,15 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return; } - const reconciled = await reconcileProject(project, true); + const configureOnlyPending = forceConfigureOnlyByProject.has(project.root); + const reconciled = await executeWithWorkspaceClangd( + () => reconcileProjectContext( + project, + true, + configureOnlyPending, + configureOnlyPending, + ), + ); if (kind === "build") { if (reconciled.context?.analysis.capability === "syntax-only") { const buildMessage = completion.state === "succeeded" ? "mcpp 构建完成" : "mcpp 构建失败"; @@ -1034,7 +1229,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); } }; - const cliController = new McppCliController({ + cliController = new McppCliController({ output, currentProject: findCurrentProject, afterProjectTask, @@ -1069,7 +1264,33 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi }); })), vscode.commands.registerCommand(COMMAND_REFRESH, runGuarded(async () => { - await cliController.runProjectTask("build"); + const project = findCurrentProject(); + if (project === undefined) { + await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); + return; + } + await executeWithWorkspaceClangd(async () => { + const hadUsableDatabase = hasUsableCompilationDatabase(project); + const result = await runConfigureOnlyForProject(project, true); + if (result === undefined) { + return; + } + const reconciled = await reconcileProjectContext(project, true, false, false); + const databaseValid = reconciled.context?.analysis.capability !== "unavailable"; + const outcome = describeConfigureOnlyOutcome( + result.exitCode, + databaseValid, + hadUsableDatabase && result.exitCode !== 0, + reconciled.configured, + ); + if (outcome.level === "information") { + await vscode.window.showInformationMessage(outcome.message); + } else if (outcome.level === "warning") { + await vscode.window.showWarningMessage(outcome.message); + } else { + await vscode.window.showErrorMessage(outcome.message); + } + }); })), vscode.commands.registerCommand(COMMAND_CHECK, runGuarded(async () => { const project = findCurrentProject(); @@ -1123,7 +1344,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await runModuleSupportCheck(ctx, status, output, "interactive"); return; case "rebuild": - await cliController.runProjectTask("build"); + await vscode.commands.executeCommand(COMMAND_REFRESH); return; } } @@ -1211,18 +1432,11 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi ); extensionContext.subscriptions.push( - manifestWatcher.onDidCreate(() => { - refreshStatus(); - cliController.refreshStatus(); - }), - manifestWatcher.onDidChange(() => { - refreshStatus(); - cliController.refreshStatus(); - }), - manifestWatcher.onDidDelete(() => { - refreshStatus(); - cliController.refreshStatus(); + manifestWatcher.onDidCreate((manifestUri) => requestManifestReconciliation(manifestUri)), + manifestWatcher.onDidChange((manifestUri) => { + requestManifestReconciliation(manifestUri); }), + manifestWatcher.onDidDelete((manifestUri) => requestDeletedManifestReconciliation(manifestUri)), ...registerCompilationDatabaseReconciliation( compilationDatabaseWatcher, requestAutomaticReconciliation, @@ -1234,6 +1448,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (project === undefined) { await reconcileProjectContext(undefined, false); } else { + lastReconciledProjectRoot = project.root; await reconcileProject(project, false); } } catch (error) { @@ -1245,5 +1460,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi export function deactivate(): void { moduleStatusByProject.clear(); moduleCheckOperations.clear(); + forceConfigureOnlyByProject.clear(); + lastReconciledProjectRoot = undefined; // VS Code 会释放 activate() 注册的所有订阅。 } diff --git a/src/ideWorkflow.ts b/src/ideWorkflow.ts new file mode 100644 index 0000000..954ae55 --- /dev/null +++ b/src/ideWorkflow.ts @@ -0,0 +1,45 @@ +import type { ProcessResult } from "./process"; + +export interface IdeConfigurationRequest { + projectRoot: string; + compilationDatabasePath: string; + trusted: boolean; + force?: boolean; + databaseValid: () => boolean; + configure: () => Promise; +} + +export type IdeConfigurationOutcome = + | { state: "configured"; compileCommands: string } + | { state: "existing"; compileCommands: string } + | { state: "failed"; compileCommands?: string; exitCode: number } + | { state: "untrusted"; compileCommands?: string }; + +/** + * 决定是否需要执行 mcpp build --configure-only;不在这里读写 VS Code 设置, + * 便于 Extension Host 和纯 Node 测试共享同一套生命周期边界。 + */ +export async function ensureIdeConfigured( + request: IdeConfigurationRequest, +): Promise { + const validBefore = request.databaseValid(); + if (!request.trusted) { + return { + state: "untrusted", + ...(validBefore ? { compileCommands: request.compilationDatabasePath } : {}), + }; + } + if (validBefore && !request.force) { + return { state: "existing", compileCommands: request.compilationDatabasePath }; + } + const result = await request.configure(); + const validAfter = request.databaseValid(); + if (result.exitCode === 0 && validAfter) { + return { state: "configured", compileCommands: request.compilationDatabasePath }; + } + return { + state: "failed", + ...(validAfter ? { compileCommands: request.compilationDatabasePath } : {}), + exitCode: result.exitCode, + }; +} diff --git a/src/newProject.ts b/src/newProject.ts index 07cdead..e7b6f0c 100644 --- a/src/newProject.ts +++ b/src/newProject.ts @@ -10,8 +10,8 @@ export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened"; /** * 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程—— - * 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程), - * 避免与缺少 CDB 时的 configure 重复执行。 + * 打开后的完整构建交给用户手动触发;新窗口按自身信任状态与 CDB 状态决定是否运行 + * `mcpp build --configure-only`,避免创建流程跨窗口重复启动配置。 */ export async function runNewProjectFlow( projectName: string, diff --git a/src/workflow.ts b/src/workflow.ts index 3def1fb..2d80817 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -182,6 +182,12 @@ export function configurationAffectsModuleSupport( return ["mcpp.clangd.path", "mcpp.modulesSupport"].some(affectsConfiguration); } +export function configurationAffectsMcppExecution( + affectsConfiguration: (section: string) => boolean, +): boolean { + return affectsConfiguration("mcpp.path"); +} + export function workspaceAllowsToolExecution(trusted: boolean): boolean { return trusted; } @@ -230,6 +236,46 @@ export function describeRefreshOutcome( }; } +export function describeConfigureOnlyOutcome( + exitCode: number, + databaseValid: boolean, + retainedExisting: boolean, + clangdConfigured: boolean, +): RefreshOutcome { + if (exitCode === 0 && databaseValid) { + return clangdConfigured + ? { + level: "information", + message: "编译数据库已刷新,clangd 配置已重新加载。", + } + : { + level: "warning", + message: "编译数据库已刷新,但 clangd 配置未完成。请查看 mcpp 输出频道。", + }; + } + if (exitCode === 0) { + return { + level: "error", + message: "mcpp configure-only 已完成,但没有生成可用的 compile_commands.json。", + }; + } + if (databaseValid) { + return retainedExisting + ? { + level: "warning", + message: "mcpp configure-only 失败;继续使用原有编译数据库。请查看 mcpp 输出频道。", + } + : { + level: "warning", + message: "mcpp configure-only 失败,但检测到可用的编译数据库。请查看 mcpp 输出频道。", + }; + } + return { + level: "error", + message: "mcpp configure-only 失败,且没有可用的编译数据库。请确认 mcpp 版本支持 build --configure-only。", + }; +} + export function statusCommandForCapability(capability: ModuleCapability): string { return capability === "unavailable" ? "mcpp.refreshCompilationDatabase" diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 72961f7..486e4b2 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -26,7 +26,7 @@ const root = path.resolve(process.cwd()); test("declares the official clangd dependency and mcpp commands", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.2.7"); + assert.equal(manifest.version, "0.3.0"); assert.ok(manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); assert.ok(manifest.activationEvents?.includes("workspaceContains:mcpp.toml")); assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.run")); @@ -113,6 +113,37 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); }); +test("wires configure-only through the existing refresh command", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + assert.ok(!manifest.activationEvents?.includes("onCommand:mcpp.configureIde")); + assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.refreshCompilationDatabase")); + + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + assert.match(source, /ensureIdeConfigured/); + assert.match(source, /runConfigureOnly/); + assert.doesNotMatch(source, /runIdeConfigure|COMMAND_IDE_CONFIGURE|ide["'],\s*["']configure/); + assert.match(source, /findProjectForUri[\s\S]*findNearestMcppProject\(uri\.fsPath, workspaceFolder\.uri\.fsPath\)/); + assert.match(source, /requestManifestReconciliation\s*=\s*\(manifestUri:[^)]+\)[\s\S]*findProjectForUri\(manifestUri\)/); + assert.match(source, /requestManifestReconciliation[\s\S]*projectAffectedByManifest\([\s\S]*manifestUri\.fsPath/); + assert.match(source, /manifestWatcher\.onDidChange\(\(manifestUri\) => \{[\s\S]*requestManifestReconciliation\(manifestUri\)/); + assert.match(source, /manifestWatcher\.onDidDelete\(\(manifestUri\) => requestDeletedManifestReconciliation\(manifestUri\)\)/); + assert.match(source, /configurationAffectsMcppExecution[\s\S]*forceConfigureOnlyByProject\.add/); + assert.match(source, /reconcilePublishedCdbByRoot[\s\S]*reconcileProjectContext\(project, forceRestart, false, false\)/); + assert.match(source, /requestAutomaticReconciliation[\s\S]*reconcilePublishedCdbByRoot\(project\.root, forceRestart\)/); + assert.match(source, /forceConfigureOnlyByProject\.has\(projectRoot\)/); +}); + +test("configure-only shares the project operation lock", () => { + const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const start = source.indexOf("public async runConfigureOnly"); + assert.notEqual(start, -1); + const end = source.indexOf("public async ", start + 10); + const method = source.slice(start, end === -1 ? source.length : end); + assert.match(method, /beginProject\(project\.root, token\)/); + assert.match(method, /runConfigureOnlyProcess/); + assert.match(method, /finally\s*\{[\s\S]*finishProject\(project\.root, token\)/); +}); + test("ships syntax-only C++ highlighting for the exact build.mcpp filename", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; const associations = manifest.contributes?.configurationDefaults?.["files.associations"] as diff --git a/test/configureOnly.test.ts b/test/configureOnly.test.ts new file mode 100644 index 0000000..c12c9dd --- /dev/null +++ b/test/configureOnly.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { configureOnlyArguments, runConfigureOnly } from "../src/configureOnly"; + +test("runs mcpp build --configure-only with the configured executable and cwd", async () => { + const calls: Array<{ executable: string; args: string[]; cwd?: string }> = []; + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async (executable, args, cwd) => { + calls.push({ executable, args, cwd }); + return { exitCode: 0, stdout: "Configured 2 compile commands", stderr: "" }; + }, + ); + + assert.deepEqual(calls, [{ + executable: "/tools/mcpp", + args: [...configureOnlyArguments], + cwd: "/work/app", + }]); + assert.equal(result.exitCode, 0); +}); + +test("does not interpret human-readable configure output", async () => { + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async () => ({ + exitCode: 0, + stdout: "Configured 2 compile commands\nFinished dev in 0.02s", + stderr: "", + }), + ); + + assert.equal(result.stdout, "Configured 2 compile commands\nFinished dev in 0.02s"); +}); + +test("returns a non-zero configure-only exit code without parsing stdout", async () => { + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async () => ({ + exitCode: 2, + stdout: "error: unknown option '--configure-only'", + stderr: "", + }), + ); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, "error: unknown option '--configure-only'"); +}); diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 2a44bae..807c126 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -4,7 +4,14 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { deriveClangdCandidates, findNearestMcppProject } from "../src/discovery"; +import { + deriveClangdCandidates, + findNearestMcppProject, + isPathWithinProject, + manifestProjectRoot, + projectAffectedByManifest, + shouldReconcileDeletedManifest, +} from "../src/discovery"; test("finds the nearest mcpp manifest and root compilation database", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-discovery-")); @@ -24,6 +31,14 @@ test("finds the nearest mcpp manifest and root compilation database", () => { } }); +test("routes a deleted manifest only to the project that owned it", () => { + assert.equal(manifestProjectRoot("/work/member/mcpp.toml"), path.join("/work", "member")); + assert.equal(shouldReconcileDeletedManifest("/work/member", "/work/member/mcpp.toml"), true); + assert.equal(shouldReconcileDeletedManifest("/work/other", "/work/member/mcpp.toml"), false); + assert.equal(isPathWithinProject("/work/member", "/work"), true); + assert.equal(isPathWithinProject("/work-other/member", "/work"), false); +}); + test("selects the nearest member inside a multi-member workspace", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-members-")); try { @@ -44,6 +59,84 @@ test("selects the nearest member inside a multi-member workspace", () => { } }); +test("does not treat a virtual workspace root as a package project", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-virtual-workspace-")); + try { + writeFileSync(path.join(root, "mcpp.toml"), "[workspace]\nmembers = ['A', 'B']\n"); + + // #387 在 virtual workspace 根执行 configure-only 时只发布 member CDB, + // 根目录本身没有可供 clangd 消费的 compile_commands.json。 + assert.equal(findNearestMcppProject(root, root), undefined); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("keeps a rooted workspace as a package project", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-rooted-workspace-")); + try { + writeFileSync( + path.join(root, "mcpp.toml"), + "[package]\nname = 'root'\n[workspace]\nmembers = ['A']\n", + ); + + assert.equal(findNearestMcppProject(root, root)?.root, root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("recognizes dotted virtual and inline rooted workspace manifests", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-toml-forms-")); + try { + writeFileSync(path.join(root, "mcpp.toml"), "workspace.members = ['A']\n"); + assert.equal(findNearestMcppProject(root, root), undefined); + + writeFileSync( + path.join(root, "mcpp.toml"), + "package = { name = 'root', version = '0.1.0' }\n[workspace]\nmembers = ['A']\n", + ); + assert.equal(findNearestMcppProject(root, root)?.root, root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("routes workspace root manifest changes to the active member", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-change-")); + try { + const memberRoot = path.join(root, "A"); + const member = { + root: memberRoot, + manifestPath: path.join(memberRoot, "mcpp.toml"), + compilationDatabasePath: path.join(memberRoot, "compile_commands.json"), + }; + const rootProject = { + root, + manifestPath: path.join(root, "mcpp.toml"), + compilationDatabasePath: path.join(root, "compile_commands.json"), + }; + mkdirSync(memberRoot, { recursive: true }); + + writeFileSync(rootProject.manifestPath, "[workspace]\nmembers = ['A']\n"); + assert.deepEqual( + projectAffectedByManifest(rootProject.manifestPath, member, undefined), + member, + ); + + writeFileSync( + rootProject.manifestPath, + "[package]\nname = 'root'\n[workspace]\nmembers = ['A']\n", + ); + assert.deepEqual( + projectAffectedByManifest(rootProject.manifestPath, member, rootProject), + member, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("does not discover an mcpp project outside the opened workspace folder", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-boundary-")); try { diff --git a/test/e2e/fixtures/fake-mcpp.js b/test/e2e/fixtures/fake-mcpp.js index 76daadf..b7e4b14 100644 --- a/test/e2e/fixtures/fake-mcpp.js +++ b/test/e2e/fixtures/fake-mcpp.js @@ -1,9 +1,23 @@ #!/usr/bin/env node const fs = require("node:fs"); +const path = require("node:path"); const logPath = process.env.MCPP_E2E_LOG; +const command = process.argv.slice(2).join(" "); if (typeof logPath === "string") { - fs.appendFileSync(logPath, `${process.argv.slice(2).join(" ")}\n`); + fs.appendFileSync(logPath, `${command}\n`); } -process.exit(process.argv.slice(2).join(" ") === "build" ? 0 : 1); + +if (command === "build --configure-only") { + // 使用 syntax-only CDB,避免 smoke test 依赖宿主 clangd 或真实编译器。 + const cwd = process.cwd(); + fs.writeFileSync(path.join(cwd, "compile_commands.json"), JSON.stringify([{ + directory: cwd, + file: path.join(cwd, "main.cpp"), + arguments: ["gcc", "-std=c++23", "-c", path.join(cwd, "main.cpp")], + }])); + process.exit(0); +} + +process.exit(command === "build" ? 0 : 1); diff --git a/test/e2e/suite/extension.test.ts b/test/e2e/suite/extension.test.ts index 4859237..d1d088d 100644 --- a/test/e2e/suite/extension.test.ts +++ b/test/e2e/suite/extension.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import * as vscode from "vscode"; suite("mcpp extension smoke", () => { - test("activates, registers commands and executes a configured build", async () => { + test("configures a missing CDB once before executing a full build", async () => { const extension = vscode.extensions.getExtension("mcpp-community.mcpp-vscode"); assert.ok(extension, "mcpp extension should be installed in development host"); await extension.activate(); @@ -22,13 +22,30 @@ suite("mcpp extension smoke", () => { await vscode.workspace.getConfiguration("mcpp", workspaceFolder.uri) .update("path", fakeMcpp, vscode.ConfigurationTarget.Workspace); - void vscode.commands.executeCommand("mcpp.build"); - const deadline = Date.now() + 15_000; - while (!existsSync(logPath) && Date.now() < deadline) { + const configureDeadline = Date.now() + 15_000; + let invocations: string[] = []; + while (Date.now() < configureDeadline) { + if (existsSync(logPath)) { + invocations = readFileSync(logPath, "utf8").trim().split("\n"); + if (invocations.includes("build --configure-only")) { + break; + } + } await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); } assert.ok(existsSync(logPath), "fake mcpp should have been invoked"); - assert.equal(readFileSync(logPath, "utf8").trim(), "build"); + + void vscode.commands.executeCommand("mcpp.build"); + const buildDeadline = Date.now() + 15_000; + while (Date.now() < buildDeadline) { + invocations = readFileSync(logPath, "utf8").trim().split("\n"); + if (invocations.includes("build")) { + break; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + assert.deepEqual(invocations, ["build --configure-only", "build"]); + assert.ok(existsSync(path.join(workspaceFolder.uri.fsPath, "compile_commands.json"))); assert.equal(path.basename(workspaceFolder.uri.fsPath), "project"); }); }); diff --git a/test/ideWorkflow.test.ts b/test/ideWorkflow.test.ts new file mode 100644 index 0000000..0f1f415 --- /dev/null +++ b/test/ideWorkflow.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureIdeConfigured } from "../src/ideWorkflow"; + +const success = { exitCode: 0, stdout: "Configured 1 compile command", stderr: "" }; + +test("configures a trusted project before clangd when the CDB is missing", async () => { + let calls = 0; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + databaseValid: () => calls > 0, + configure: async () => { + calls += 1; + return success; + }, + }); + + assert.equal(calls, 1); + assert.deepEqual(outcome, { + state: "configured", + compileCommands: "/work/app/compile_commands.json", + }); +}); + +test("keeps a valid existing CDB without invoking mcpp", async () => { + let called = false; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + databaseValid: () => true, + configure: async () => { + called = true; + throw new Error("must not run"); + }, + }); + + assert.equal(called, false); + assert.deepEqual(outcome, { + state: "existing", + compileCommands: "/work/app/compile_commands.json", + }); +}); + +test("does not execute mcpp in an untrusted workspace", async () => { + let called = false; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: false, + databaseValid: () => false, + configure: async () => { + called = true; + throw new Error("must not run"); + }, + }); + + assert.equal(called, false); + assert.deepEqual(outcome, { state: "untrusted" }); +}); + +test("retains an existing CDB when forced configure-only fails", async () => { + let called = false; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + force: true, + databaseValid: () => true, + configure: async () => { + called = true; + return { exitCode: 1, stdout: "compile failed", stderr: "" }; + }, + }); + + assert.equal(called, true); + assert.deepEqual(outcome, { + state: "failed", + compileCommands: "/work/app/compile_commands.json", + exitCode: 1, + }); +}); + +test("requires a valid CDB after configure-only succeeds", async () => { + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + databaseValid: () => false, + configure: async () => success, + }); + + assert.deepEqual(outcome, { state: "failed", exitCode: 0 }); +}); + +test("force refresh invokes configure-only when a CDB already exists", async () => { + let calls = 0; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + force: true, + databaseValid: () => true, + configure: async () => { + calls += 1; + return success; + }, + }); + + assert.equal(calls, 1); + assert.deepEqual(outcome, { + state: "configured", + compileCommands: "/work/app/compile_commands.json", + }); +}); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index e56019b..ce84c52 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -5,6 +5,9 @@ import * as workflow from "../src/workflow"; import { createSerialExecutor, createSingleFlightReconciler, + configurationAffectsMcppExecution, + configurationAffectsModuleSupport, + describeConfigureOnlyOutcome, describeRefreshOutcome, statusCommandForCapability, } from "../src/workflow"; @@ -28,6 +31,33 @@ test("keeps IDE configuration when a source build fails after producing a databa assert.equal(describeRefreshOutcome(1, false, false).level, "error"); }); +test("describes configure-only results without treating an old CDB as newly generated", () => { + assert.deepEqual(describeConfigureOnlyOutcome(0, true, false, true), { + level: "information", + message: "编译数据库已刷新,clangd 配置已重新加载。", + }); + assert.deepEqual(describeConfigureOnlyOutcome(0, true, false, false), { + level: "warning", + message: "编译数据库已刷新,但 clangd 配置未完成。请查看 mcpp 输出频道。", + }); + assert.deepEqual(describeConfigureOnlyOutcome(0, false, false, false), { + level: "error", + message: "mcpp configure-only 已完成,但没有生成可用的 compile_commands.json。", + }); + assert.deepEqual(describeConfigureOnlyOutcome(2, true, true, false), { + level: "warning", + message: "mcpp configure-only 失败;继续使用原有编译数据库。请查看 mcpp 输出频道。", + }); + assert.deepEqual(describeConfigureOnlyOutcome(2, true, false, false), { + level: "warning", + message: "mcpp configure-only 失败,但检测到可用的编译数据库。请查看 mcpp 输出频道。", + }); + assert.deepEqual(describeConfigureOnlyOutcome(2, false, false, false), { + level: "error", + message: "mcpp configure-only 失败,且没有可用的编译数据库。请确认 mcpp 版本支持 build --configure-only。", + }); +}); + test("serializes reconciliation and merges pending restart requests", async () => { let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { @@ -369,30 +399,29 @@ test("accepts module status only from the latest check for the current project", assert.equal(shouldRender(undefined, "/work/a"), false); }); -test("reconciles when a module-related mcpp setting changes", () => { - const affectsModuleConfiguration = ( - workflow as typeof workflow & { - configurationAffectsModuleSupport?: ( - affectsConfiguration: (section: string) => boolean, - ) => boolean; - } - ).configurationAffectsModuleSupport; - assert.equal(typeof affectsModuleConfiguration, "function"); - assert.ok(affectsModuleConfiguration); - +test("separates mcpp executable changes from clangd-only settings", () => { assert.equal( - affectsModuleConfiguration((section) => section === "mcpp.clangd.path"), + configurationAffectsModuleSupport((section) => section === "mcpp.clangd.path"), true, ); assert.equal( - affectsModuleConfiguration((section) => section === "mcpp.modulesSupport"), + configurationAffectsModuleSupport((section) => section === "mcpp.modulesSupport"), + true, + ); + assert.equal( + configurationAffectsModuleSupport((section) => section === "mcpp.path"), + false, + ); + assert.equal( + configurationAffectsMcppExecution((section) => section === "mcpp.path"), true, ); assert.equal( - affectsModuleConfiguration((section) => section === "mcpp.path"), + configurationAffectsMcppExecution((section) => section === "mcpp.clangd.path"), false, ); - assert.equal(affectsModuleConfiguration(() => false), false); + assert.equal(configurationAffectsModuleSupport(() => false), false); + assert.equal(configurationAffectsMcppExecution(() => false), false); }); test("only executes workspace-selected tools in trusted workspaces", () => {