diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6b21fe3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.tsdk.path": "node_modules/typescript/lib" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a51eb06..831d24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 更新日志 +## 0.3.1 - 2026-08-11 + +- 修复缺少 CDB 时 IDE 命令看似无响应的问题:配置 clangd、刷新编译数据库、检查模块支持和一键配置现在会立即显示进度并打开 `mcpp` 输出频道;clangd 重启与 `mcpp build --configure-only` 增加超时,避免异步操作永久占用 IDE 队列。 +- 增加删除 `compile_commands.json` 后刷新 CDB、配置 clangd、检查模块支持和一键配置的 Extension Host 回归测试。 + +## 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 内置 + xlings 的问题:xlings 发现以 `mcpp self env` 为权威来源(项目级契约),路径探测仅作回退; + 为 `mcpp self env` 调用增加超时保护,并补齐测试(PR #11)。 + +## 0.2.6 + +- 新增 **mcpp: 一键配置模块代码提示** 向导:按「安装/切换工具链 → 构建 → 重载 → clangd + 配置 → 模块检查」链恢复 IDE,支持取消与失败降级;仅在受信任工作区执行(issues #6/#7, + PR #10)。 +- 新增 `mcpp.toml` 结构补全:段头 snippet(26 段)与依赖/feature 等写法模板,所有建议 + 携带显式替换范围并经真实 mcpp 契约测试;由 `mcpp.tomlCompletion` 设置控制(默认开启), + 未受信任工作区仅纯文本分析(PR #9)。 + ## 0.2.5 - 编辑器标题栏的 mcpp 运行/测试操作按当前活动文件所属的 mcpp member 作用域执行;工作区外 diff --git a/README.md b/README.md index 326d8a7..b96ae2a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 把 mcpp 工程、C++ 模块语法和官方 clangd 扩展接入 VS Code。 -当前版本为 `0.2.5`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 +当前版本为 `0.3.1`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的构建逻辑。 > 当前完整的模块语义能力只支持 LLVM/Clang 工具链。GCC 和 MSVC 工程仍可使用 @@ -18,6 +18,7 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 | --- | --- | --- | --- | | 识别 mcpp 工程 | 支持 | 支持 | 支持 | | `mcpp.toml` TOML 语法高亮 | 支持 | 支持 | 支持 | +| `mcpp.toml` 结构补全(段头 + 写法模板) | 支持 | 支持 | 支持 | | `build.mcpp`、`.cppm`、`.ixx`、`.mpp`、`.ccm` 文件关联 | 支持 | 支持 | 支持 | | `module`、`export module`、`import` 语法高亮 | 支持 | 支持 | 支持 | | 构建、运行、测试、清理命令 | 支持 | 支持 | 支持 | @@ -32,11 +33,11 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 ## 安装 -从 [GitHub Releases](https://github.com/wellwei/mcpp-vscode/releases) 下载对应版本的 +从 [GitHub Releases](https://github.com/mcpp-community/mcpp-vscode/releases) 下载对应版本的 VSIX,然后在 VS Code 中执行 **Extensions: Install from VSIX...**,或者运行: ```sh -code --install-extension /path/to/mcpp-vscode-0.2.5.vsix +code --install-extension /path/to/mcpp-vscode-0.3.1.vsix ``` 安装后确认当前 VS Code profile 中同时存在 `mcpp-community.mcpp-vscode` 和 @@ -46,7 +47,9 @@ code --install-extension /path/to/mcpp-vscode-0.2.5.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。 @@ -56,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 合并到虚拟根。 ## 已实现功能 @@ -77,7 +85,8 @@ xlings 安装。 - 打开已有有效 CDB 的 LLVM 工程后自动配置并检查,不需要先点击状态栏或 Reload Window。 -扩展激活本身不会静默运行 `mcpp build`,也不会自动下载、安装或切换工具链。 +扩展激活不会静默运行完整 `mcpp build`,也不会自动下载、安装或切换工具链;受信任 +工程缺少有效 CDB 时会自动运行配置阶段的 `mcpp build --configure-only`。 ### 语法高亮 @@ -97,6 +106,19 @@ TextMate 语法规则提供。 `import mcpp;` 不会再被误报为缺少 C++ 模块。真正的 mcpp API 补全需要 mcpp 核心 未来生成宿主 helper 的 CDB 和 PCM 映射。 +### mcpp.toml 结构补全 + +- 段头补全:26 个段(`[package]`、`[targets.]`、`[dependencies]`、`[build-dependencies]`、 + `[features]`、`[indices]`、`[pack]` 等),参数化段带可跳转占位符。 +- 写法模板:依赖段的依赖写法(版本 / 路径 / git / features / tools)、`[features]` 表形式、 + capabilities / xlings / tools.overrides / generated_files 等开放段的条目形态。 +- 每条建议携带显式替换范围,部分输入(`[dep`、`na`)不会残留无效文本;所有语义规则有真实 + mcpp 契约测试(44 例,无 mcpp 环境自动跳过)。 +- 范围边界:不提供静态字段键/枚举(等上游版本化 manifest schema),不提供依赖包名/版本候选 + (等上游批量 catalog 接口);未知自定义段与 `[[...]]` 数组表不提供建议。 +- 由 `mcpp.tomlCompletion` 设置控制(默认开启);未受信任工作区仅做纯文本分析,不执行任何 + 外部程序。 + ### LLVM 与 clangd 集成 扩展读取 mcpp 生成的 `compile_commands.json`,然后: @@ -127,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 与工具链管理 @@ -161,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 工具链时停止 | @@ -172,7 +195,8 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB "mcpp.path": "/path/to/mcpp", "mcpp.clangd.path": "/path/to/matching/clangd", "mcpp.modulesSupport": "auto", - "mcpp.configureCppTools": true + "mcpp.configureCppTools": true, + "mcpp.tomlCompletion": true } ``` @@ -182,6 +206,7 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB | `mcpp.clangd.path` | 空 | 与 CDB 中 LLVM 编译器匹配的 clangd;空值表示自动发现 | | `mcpp.modulesSupport` | `auto` | `auto`、`on` 或 `off`,控制 clangd 实验模块参数 | | `mcpp.configureCppTools` | `true` | 手动配置 clangd 时,是否询问关闭当前工作区的 cpptools IntelliSense | +| `mcpp.tomlCompletion` | `true` | 为 `mcpp.toml` 提供结构补全:段头与写法模板(snippet);所有建议带显式替换范围,并经真实 mcpp 契约测试验证 | `mcpp.path` 只影响插件执行 mcpp CLI 命令。工程实际编译器来自 `compile_commands.json`,`mcpp.clangd.path` 只指定语言服务器,三者相互独立。 @@ -217,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 模块支持仍是实验能力 @@ -237,7 +263,9 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 未受信任工作区只启用文件关联和语法高亮,不执行 CDB 中的编译器、mcpp、clangd 或 工程指定程序,也不接管 clangd 配置。授予信任后,扩展会自动重新协调当前工程;仍不 -会在没有用户操作时下载工具链或发起构建。 +会在没有用户操作时下载或切换工具链,也不会发起完整构建;缺少有效 CDB 时可以执行 +配置阶段的 configure-only。该命令不是只读操作:mcpp 仍可能执行 `build.mcpp`、解析或 +安装缺失依赖和工具链,并写入 lock、构建目录与相关元数据。 ## 当前版本能达到的效果 @@ -249,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 命令和引导操作。 ## 理想最终效果 @@ -272,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 输出文件、行、列、严重级别和稳定错误码,插件可精确定位配置期错误 | 必需 | @@ -295,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 @@ -339,15 +372,17 @@ 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 未安装或无法匹配 - 执行 **mcpp: 一键配置模块代码提示**,扩展会自动通过 xlings 安装与当前编译器 版本匹配的 llvm-tools(含 clangd)。 +- xlings 可执行文件的定位以 `mcpp self env` 报告为准(覆盖 install.sh、AUR 等 + mcpp 内置安装布局),找不到时再回退到独立 `~/.xlings` 与 `PATH`。 - `mcpp.clangd.path` 可以指向任意安装来源的真实 clangd,不要求与 mcpp 同目录。 - 自动发现会尝试编译器同目录、匹配的 xlings `xim-x-llvm-tools//bin` 和 `PATH`。 @@ -384,13 +419,13 @@ API、状态栏、任务和 clangd 集成。 版本完全一致的 tag: ```sh -git tag -a v0.2.5 -m "mcpp-vscode 0.2.5" -git push origin v0.2.5 +git tag -a v0.3.1 -m "mcpp-vscode 0.3.1" +git push origin v0.3.1 ``` `.github/workflows/release.yml` 会校验 tag,执行测试和打包,生成 VSIX 与 SHA-256 文件, 并创建或更新对应的 GitHub Release。工作流当前不会发布到 VS Marketplace。 -项目地址:[wellwei/mcpp-vscode](https://github.com/wellwei/mcpp-vscode) +项目地址:[mcpp-community/mcpp-vscode](https://github.com/mcpp-community/mcpp-vscode) -问题反馈:[GitHub Issues](https://github.com/wellwei/mcpp-vscode/issues) +问题反馈:[GitHub Issues](https://github.com/mcpp-community/mcpp-vscode/issues) diff --git a/package-lock.json b/package-lock.json index ae908dd..3feca73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.2.5", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.2.5", + "version": "0.3.1", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index 4ccbd35..7c681d5 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,17 @@ "name": "mcpp-vscode", "displayName": "mcpp", "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.2.5", + "version": "0.3.1", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", "repository": { "type": "git", - "url": "https://github.com/wellwei/mcpp-vscode.git" + "url": "https://github.com/mcpp-community/mcpp-vscode.git" }, - "homepage": "https://github.com/wellwei/mcpp-vscode#readme", + "homepage": "https://github.com/mcpp-community/mcpp-vscode#readme", "bugs": { - "url": "https://github.com/wellwei/mcpp-vscode/issues" + "url": "https://github.com/mcpp-community/mcpp-vscode/issues" }, "engines": { "vscode": "^1.90.0" @@ -24,6 +24,7 @@ "workspaceContains:mcpp.toml", "onLanguage:cpp", "onLanguage:mcpp-build", + "onLanguage:mcpp-toml", "onCommand:mcpp.configureClangd", "onCommand:mcpp.refreshCompilationDatabase", "onCommand:mcpp.checkModuleSupport", @@ -45,7 +46,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "未受信任工作区仅启用模块语法高亮,不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" + "description": "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" } }, "main": "./dist/src/extension.js", @@ -151,6 +152,12 @@ "default": true, "scope": "resource", "description": "配置 clangd 时,是否询问关闭 Microsoft C/C++ IntelliSense。" + }, + "mcpp.tomlCompletion": { + "type": "boolean", + "default": true, + "scope": "resource", + "description": "为 mcpp.toml 提供结构补全:段头与写法模板(snippet)。所有建议带显式替换范围,并经真实 mcpp 契约测试验证。" } } }, diff --git a/src/cliController.ts b/src/cliController.ts index ef77e6a..0cec3d9 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 { @@ -607,7 +632,7 @@ export class McppCliController { } catch { // 输出频道可能已经在窗口重载时释放。 } - await vscode.window.showErrorMessage(`mcpp:${message}`); + void vscode.window.showErrorMessage(`mcpp:${message}`); } }; } @@ -630,7 +655,7 @@ export class McppCliController { return false; } - private mcppExecutable(project: McppProjectDiscovery | undefined): string { + public mcppExecutable(project: McppProjectDiscovery | undefined): string { const uri = project === undefined ? vscode.workspace.workspaceFolders?.[0]?.uri : vscode.Uri.file(project.root); @@ -654,7 +679,7 @@ export class McppCliController { const result = await runProcess(executable, args, workingDirectory(project)); this.appendShortCommand("查看工具链", executable, args, result); if (result.exitCode !== 0) { - await vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( `mcpp toolchain list 失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, ); return undefined; @@ -662,7 +687,7 @@ export class McppCliController { const inventory = parseToolchainList(`${result.stdout}${result.stderr.length > 0 ? `\n${result.stderr}` : ""}`); if (!inventory.recognized) { - await vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( "无法识别当前 mcpp toolchain list 输出;原始输出已保留在 mcpp 输出频道,请检查 mcpp 版本。", ); return undefined; diff --git a/src/configureOnly.ts b/src/configureOnly.ts new file mode 100644 index 0000000..cf233ae --- /dev/null +++ b/src/configureOnly.ts @@ -0,0 +1,18 @@ +import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; + +export const configureOnlyArguments = ["build", "--configure-only"] as const; +// 配置阶段可能解析工具链和依赖,但不能无限期占住 IDE 操作队列。 +export const configureOnlyTimeoutMs = 5 * 60_000; + +export function runConfigureOnly( + projectRoot: string, + executable = "mcpp", + runner: ProcessRunner = runProcess, +): Promise { + return runner( + executable, + [...configureOnlyArguments], + projectRoot, + { timeoutMs: configureOnlyTimeoutMs }, + ); +} 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 199dbcf..0d26e3d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,22 +16,29 @@ import { import { deriveClangdCandidates, findNearestMcppProject, + isPathWithinProject, + manifestProjectRoot, + projectAffectedByManifest, + shouldReconcileDeletedManifest, type McppProjectDiscovery, } from "./discovery"; import { - findXlingsExecutable, + resolveXlingsExecutable, llvmToolsVersionSpec, xlingsInstallArgs, } 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, @@ -40,11 +47,13 @@ import { shouldRenderProjectStatus, shouldUseWorkspaceClangd, statusCommandForCapability, + withTimeout, workspaceAllowsToolExecution, type ModuleSupportState, } from "./workflow"; import { classifyTaskExit, type TaskCompletion } from "./tasks"; import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./inProject"; +import { computeMcppTomlCompletions } from "./mcppTomlCompletion"; import { buildModuleSetupPlan, executeModuleSetup, @@ -83,6 +92,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 { @@ -100,7 +112,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; } @@ -108,6 +123,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; @@ -141,6 +183,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": @@ -261,12 +307,23 @@ async function maybeDisableCppTools(project: McppProjectDiscovery): Promise { +const CLANGD_RESTART_TIMEOUT_MS = 15_000; + +async function restartClangd(output: vscode.OutputChannel): Promise { if (vscode.extensions.getExtension("llvm-vs-code-extensions.vscode-clangd") === undefined) { return false; } try { - await vscode.commands.executeCommand("clangd.restart"); + const timedOut = {}; + const result = await withTimeout( + vscode.commands.executeCommand("clangd.restart"), + CLANGD_RESTART_TIMEOUT_MS, + timedOut, + ); + if (result === timedOut) { + appendOutputLine(output, `[clangd] 重启命令超过 ${CLANGD_RESTART_TIMEOUT_MS / 1000} 秒未完成,已释放 IDE 操作队列。`); + return false; + } return true; } catch { // clangd 扩展可能尚未激活,但配置仍然已经写入。 @@ -284,7 +341,7 @@ async function configureClangd( const interactive = mode === "interactive"; if (context.analysis.capability === "syntax-only") { if (interactive) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${context.analysis.kind.toUpperCase()} 模块产物无法由 clangd 读取。语法高亮仍然可用,但模块语义诊断需要 LLVM mcpp 工具链。`, ); } @@ -293,7 +350,7 @@ async function configureClangd( } if (context.analysis.capability !== "full" || context.analysis.compilerPath === undefined) { if (interactive) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${context.analysis.reason} 请先运行“mcpp: 刷新编译数据库”。`, ); } @@ -302,7 +359,7 @@ async function configureClangd( if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { const message = "当前工作区未受信任,mcpp 不会执行 CDB 中的编译器或 clangd,也不会接管 clangd 配置。"; if (interactive) { - await vscode.window.showWarningMessage(message); + void vscode.window.showWarningMessage(message); } else { appendOutputLine(output, `[自动配置] ${message}`); } @@ -313,7 +370,7 @@ async function configureClangd( if (clangd === undefined) { const message = "没有找到可用的 clangd。请安装与 mcpp LLVM 编译器来自同一 revision 的 clangd,或设置 mcpp.clangd.path;clangd 可以来自 xlings llvm-tools,也可以独立安装。"; if (interactive) { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } else { appendOutputLine(output, `[自动配置] ${message}`); } @@ -354,11 +411,11 @@ async function configureClangd( await maybeDisableCppTools(context.project); } const restartRequired = shouldRestartClangd(plan.changed, interactive, forceRestart); - const restartSucceeded = restartRequired ? await restartClangd() : false; + const restartSucceeded = restartRequired ? await restartClangd(output) : false; if (!configurationReadyAfterRestart(restartRequired, restartSucceeded)) { const message = "clangd 配置已写入,但无法重启语言服务器。请查看 mcpp 输出频道,或手动执行 clangd 重启命令。"; if (interactive) { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } else { appendOutputLine(output, `[自动配置] ${message}`); } @@ -367,11 +424,11 @@ async function configureClangd( updateStatusBar(status, context); if (!clangd.comparison.compatible) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `clangd 已配置,但 LLVM 身份与 mcpp 编译器不匹配:${clangd.comparison.reason}。`, ); } else if (interactive) { - await vscode.window.showInformationMessage("mcpp 已为当前工作区配置匹配的 clangd。"); + void vscode.window.showInformationMessage("mcpp 已为当前工作区配置匹配的 clangd。"); } else if (plan.changed) { appendOutputLine(output, `[自动配置] clangd.path = ${plan.path}`); } @@ -501,7 +558,7 @@ async function runModuleSupportCheck( storeModuleStatus(status, context, { state: "unavailable", message }); } if (interactive) { - await vscode.window.showWarningMessage(message); + void vscode.window.showWarningMessage(message); } return context.analysis.capability === "full" ? { state: "unavailable", message } @@ -514,7 +571,7 @@ async function runModuleSupportCheck( const moduleStatus = { state: "unavailable", message } as const; storeModuleStatus(status, context, moduleStatus); if (interactive) { - await vscode.window.showWarningMessage(message); + void vscode.window.showWarningMessage(message); } else { appendOutputLine(output, `[自动检查] ${message}`); } @@ -538,7 +595,7 @@ async function runModuleSupportCheck( return undefined; } if (interactive) { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } else { appendOutputLine(output, `[自动检查] ${message}`); } @@ -585,9 +642,9 @@ async function runModuleSupportCheck( } if (interactive) { if (moduleStatus.state === "available") { - await vscode.window.showInformationMessage(message); + void vscode.window.showInformationMessage(message); } else { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } } else { appendOutputLine(output, `[自动检查] ${message}`); @@ -706,7 +763,7 @@ async function autoConfigureModulesWizard( appendOutputLine(output, "[一键配置] 开始一键配置模块代码提示..."); if (!vscode.workspace.isTrusted) { - await vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); + void vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); return; } const inventory = await cliController.readToolchainInventory(context.project); @@ -720,7 +777,7 @@ async function autoConfigureModulesWizard( cliController.isBusy(), ); if (decision.kind === "blocked") { - await vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); + void vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); return; } @@ -762,7 +819,9 @@ async function autoConfigureModulesWizard( : { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" }; } - const xlingsPath = findXlingsExecutable(); + const xlingsPath = await resolveXlingsExecutable( + cliController.mcppExecutable(currentContext.project), + ); const compilerPath = currentContext.analysis.compilerPath; if (xlingsPath === undefined || compilerPath === undefined) { return { @@ -814,20 +873,63 @@ async function autoConfigureModulesWizard( if (outcome.state === "succeeded") { if (outcome.degraded) { - await vscode.window.showWarningMessage("构建失败,语言服务已刷新。请查看任务终端获取构建错误。"); + void vscode.window.showWarningMessage("构建失败,语言服务已刷新。请查看任务终端获取构建错误。"); } else { - await vscode.window.showInformationMessage("mcpp 模块代码提示一键配置完成。"); + void vscode.window.showInformationMessage("mcpp 模块代码提示一键配置完成。"); } } else if (outcome.state === "cancelled") { - await vscode.window.showWarningMessage(`一键配置已取消(${outcome.stage})。`); + void vscode.window.showWarningMessage(`一键配置已取消(${outcome.stage})。`); } else { - await vscode.window.showErrorMessage(`一键配置失败(${outcome.stage})。${outcome.steps.at(-1)?.detail ?? "请查看 mcpp 输出频道。"}`); + void vscode.window.showErrorMessage(`一键配置失败(${outcome.stage})。${outcome.steps.at(-1)?.detail ?? "请查看 mcpp 输出频道。"}`); } } +// mcpp.toml 结构补全:建议由纯函数 computeMcppTomlCompletions 计算,这里只做 +// vscode 类型映射。范围:段头 snippet + 开放词汇段的写法模板;不含字段键/枚举 +// 与依赖数据(分别等上游版本化 schema 与批量 catalog 接口)。 +const mcppTomlCompletionKinds = { + section: vscode.CompletionItemKind.Folder, + template: vscode.CompletionItemKind.Snippet, +} as const; + +const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { + provideCompletionItems(document, position) { + // mcpp.toml 结构补全由 mcpp.tomlCompletion 控制,按文档作用域读取。 + if (!vscode.workspace.getConfiguration("mcpp", document.uri).get("tomlCompletion", true)) { + return undefined; + } + const lines: string[] = []; + for (let line = 0; line <= position.line; line += 1) { + lines.push(document.lineAt(line).text); + } + return computeMcppTomlCompletions(lines, position.line, position.character).map((suggestion) => { + const item = new vscode.CompletionItem( + suggestion.label, + mcppTomlCompletionKinds[suggestion.kind], + ); + item.detail = suggestion.detail; + if (suggestion.documentation !== undefined) { + item.documentation = new vscode.MarkdownString(suggestion.documentation); + } + if (suggestion.insertSnippet !== undefined) { + item.insertText = new vscode.SnippetString(suggestion.insertSnippet); + } + item.range = new vscode.Range( + position.line, + suggestion.range.startCharacter, + position.line, + suggestion.range.endCharacter, + ); + return item; + }); + }, +}; + 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); @@ -841,6 +943,35 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi void vscode.window.showErrorMessage(`mcpp:${message}`); } }; + const showInteractiveIdeStart = (title: string): void => { + appendOutputLine(output, `\n[IDE] ${title}:已接收,正在等待 IDE 操作队列。`); + output.show(true); + }; + 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) { + void vscode.window.showWarningMessage( + "当前工作区未受信任,不会刷新编译数据库。请先信任工作区。", + ); + } + return undefined; + } + const result = await cliController.runConfigureOnly(project); + if (result === undefined) { + appendOutputLine(output, "[CDB 配置] 已有 mcpp 操作正在运行,本次刷新已跳过。"); + if (interactive) { + void 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({ @@ -857,10 +988,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, @@ -868,6 +1001,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, @@ -895,7 +1061,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); }, ), ); @@ -910,7 +1095,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; } @@ -918,7 +1103,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}`); }); @@ -926,20 +1112,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), )) { @@ -947,6 +1192,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); }); @@ -961,11 +1208,19 @@ 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 构建失败"; - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${buildMessage};${reconciled.context.analysis.reason}。模块语法高亮仍然可用。`, ); return; @@ -976,21 +1231,21 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi reconciled.configured, ); if (outcome.level === "information") { - await vscode.window.showInformationMessage(outcome.message); + void vscode.window.showInformationMessage(outcome.message); } else if (outcome.level === "warning") { - await vscode.window.showWarningMessage(outcome.message); + void vscode.window.showWarningMessage(outcome.message); } else { - await vscode.window.showErrorMessage(outcome.message); + void vscode.window.showErrorMessage(outcome.message); } return; } if (completion.state === "succeeded") { const label = kind === "run" ? "运行" : kind === "test" ? "测试" : "清理"; - await vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); + void vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); } }; - const cliController = new McppCliController({ + cliController = new McppCliController({ output, currentProject: findCurrentProject, afterProjectTask, @@ -1001,12 +1256,18 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi output, status, ...cliController.register(), + vscode.languages.registerCompletionItemProvider( + { language: "mcpp-toml" }, + mcppTomlCompletionProvider, + "[", + ), vscode.commands.registerCommand(COMMAND_CONFIGURE, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("配置 clangd"); await executeWithWorkspaceClangd(async () => { if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; @@ -1020,7 +1281,34 @@ 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; + } + showInteractiveIdeStart("刷新编译数据库"); + 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") { + void vscode.window.showInformationMessage(outcome.message); + } else if (outcome.level === "warning") { + void vscode.window.showWarningMessage(outcome.message); + } else { + void vscode.window.showErrorMessage(outcome.message); + } + }); })), vscode.commands.registerCommand(COMMAND_CHECK, runGuarded(async () => { const project = findCurrentProject(); @@ -1028,6 +1316,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("检查模块支持"); await executeWithWorkspaceClangd(async () => { if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; @@ -1074,7 +1363,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; } } @@ -1131,6 +1420,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("一键配置模块代码提示"); await executeWithWorkspaceClangd(async () => { if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; @@ -1162,18 +1452,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, @@ -1185,6 +1468,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) { @@ -1196,5 +1480,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/llvmTools.ts b/src/llvmTools.ts index be064f9..f573f05 100644 --- a/src/llvmTools.ts +++ b/src/llvmTools.ts @@ -4,7 +4,11 @@ import path from "node:path"; import process from "node:process"; import type { ToolIdentity } from "./analysis"; -import { runProcess, type ProcessResult } from "./process"; +import { + runProcess, + type ProcessResult, + type ProcessRunner, +} from "./process"; export function llvmToolsVersionSpec(identity: ToolIdentity): string { return `${identity.major}.${identity.minor}.${identity.patch}`; @@ -60,8 +64,16 @@ export function xlingsInstallArgs(version?: string): string[] { return ["update", "llvm-tools"]; } -export function findXlingsExecutable(): string | undefined { - const home = os.homedir(); +export interface FindXlingsOptions { + /** Override for tests: base home directory instead of os.homedir(). */ + home?: string; + /** Override for tests: environment instead of process.env. */ + env?: NodeJS.ProcessEnv; +} + +export function findXlingsExecutable(options?: FindXlingsOptions): string | undefined { + const home = options?.home ?? os.homedir(); + const env = options?.env ?? process.env; const knownPaths = [ path.join(home, ".xlings", "subos", "current", "bin", "xlings"), path.join(home, ".xlings", "bin", "xlings"), @@ -72,6 +84,26 @@ export function findXlingsExecutable(): string | undefined { ); } + // mcpp (install.sh / AUR / mcpp-m) bundles xlings inside its own registry + // sandbox instead of installing to ~/.xlings. The AUR launcher pins the + // path via MCPP_VENDORED_XLINGS; otherwise it lives at + // $MCPP_HOME/registry/bin/xlings. Without probing both, the one-click + // module setup can never auto-install llvm-tools after a standard install. + const vendored = env.MCPP_VENDORED_XLINGS?.trim(); + if (vendored !== undefined && vendored.length > 0) { + knownPaths.push(vendored); + } + const mcppHome = env.MCPP_HOME?.trim(); + const extension = process.platform === "win32" ? ".exe" : ""; + knownPaths.push( + path.join( + mcppHome !== undefined && mcppHome.length > 0 ? mcppHome : path.join(home, ".mcpp"), + "registry", + "bin", + `xlings${extension}`, + ), + ); + // Check known install paths first for (const candidate of knownPaths) { if (existsSync(candidate)) { @@ -82,15 +114,15 @@ export function findXlingsExecutable(): string | undefined { // Fall back to PATH, but only when "xlings" actually resolves there. Always // returning "xlings" hid the not-installed case, so callers could never show // the "xlings 未安装" guidance. - return xlingsResolvableOnPath() ? "xlings" : undefined; + return xlingsResolvableOnPath(env.PATH) ? "xlings" : undefined; } -function xlingsResolvableOnPath(): boolean { +function xlingsResolvableOnPath(pathValue?: string): boolean { const names = process.platform === "win32" ? ["xlings.exe", "xlings.cmd", "xlings.bat"] : ["xlings"]; - const pathValue = process.env.PATH ?? ""; - for (const dir of pathValue.split(path.delimiter)) { + const pathEnv = pathValue ?? ""; + for (const dir of pathEnv.split(path.delimiter)) { if (dir.length === 0) { continue; } @@ -103,6 +135,35 @@ function xlingsResolvableOnPath(): boolean { return false; } +const XLINGS_BINARY_LINE = /^\s*xlings binary\s*=\s*(.+?)\s*$/im; + +// Source of truth is mcpp itself, not the filesystem or PATH: `mcpp self env` +// reports the exact xlings bundled with THIS mcpp (mcpp is a project-level +// environment; it owns its tool paths). Works for install.sh, AUR and any +// custom MCPP_PREFIX layout. Falls back to the historical path heuristics for +// standalone ~/.xlings installs and for mcpp versions without the line. +// +// The subprocess is bounded by MCPP_SELF_ENV_TIMEOUT_MS: the wizard reaches +// this step only after mcpp is initialized (toolchain list / build already +// ran), so 60s is generous while still guarding against an extreme hang. +const MCPP_SELF_ENV_TIMEOUT_MS = 60_000; + +export async function resolveXlingsExecutable( + mcppExecutable: string, + runner: ProcessRunner = runProcess, + options?: FindXlingsOptions, +): Promise { + const result = await runner(mcppExecutable, ["self", "env"], undefined, { + timeoutMs: MCPP_SELF_ENV_TIMEOUT_MS, + }); + const match = `${result.stdout}\n${result.stderr}`.match(XLINGS_BINARY_LINE); + const reported = match?.[1]?.trim(); + if (reported !== undefined && reported.length > 0 && existsSync(reported)) { + return reported; + } + return findXlingsExecutable(options); +} + export async function runXlingsCommand( xlingsPath: string, args: string[], diff --git a/src/mcppTomlCompletion.ts b/src/mcppTomlCompletion.ts new file mode 100644 index 0000000..3cc0e16 --- /dev/null +++ b/src/mcppTomlCompletion.ts @@ -0,0 +1,229 @@ +// mcpp.toml 的代码补全查询层(结构补全版)。 +// +// 范围:段头结构建议 + 开放词汇段的写法模板。每条建议携带显式替换范围。 +// 依赖包名/版本等动态数据补全与静态字段键/枚举补全均不在本版——前者等上游 +// 批量 catalog 接口,后者等版本化 manifest schema(见设计 issue #8 与 +// mcpp RFC #379)。 +// +// 本模块不依赖 vscode API;上下文来自 mcppTomlParser 的 contextAt(容错解析)。 + +import { + contextAt, + type ReplaceRange, + type SectionResolution, +} from "./mcppTomlParser"; + +export type McppTomlSuggestionKind = "section" | "template"; + +export interface McppTomlSuggestion { + label: string; + kind: McppTomlSuggestionKind; + detail: string; + documentation?: string; + /** 插入文本;含 $1 等 snippet 占位符。缺省时插入 label。 */ + insertSnippet?: string; + /** 替换范围(光标所在行的起止列)。 */ + range: ReplaceRange; +} + +export interface SectionHeaderSpec { + group: string; + label: string; + /** snippet 形式的段头(含 ${1:...} 占位)。 */ + header: string; + detail: string; +} + +// 段头结构清单:TOML 结构语法,非字段语义。出处:mcpp 文档 02/03/05/06 +// 与 src/manifest/toml.cppm 的段清单(契约测试用真实 mcpp 逐段验证)。 +export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ + { group: "package", label: "[package]", header: "[package]", detail: "包元数据" }, + { group: "lib", label: "[lib]", header: "[lib]", detail: "库根模块约定" }, + { group: "build", label: "[build]", header: "[build]", detail: "构建配置" }, + { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: "生成文件(路径 → 内容)" }, + { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: "运行时依赖" }, + { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: "开发/测试依赖" }, + { group: "build-dependencies", label: "[build-dependencies]", header: "[build-dependencies]", detail: "构建期依赖(仅构建期拉取,运行时不可见)" }, + { group: "workspace", label: "[workspace]", header: "[workspace]", detail: "工作空间成员声明" }, + { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: "集中声明依赖版本,成员用 workspace = true 继承" }, + { group: "features", label: "[features]", header: "[features]", detail: "feature 定义" }, + { group: "feature-deps", label: "[feature-deps.]", header: "[feature-deps.${1:name}]", detail: "由 feature 拉取的可选依赖" }, + { group: "capabilities", label: "[capabilities]", header: "[capabilities]", detail: "capability 绑定(provider 选择)" }, + { group: "targets", label: "[targets.]", header: "[targets.${1:name}]", detail: "构建目标" }, + { group: "profile", label: "[profile.]", header: "[profile.${1:name}]", detail: "构建档案" }, + { group: "runtime", label: "[runtime]", header: "[runtime]", detail: "主机运行时能力" }, + { group: "resources", label: "[resources]", header: "[resources]", detail: "编译进产物的元数据与资产(仅 PE 目标)" }, + { group: "toolchain", label: "[toolchain]", header: "[toolchain]", detail: "编译器工具链简写" }, + { group: "xlings", label: "[xlings]", header: "[xlings]", detail: "构建环境(xlings 供给)" }, + { group: "xlings.workspace", label: "[xlings.workspace]", header: "[xlings.workspace]", detail: "固定工具版本" }, + { group: "xlings.envs", label: "[xlings.envs]", header: "[xlings.envs]", detail: "工具环境的环境变量" }, + { group: "target", label: "[target.]", header: "[target.${1:x86_64-linux-gnu}]", detail: "按目标三元组的配置" }, + { group: "pack", label: "[pack]", header: "[pack]", detail: "mcpp pack 打包配置" }, + { group: "pack.bundle-project", label: "[pack.bundle-project]", header: "[pack.bundle-project]", detail: "vendored 过滤策略微调" }, + { group: "indices", label: "[indices]", header: "[indices]", detail: "项目级索引重定向" }, + { group: "tools.overrides", label: "[tools.overrides]", header: "[tools.overrides]", detail: "host 工具二进制覆盖" }, + { group: "language", label: "[language]", header: "[language]", detail: "旧版兼容字段;新项目请用 [package].standard" }, +]; + +/** 依赖类段(键位置给依赖写法模板)。 */ +const DEPENDENCY_GROUPS: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", + "workspace.dependencies", + "feature-deps", +]); + +interface TemplateSpec { + label: string; + detail: string; + documentation?: string; + insertSnippet: string; +} + +const DEPENDENCY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'name = "version"', + detail: "SemVer 版本依赖", + documentation: "默认 caret 约束(^);也支持 ~、= 与 \">=1.0, <2.0\" 范围组合。", + insertSnippet: '${1:name} = "${2:1.0.0}"', + }, + { + label: "name = { path = ... }", + detail: "路径依赖(本地开发)", + insertSnippet: '${1:name} = { path = "${2:../mylib}" }', + }, + { + label: "name = { git = ..., tag = ... }", + detail: "Git 依赖(tag / branch / rev 三选一)", + insertSnippet: '${1:name} = { git = "${2:https://github.com/user/repo.git}", tag = "${3:v1.0.0}" }', + }, + { + label: "name = { version = ..., features = [...] }", + detail: "长式 dep spec:请求该依赖的 feature", + insertSnippet: '${1:name} = { version = "${2:1.0}", features = ["${3:feature}"] }', + }, + { + label: "name = { version = ..., tools = [...] }", + detail: "依赖产出的 host 工具(须为该包的 bin target)", + insertSnippet: '${1:name} = { version = "${2:1.0}", tools = ["${3:protoc}"] }', + }, +]; + +const FEATURE_TEMPLATES: readonly TemplateSpec[] = [ + { label: "name = [...]", detail: "数组简写:仅隐含 feature", insertSnippet: "${1:name} = [${2}]" }, + { label: "name = { defines = [...] }", detail: "表形式:激活时贡献包自有宏", insertSnippet: '${1:name} = { defines = ["${2:MACRO}"] }' }, + { label: "name = { requires = [...] }", detail: "表形式:需要 capability", insertSnippet: '${1:name} = { requires = ["${2:blas}"] }' }, + { label: "name = { sources = [...] }", detail: "表形式:feature 门控的源 glob", insertSnippet: '${1:name} = { sources = ["${2:src/simd/**}"] }' }, +]; + +const GENERATED_FILE_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"path" = "content"', + detail: "生成文件(相对路径 → 内容,进指纹)", + insertSnippet: '"${1:src/gen/wrap.cppm}" = """\n${2:}\n"""', + }, +]; + +const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'capability = "provider"', + detail: "capability 绑定(等价于 --cap)", + insertSnippet: '${1:blas} = "${2:compat.openblas}"', + }, +]; + +const XLINGS_WORKSPACE_TEMPLATES: readonly TemplateSpec[] = [ + { label: 'tool = "version"', detail: "固定工具版本", insertSnippet: '${1:clang} = "${2:20.1.7}"' }, +]; + +const XLINGS_ENVS_TEMPLATES: readonly TemplateSpec[] = [ + { label: 'NAME = "value"', detail: "应用到工具环境的环境变量", insertSnippet: '${1:NAME} = "${2:value}"' }, +]; + +const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"pkg:tool" = "path"', + detail: "用已有二进制覆盖 host 工具(跳过构建)", + insertSnippet: '"${1:compat.protobuf:protoc}" = "${2:/usr/bin/protoc}"', + }, +]; + +const TEMPLATES_BY_GROUP: Record = { + "features": FEATURE_TEMPLATES, + "generated_files": GENERATED_FILE_TEMPLATES, + "capabilities": CAPABILITY_TEMPLATES, + "xlings.workspace": XLINGS_WORKSPACE_TEMPLATES, + "xlings.envs": XLINGS_ENVS_TEMPLATES, + "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, +}; + +function sectionHeaderSuggestions(range: ReplaceRange): McppTomlSuggestion[] { + return SECTION_HEADERS.map((section) => ({ + label: section.label, + kind: "section", + detail: section.detail, + insertSnippet: section.header, + range, + })); +} + +function templateSuggestions(templates: readonly TemplateSpec[], range: ReplaceRange): McppTomlSuggestion[] { + return templates.map((template) => ({ + label: template.label, + kind: "template", + detail: template.detail, + documentation: template.documentation, + insertSnippet: template.insertSnippet, + range, + })); +} + +/** + * 计算 mcpp.toml 在指定位置的补全建议(结构补全:段头 + 写法模板)。 + */ +export function computeMcppTomlCompletions( + lines: readonly string[], + line: number, + character: number, +): McppTomlSuggestion[] { + const context = contextAt(lines, line, character); + + if (context.kind === "section-header") { + // mcpp manifest 不使用 TOML 数组表([[...]]);[[ 内不提供建议, + // 避免把用户意图的数组表悄悄替换成普通段 [x](未知段会被 mcpp 静默忽略)。 + if (context.isArray) { + return []; + } + // parser 的替换范围从段名 token 开始;段头建议插入的是完整 "[xxx]", + // 需要把范围扩展到本行的 "[",避免留下 "[["。仅当 "[" 是行内首个 + // 非空白字符时才扩展(section-header 上下文正常都满足,防御奇怪输入)。 + const lineText = (lines[line] ?? "").replace(/\r$/, ""); + const bracket = lineText.indexOf("["); + const firstNonWs = lineText.search(/\S/); + const range = bracket >= 0 && bracket === firstNonWs + ? { startCharacter: bracket, endCharacter: context.replaceRange.endCharacter } + : context.replaceRange; + return sectionHeaderSuggestions(range); + } + + if (context.kind === "key") { + const { section, containerPath, replaceRange } = context; + // 文档顶部(尚无段头):提示段头。未知段:不提供建议 + // (附录 A:不支持包自定义 toml 键)。 + if (section.kind === "top") { + return sectionHeaderSuggestions(replaceRange); + } + if (section.kind !== "known" || containerPath.length > 0) { + return []; + } + if (DEPENDENCY_GROUPS.has(section.group)) { + return templateSuggestions(DEPENDENCY_TEMPLATES, replaceRange); + } + const templates = TEMPLATES_BY_GROUP[section.group]; + return templates === undefined ? [] : templateSuggestions(templates, replaceRange); + } + + // 值位置:自由格式值不瞎猜(版本候选等动态数据层落地后再说)。 + return []; +} diff --git a/src/mcppTomlParser.ts b/src/mcppTomlParser.ts new file mode 100644 index 0000000..1276eca --- /dev/null +++ b/src/mcppTomlParser.ts @@ -0,0 +1,890 @@ +// mcpp.toml 的容错解析层:为代码补全提供结构化信息。 +// 设计目标是容错而非校验——用户在编辑器里输入到一半(未闭合的 `[`、字符串、 +// 内联表)时不抛异常,而是把已识别的结构连同精确的 0 基行列范围返回。 +// 本模块不依赖 vscode API,可在 node --test 下直接测试;extension.ts 负责 +// 把这里的纯数据上下文映射为 CompletionItem。 +// +// 覆盖的 TOML 子集:段头(含 [[...]] 数组表、单/双引号段)、裸键/引号键/ +// 点分键、字符串(含三引号多行串)、整数、布尔、数组与内联表(可嵌套、 +// 可跨行)、行注释。CRLF 行尾在进入解析前剥除,列号按剥除后的文本计算。 + +/** 0 基行列范围,end 为开区间(与 VS Code Range 同构)。 */ +export interface TomlRange { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; +} + +/** 键/段路径中的一段:已去引号的名字 + 原始 token 的范围(含引号)。 */ +export interface TomlKeySegment { + name: string; + quoted: boolean; + range: TomlRange; +} + +export type TomlValueKind = "string" | "integer" | "boolean" | "array" | "inlineTable" | "unknown"; + +/** 值节点。open = true 表示结构未闭合(输入到一半),range 延伸到文档末尾。 */ +export interface TomlValueNode { + kind: TomlValueKind; + range: TomlRange; + /** 未闭合(字符串缺右引号、数组缺 ]、内联表缺 })。 */ + open: boolean; + /** 标量原文:字符串为去引号内容(未做转义还原),整数/布尔为原文 token。 */ + text?: string; + /** 字符串节点:引号字符。 */ + quote?: '"' | "'"; + /** 字符串节点:是否三引号多行串。 */ + multiline?: boolean; + /** 字符串节点:内容范围(不含引号;未闭合时延伸到扫描终点)。 */ + contentRange?: TomlRange; + /** 数组节点:已解析出的元素。 */ + elements?: TomlValueNode[]; + /** 内联表节点:已解析出的键值条目。 */ + entries?: TomlKeyValueNode[]; +} + +/** 键值对节点:keyPath 为点分键拆出的段数组(如 capi.lua → [capi, lua])。 */ +export interface TomlKeyValueNode { + type: "keyValue"; + keyPath: TomlKeySegment[]; + range: TomlRange; + /** 缺省表示 `=` 之后没有可用值(含尚未输入的情况)。 */ + value?: TomlValueNode; +} + +/** 段头节点。open = true 表示 `]` 尚未输入。 */ +export interface TomlSectionNode { + type: "section"; + /** 段路径段(已去引号,如 [target.'cfg(windows)'.build] → target / cfg(windows) / build)。 */ + segments: TomlKeySegment[]; + /** 是否为 [[...]] 数组表段头。 */ + isArray: boolean; + open: boolean; + range: TomlRange; + /** 段头所在行(0 基)。 */ + line: number; +} + +export type TomlNode = TomlSectionNode | TomlKeyValueNode; + +export interface TomlDocument { + nodes: TomlNode[]; +} + +/** 段归属语义:top = 文档顶部尚无段头;unknown = 未识别的自定义段;known = 已知段组。 */ +export type SectionResolution = + | { kind: "top" } + | { kind: "unknown"; segments: string[] } + | { kind: "known"; group: string }; + +/** 当前 token 的替换范围(与光标同行的起止列),供补全做显式 range。 */ +export interface ReplaceRange { + startCharacter: number; + endCharacter: number; +} + +/** 光标的结构化上下文(纯数据)。 */ +export type TomlCursorContext = + | { + kind: "section-header"; + /** 光标所在 token 之前已解析出的段路径段(去引号)。 */ + segments: string[]; + isArray: boolean; + replaceRange: ReplaceRange; + } + | { + kind: "key"; + section: SectionResolution; + /** 容器路径:顶层语句为 [];内联表内为从语句根键开始的路径(如 simd = { flags = [ { … 深处为 ["simd","flags"])。 */ + containerPath: string[]; + /** 同一行点分键中光标 token 之前已输入的段(如 capi.la| → ["capi"])。 */ + keyPrefix: string[]; + replaceRange: ReplaceRange; + } + | { + kind: "value"; + section: SectionResolution; + /** 所属键的完整路径(含内联表/数组下钻)。 */ + keyPath: string[]; + /** 光标处值的种类;值尚未开始或不可判定时为 undefined。 */ + valueKind: TomlValueKind | undefined; + insideString: boolean; + /** 所在字符串未闭合(不在字符串内时为 false)。 */ + stringOpen: boolean; + replaceRange: ReplaceRange; + }; + +/** 精确匹配的已知段(与 mcpp manifest 层一致)。 */ +const KNOWN_SECTIONS: ReadonlySet = new Set([ + "package", + "lib", + "build", + "generated_files", + "dependencies", + "dev-dependencies", + "build-dependencies", + "features", + "indices", + "capabilities", + "runtime", + "resources", + "toolchain", + "xlings", + "xlings.workspace", + "xlings.envs", + "workspace", + "workspace.dependencies", + "pack", + "pack.bundle-project", + "language", + "tools.overrides", +]); + +/** 参数化段基组:. 归入 (dependencies 系支持命名空间再嵌套)。 */ +const PARAMETERIZED_BASES: ReadonlySet = new Set([ + "targets", + "profile", + "feature-deps", + "dependencies", + "dev-dependencies", +]); + +function resolveGroup(segments: readonly string[]): string | undefined { + const joined = segments.join("."); + if (KNOWN_SECTIONS.has(joined)) { + return joined; + } + const head = segments[0]; + if (head === "target") { + // [target.] 或 [target..<子表>];sel 是三元组或去引号后的 + // cfg 表达式,本身不含未引号点,因此子表部分从第三段开始。 + if (segments.length <= 2) { + return "target"; + } + return resolveGroup(segments.slice(2)); + } + if (head === "runtime" && segments.length === 2) { + // [runtime.""]:带点 capability 名的显式 provider 子表。 + return "runtime.capability"; + } + if (PARAMETERIZED_BASES.has(head) && segments.length >= 2) { + return head; + } + if (head === "workspace" && segments[1] === "dependencies" && segments.length >= 3) { + return "workspace.dependencies"; + } + return undefined; +} + +/** + * 把解析出的段路径规范化为语义组。空 segments 表示文档顶部(尚无段头), + * 未识别的段返回 unknown,由调用方区分这两种情况。 + */ +export function resolveSection(segments: readonly string[]): SectionResolution { + if (segments.length === 0) { + return { kind: "top" }; + } + const group = resolveGroup(segments); + return group === undefined + ? { kind: "unknown", segments: [...segments] } + : { kind: "known", group }; +} + +interface Pos { + line: number; + col: number; +} + +/** 内部控制流信号:光标上下文已捕获,提前结束扫描。解析本身永不抛异常。 */ +const CONTEXT_FOUND = Symbol("mcppTomlContextFound"); + +function isBareKeyChar(ch: string): boolean { + return ( + (ch >= "a" && ch <= "z") || + (ch >= "A" && ch <= "Z") || + (ch >= "0" && ch <= "9") || + ch === "_" || + ch === "-" + ); +} + +/** 逐行容错扫描器。行列均为 0 基,列按剥除 \r 后的文本计算。 */ +class Scanner { + private readonly lines: string[]; + private line = 0; + private col = 0; + private readonly cursor: Pos | undefined; + /** 光标上下文捕获结果。 */ + context: TomlCursorContext | undefined; + /** 扫描过程中最近经过的段头,用于给键/值上下文标注段归属。 */ + private currentSection: TomlSectionNode | undefined; + + constructor(lines: readonly string[], cursor?: Pos) { + this.lines = lines + .map((text) => (text.endsWith("\r") ? text.slice(0, -1) : text)); + if (this.lines.length === 0) { + this.lines.push(""); + } + if (cursor !== undefined) { + // 越界坐标钳制到文档内,保证补全在任意光标位置都能得到上下文。 + const line = Math.min(Math.max(0, cursor.line), this.lines.length - 1); + const col = Math.min(Math.max(0, cursor.col), this.lines[line].length); + this.cursor = { line, col }; + } + } + + // ---- 基础游标操作 ---- + + private pos(): Pos { + return { line: this.line, col: this.col }; + } + + private eof(): boolean { + return this.line >= this.lines.length; + } + + private atEol(): boolean { + return this.eof() || this.col >= this.lines[this.line].length; + } + + private peek(): string { + return this.atEol() ? "" : this.lines[this.line][this.col]; + } + + private peekAt(offset: number): string { + if (this.eof()) { + return ""; + } + const text = this.lines[this.line]; + return this.col + offset < text.length ? text[this.col + offset] : ""; + } + + private advance(): void { + if (this.eof()) { + return; + } + this.col += 1; + if (this.col > this.lines[this.line].length) { + this.line += 1; + this.col = 0; + } + } + + private samePos(a: Pos, b: Pos): boolean { + return a.line === b.line && a.col === b.col; + } + + private cmpPos(a: Pos, b: Pos): number { + return a.line - b.line || a.col - b.col; + } + + private skipInlineWs(): void { + while (this.peek() === " " || this.peek() === "\t") { + this.col += 1; + } + } + + /** 跳过空白、换行与行注释(# 到行尾;字符串内的 # 不会走到这里)。 */ + private skipTrivia(): void { + while (!this.eof()) { + const ch = this.peek(); + if (ch === " " || ch === "\t") { + this.col += 1; + continue; + } + if (this.atEol()) { + this.line += 1; + this.col = 0; + continue; + } + if (ch === "#") { + while (!this.atEol()) { + this.col += 1; + } + continue; + } + break; + } + } + + private rangeFrom(start: Pos): TomlRange { + return { + startLine: start.line, + startCharacter: start.col, + endLine: this.line, + endCharacter: this.col, + }; + } + + private sliceText(a: Pos, b: Pos): string { + if (a.line === b.line) { + return this.lines[a.line].slice(a.col, b.col); + } + const parts = [this.lines[a.line].slice(a.col)]; + for (let l = a.line + 1; l < b.line; l += 1) { + parts.push(this.lines[l]); + } + parts.push(this.lines[b.line].slice(0, b.col)); + return parts.join("\n"); + } + + // ---- 光标上下文捕获 ---- + + private cursorReached(): boolean { + const cursor = this.cursor; + return cursor !== undefined && this.cmpPos(this.pos(), cursor) >= 0; + } + + /** 光标是否落在范围内(端点含尾:补全时光标常贴在 token 末尾)。 */ + private cursorWithin(range: TomlRange): boolean { + const cursor = this.cursor; + if (cursor === undefined) { + return false; + } + if (cursor.line < range.startLine || cursor.line > range.endLine) { + return false; + } + if (cursor.line === range.startLine && cursor.col < range.startCharacter) { + return false; + } + if (cursor.line === range.endLine && cursor.col > range.endCharacter) { + return false; + } + return true; + } + + /** 在光标行上向左/右扩展 token(遇 stop 字符停),用于字符串内容等无预扫描范围的替换区间。 */ + private expandOnLine(stop: (ch: string) => boolean): ReplaceRange { + const cursor = this.cursor; + if (cursor === undefined) { + return { startCharacter: 0, endCharacter: 0 }; + } + const text = this.lines[cursor.line] ?? ""; + const at = Math.min(cursor.col, text.length); + let start = at; + let end = at; + while (start > 0 && !stop(text[start - 1])) { + start -= 1; + } + while (end < text.length && !stop(text[end])) { + end += 1; + } + return { startCharacter: start, endCharacter: end }; + } + + private emptyReplaceRange(): ReplaceRange { + const cursor = this.cursor; + const col = cursor?.col ?? 0; + return { startCharacter: col, endCharacter: col }; + } + + private static tokenReplaceRange(range: TomlRange): ReplaceRange { + // 键/段 token 保证单行(引号键跨行即视为未闭合而截断在行尾)。 + return { startCharacter: range.startCharacter, endCharacter: range.endCharacter }; + } + + /** 单行范围转替换范围;跨行值(数组/多行串)返回 undefined,由调用方回退。 */ + private static singleLineReplaceRange(range: TomlRange): ReplaceRange | undefined { + if (range.startLine !== range.endLine) { + return undefined; + } + return { startCharacter: range.startCharacter, endCharacter: range.endCharacter }; + } + + private sectionResolution(): SectionResolution { + if (this.currentSection === undefined) { + return { kind: "top" }; + } + return resolveSection(this.currentSection.segments.map((segment) => segment.name)); + } + + private capture(context: TomlCursorContext): never { + this.context = context; + throw CONTEXT_FOUND; + } + + private captureKey(containerPath: readonly string[], keyPrefix: readonly string[], token?: TomlRange): never { + this.capture({ + kind: "key", + section: this.sectionResolution(), + containerPath: [...containerPath], + keyPrefix: [...keyPrefix], + replaceRange: token !== undefined ? Scanner.tokenReplaceRange(token) : this.emptyReplaceRange(), + }); + } + + private captureValue( + keyPath: readonly string[], + valueKind: TomlValueKind | undefined, + options?: { insideString?: boolean; stringOpen?: boolean; replaceRange?: ReplaceRange }, + ): never { + this.capture({ + kind: "value", + section: this.sectionResolution(), + keyPath: [...keyPath], + valueKind, + insideString: options?.insideString ?? false, + stringOpen: options?.stringOpen ?? false, + replaceRange: options?.replaceRange ?? this.emptyReplaceRange(), + }); + } + + private captureHeader(segments: readonly TomlKeySegment[], isArray: boolean, token?: TomlRange): never { + this.capture({ + kind: "section-header", + segments: segments.map((segment) => segment.name), + isArray, + replaceRange: token !== undefined ? Scanner.tokenReplaceRange(token) : this.emptyReplaceRange(), + }); + } + + /** 扫描到文档末尾仍未捕获光标上下文时的兜底:当前段内的顶层键位置。 */ + finalContext(): TomlCursorContext { + return { + kind: "key", + section: this.sectionResolution(), + containerPath: [], + keyPrefix: [], + replaceRange: this.emptyReplaceRange(), + }; + } + + // ---- 结构扫描 ---- + + scan(): TomlNode[] { + const nodes: TomlNode[] = []; + while (!this.eof()) { + this.skipTrivia(); + // 光标落在语句之间的空白/注释里:视为顶层键位置(新语句的起点)。 + if (this.cursorReached()) { + this.captureKey([], []); + } + if (this.eof()) { + break; + } + const start = this.pos(); + if (this.peek() === "[") { + nodes.push(this.scanHeader(start)); + } else { + const keyValue = this.scanKeyValue([]); + if (keyValue !== undefined) { + nodes.push(keyValue); + } + } + // 进度保护:任何无法识别的垃圾字符都必须被消费,保证容错扫描必然终止。 + if (this.samePos(this.pos(), start)) { + this.advance(); + } + } + return nodes; + } + + /** 段头:[a.b] / [[a.b]] / 引号段;未闭合(缺 ])时 open = true 并跳过该行剩余内容。 */ + private scanHeader(start: Pos): TomlSectionNode { + this.advance(); // [ + let isArray = false; + if (this.peek() === "[") { + isArray = true; + this.advance(); + } + const segments: TomlKeySegment[] = []; + let open = true; + for (;;) { + this.skipInlineWs(); + // 光标在段之间的空白/点号附近(如 `[targets.` 之后)。 + if (this.cursorReached()) { + this.captureHeader(segments, isArray); + } + const segment = this.scanKeySegment(); + if (segment === undefined) { + break; + } + if (this.cursorWithin(segment.range)) { + this.captureHeader(segments, isArray, segment.range); + } + segments.push(segment); + this.skipInlineWs(); + if (this.peek() === ".") { + this.advance(); + continue; + } + break; + } + this.skipInlineWs(); + if (this.cursorReached()) { + this.captureHeader(segments, isArray); + } + if (this.peek() === "]") { + this.advance(); + if (isArray && this.peek() === "]") { + this.advance(); + } + // 容错:[[x] 只写了一个 ] 也视为已闭合。 + open = false; + } + if (open) { + // 未闭合段头:跳过该行剩余内容,避免残余字符被当作下一条语句。 + while (!this.atEol()) { + this.col += 1; + } + } + const node: TomlSectionNode = { + type: "section", + segments, + isArray, + open, + range: this.rangeFrom(start), + line: start.line, + }; + this.currentSection = node; + return node; + } + + /** 单个键/段路径段:裸键或单/双引号键(引号键跨行即按未闭合截断)。 */ + private scanKeySegment(): TomlKeySegment | undefined { + const start = this.pos(); + const quote = this.peek(); + if (quote === '"' || quote === "'") { + this.advance(); + const contentStart = this.pos(); + while (!this.atEol() && this.peek() !== quote) { + // 双引号键内的转义(如 \")不结束键。 + if (quote === '"' && this.peek() === "\\" && this.col + 1 < this.lines[this.line].length) { + this.col += 2; + continue; + } + this.col += 1; + } + const contentEnd = this.pos(); + if (this.peek() === quote) { + this.advance(); + } + return { + name: this.sliceText(contentStart, contentEnd), + quoted: true, + range: this.rangeFrom(start), + }; + } + if (!isBareKeyChar(quote)) { + return undefined; + } + while (isBareKeyChar(this.peek())) { + this.col += 1; + } + return { + name: this.sliceText(start, this.pos()), + quoted: false, + range: this.rangeFrom(start), + }; + } + + /** + * 键值对:点分键 [= 值]。containerPath 是内联表递归时下钻的键路径 + * (顶层语句为 [])。无法识别出键也没有值时返回 undefined。 + */ + private scanKeyValue(containerPath: readonly string[]): TomlKeyValueNode | undefined { + const start = this.pos(); + const segments: TomlKeySegment[] = []; + for (;;) { + this.skipInlineWs(); + // 光标在键槽位的空白处(如 `{ ` 之后、键未开始时)。 + if (this.cursorReached()) { + this.captureKey(containerPath, segments.map((segment) => segment.name)); + } + const segment = this.scanKeySegment(); + if (segment === undefined) { + break; + } + if (this.cursorWithin(segment.range)) { + this.captureKey(containerPath, segments.map((segment) => segment.name), segment.range); + } + segments.push(segment); + this.skipInlineWs(); + if (this.peek() === ".") { + this.advance(); + continue; + } + break; + } + this.skipInlineWs(); + // 光标在键与 = 之间(如 `name |`):仍算键位置。 + if (this.cursorReached()) { + this.captureKey(containerPath, segments.map((segment) => segment.name)); + } + const keyNames = segments.map((segment) => segment.name); + let value: TomlValueNode | undefined; + if (this.peek() === "=") { + this.advance(); + this.skipInlineWs(); + const valuePath = containerPath.concat(keyNames); + // 光标在 = 之后、值未开始(如 `kind = |`)。 + if (this.cursorReached()) { + // 光标后方同行还有值 token(光标恰在 token 首字符、或在 = 与 token + // 之间的空白上)时,替换范围要覆盖整个 token,否则补全插入后原文残留。 + if (!this.atEol() && this.peek() !== "#") { + const ahead = this.scanValue(valuePath); // 光标落在 token 内时由 scanValue 捕获 + this.captureValue(valuePath, ahead.kind, { + replaceRange: Scanner.singleLineReplaceRange(ahead.range) ?? this.emptyReplaceRange(), + }); + } + this.captureValue(valuePath, undefined); + } + // 值必须在同行开始(TOML 本就如此);行尾没有值则按缺失处理。 + if (!this.atEol() && this.peek() !== "#") { + value = this.scanValue(valuePath); + } + } + if (segments.length === 0 && value === undefined) { + return undefined; + } + return { type: "keyValue", keyPath: segments, range: this.rangeFrom(start), value }; + } + + private scanValue(keyPath: readonly string[]): TomlValueNode { + const ch = this.peek(); + if (ch === '"' || ch === "'") { + return this.scanStringValue(keyPath); + } + if (ch === "[") { + return this.scanArrayValue(keyPath); + } + if (ch === "{") { + return this.scanInlineTableValue(keyPath); + } + return this.scanBareValue(keyPath); + } + + /** 字符串:单/双引号单行串与三引号多行串;未闭合时 open = true。 */ + private scanStringValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + const quote = this.peek() as '"' | "'"; + this.advance(); + let multiline = false; + let open = true; + let contentStart = this.pos(); + let contentEnd = this.pos(); + if (this.peek() === quote) { + this.advance(); + if (this.peek() === quote) { + // 三引号多行串。 + multiline = true; + this.advance(); + contentStart = this.pos(); + } else { + // 空字符串 "" / ''。 + open = false; + } + } + let closeStart: Pos | undefined; + if (open) { + while (!this.eof()) { + if (!multiline && this.atEol()) { + break; // 单行串跨行 → 未闭合 + } + const ch = this.peek(); + if (ch === "\\" && quote === '"') { + // 双引号串内的转义:跳过下一个字符(\" 不结束串)。 + this.advance(); + if (!this.eof() && (multiline || !this.atEol())) { + this.advance(); + } + continue; + } + if (ch === quote) { + if (multiline) { + if (this.peekAt(1) === quote && this.peekAt(2) === quote) { + contentEnd = this.pos(); + closeStart = this.pos(); + this.advance(); + this.advance(); + this.advance(); + open = false; + break; + } + this.advance(); + continue; + } + contentEnd = this.pos(); + closeStart = this.pos(); + this.advance(); + open = false; + break; + } + this.advance(); + } + if (open) { + contentEnd = this.pos(); + } + } + const node: TomlValueNode = { + kind: "string", + range: this.rangeFrom(start), + open, + text: this.sliceText(contentStart, contentEnd), + quote, + multiline, + contentRange: { + startLine: contentStart.line, + startCharacter: contentStart.col, + endLine: contentEnd.line, + endCharacter: contentEnd.col, + }, + }; + if (this.cursorWithin(node.range)) { + const cursor = this.cursor; + if (cursor !== undefined) { + const afterOpen = this.cmpPos(cursor, contentStart) >= 0; + const beforeClose = closeStart === undefined || this.cmpPos(cursor, closeStart) <= 0; + const insideString = afterOpen && beforeClose; + // 字符串内容的替换范围:扩展到本行引号边界,并钳制在内容范围内。 + let replaceRange = this.emptyReplaceRange(); + if (insideString) { + const expanded = this.expandOnLine((ch) => ch === quote); + const minCol = contentStart.line === cursor.line ? contentStart.col : 0; + const maxCol = + contentEnd.line === cursor.line ? contentEnd.col : this.lines[cursor.line]?.length ?? 0; + replaceRange = { + startCharacter: Math.max(expanded.startCharacter, minCol), + endCharacter: Math.min(Math.max(expanded.endCharacter, minCol), maxCol), + }; + } + this.captureValue(keyPath, "string", { + insideString, + stringOpen: open && afterOpen, + replaceRange, + }); + } + } + return node; + } + + /** 数组:可跨行,元素递归解析;缺 ] 时 open = true。 */ + private scanArrayValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + this.advance(); // [ + const elements: TomlValueNode[] = []; + let open = true; + for (;;) { + this.skipTrivia(); + // 光标在元素槽位(如 `sources = [ ` 之后)。 + if (this.cursorReached()) { + this.captureValue(keyPath, undefined); + } + if (this.eof()) { + break; + } + const ch = this.peek(); + if (ch === "]") { + this.advance(); + open = false; + break; + } + if (ch === ",") { + this.advance(); + continue; + } + const elementStart = this.pos(); + elements.push(this.scanValue(keyPath)); + if (this.samePos(this.pos(), elementStart)) { + this.advance(); // 进度保护 + } + } + return { kind: "array", range: this.rangeFrom(start), open, elements }; + } + + /** 内联表:可嵌套、可跨行,条目是完整键值对;缺 } 时 open = true。 */ + private scanInlineTableValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + this.advance(); // { + const entries: TomlKeyValueNode[] = []; + let open = true; + for (;;) { + this.skipTrivia(); + // 光标在条目键槽位(如 `simd = { flags = [ { ` 深处)。 + if (this.cursorReached()) { + this.captureKey(keyPath, []); + } + if (this.eof()) { + break; + } + const ch = this.peek(); + if (ch === "}") { + this.advance(); + open = false; + break; + } + if (ch === ",") { + this.advance(); + continue; + } + const entryStart = this.pos(); + const entry = this.scanKeyValue(keyPath); + if (entry !== undefined) { + entries.push(entry); + } + if (this.samePos(this.pos(), entryStart)) { + this.advance(); // 进度保护 + } + } + return { kind: "inlineTable", range: this.rangeFrom(start), open, entries }; + } + + /** 裸值 token:布尔、整数,其余归为 unknown(容错,不校验合法性)。 */ + private scanBareValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + let end = this.pos(); + while (!this.atEol()) { + const ch = this.peek(); + if (ch === "," || ch === "]" || ch === "}" || ch === "[" || ch === "{" || ch === "#") { + break; + } + this.col += 1; + if (ch !== " " && ch !== "\t") { + end = this.pos(); + } + } + const text = this.sliceText(start, end); + let kind: TomlValueKind = "unknown"; + if (text === "true" || text === "false") { + kind = "boolean"; + } else if (/^[+-]?\d[\d_]*$/.test(text)) { + kind = "integer"; + } + const range: TomlRange = { + startLine: start.line, + startCharacter: start.col, + endLine: end.line, + endCharacter: end.col, + }; + if (this.cursorWithin(range)) { + this.captureValue(keyPath, kind, { replaceRange: Scanner.tokenReplaceRange(range) }); + } + return { kind, range, open: false, text }; + } +} + +/** 容错解析整个文档,产出带 0 基行列范围的节点树。永不抛异常。 */ +export function parseMcppToml(lines: readonly string[]): TomlDocument { + const scanner = new Scanner(lines); + return { nodes: scanner.scan() }; +} + +/** + * 计算光标的结构化上下文。line / character 均为 0 基(character 按剥除 + * \r 后的文本计算);越界坐标会被钳制到文档内。永不抛异常。 + */ +export function contextAt(lines: readonly string[], line: number, character: number): TomlCursorContext { + const scanner = new Scanner(lines, { line, col: character }); + try { + scanner.scan(); + } catch (error) { + if (error === CONTEXT_FOUND && scanner.context !== undefined) { + return scanner.context; + } + throw error; + } + // 扫描结束仍未命中:光标在文档末尾的空白处,视为当前段内的顶层键位置。 + return scanner.finalContext(); +} diff --git a/src/moduleSetup.ts b/src/moduleSetup.ts index 14d962a..6c6b8a0 100644 --- a/src/moduleSetup.ts +++ b/src/moduleSetup.ts @@ -75,7 +75,10 @@ export function mcppModuleSetupCommands(plan: ModuleSetupPlan): ModuleSetupComma args: ["toolchain", "default", "llvm"], }); } - commands.push({ stage: "build", mode: "task", args: ["build", "--no-cache"] }); + const buildArgs = plan.installLlvm || plan.switchDefault + ? ["build", "--no-cache"] + : ["build"]; + commands.push({ stage: "build", mode: "task", args: buildArgs }); return commands; } 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..371b501 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -16,6 +16,26 @@ export interface CompilationDatabaseWatcher { onDidDelete(listener: (event: E) => void): T; } +export async function withTimeout( + operation: PromiseLike, + timeoutMs: number, + fallback: T, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve(operation), + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + export function registerCompilationDatabaseReconciliation( watcher: CompilationDatabaseWatcher, requestReconciliation: (event: E, forceRestart: boolean) => void, @@ -182,6 +202,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 +256,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 4ab77e4..bf5baba 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -26,14 +26,14 @@ 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.5"); + assert.equal(manifest.version, "0.3.1"); 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")); assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); assert.equal( manifest.capabilities?.untrustedWorkspaces?.description, - "未受信任工作区仅启用模块语法高亮,不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。", + "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。", ); assert.deepEqual( manifest.contributes?.commands?.map((command) => command.command), @@ -113,6 +113,47 @@ 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("IDE commands expose progress and bound clangd restart waits", () => { + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + assert.match(source, /const CLANGD_RESTART_TIMEOUT_MS = 15_000/); + assert.match(source, /withTimeout\([\s\S]*CLANGD_RESTART_TIMEOUT_MS/); + for (const title of ["配置 clangd", "刷新编译数据库", "检查模块支持", "一键配置模块代码提示"]) { + assert.match(source, new RegExp(`showInteractiveIdeStart\\("${title}"\\)`)); + } + assert.match(source, /output\.show\(true\)/); +}); + 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 @@ -301,9 +342,9 @@ test("新建工程契约是创建并打开,不自动构建", () => { test("声明 GitHub 仓库和扩展图标", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; assert.equal(manifest.icon, "images/logo.png"); - assert.equal(manifest.repository?.url, "https://github.com/wellwei/mcpp-vscode.git"); - assert.equal(manifest.homepage, "https://github.com/wellwei/mcpp-vscode#readme"); - assert.equal(manifest.bugs?.url, "https://github.com/wellwei/mcpp-vscode/issues"); + assert.equal(manifest.repository?.url, "https://github.com/mcpp-community/mcpp-vscode.git"); + assert.equal(manifest.homepage, "https://github.com/mcpp-community/mcpp-vscode#readme"); + assert.equal(manifest.bugs?.url, "https://github.com/mcpp-community/mcpp-vscode/issues"); const icon = readFileSync(path.join(root, manifest.icon)); assert.deepEqual([...icon.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); diff --git a/test/configureOnly.test.ts b/test/configureOnly.test.ts new file mode 100644 index 0000000..890abb9 --- /dev/null +++ b/test/configureOnly.test.ts @@ -0,0 +1,58 @@ +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; + timeoutMs?: number; + }> = []; + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async (executable, args, cwd, options) => { + calls.push({ executable, args, cwd, timeoutMs: options?.timeoutMs }); + return { exitCode: 0, stdout: "Configured 2 compile commands", stderr: "" }; + }, + ); + + assert.deepEqual(calls, [{ + executable: "/tools/mcpp", + args: [...configureOnlyArguments], + cwd: "/work/app", + timeoutMs: 5 * 60_000, + }]); + 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..3f211cb 100644 --- a/test/e2e/suite/extension.test.ts +++ b/test/e2e/suite/extension.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; 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,56 @@ 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"); + + await 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"); + + const compilationDatabase = path.join(workspaceFolder.uri.fsPath, "compile_commands.json"); + const executeWithinDeadline = async (command: string): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + vscode.commands.executeCommand(command), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${command} did not finish`)), 5_000); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } + }; + + rmSync(compilationDatabase); + await executeWithinDeadline("mcpp.refreshCompilationDatabase"); + assert.ok(existsSync(compilationDatabase), "refresh should recreate a missing CDB"); + + rmSync(compilationDatabase); + await executeWithinDeadline("mcpp.configureClangd"); + await executeWithinDeadline("mcpp.checkModuleSupport"); + await executeWithinDeadline("mcpp.autoConfigureModules"); }); }); 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/llvmTools.test.ts b/test/llvmTools.test.ts index b6348f5..8e967d7 100644 --- a/test/llvmTools.test.ts +++ b/test/llvmTools.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import test from "node:test"; import { @@ -7,8 +10,12 @@ import { xlingsInstallArgs, deriveInstalledClangdPath, findXlingsExecutable, + resolveXlingsExecutable, } from "../src/llvmTools"; +// `xlings` on POSIX, `xlings.exe` on Windows — mirrors mcpp's exe_suffix. +const xlingsBinaryName = process.platform === "win32" ? "xlings.exe" : "xlings"; + test("extracts version string from ToolIdentity", () => { assert.equal( llvmToolsVersionSpec({ major: 22, minor: 1, patch: 8, revision: "abc1234" }), @@ -68,3 +75,115 @@ test("findXlingsExecutable returns a string or undefined", () => { // Returns string (PATH fallback or known path) or undefined if xlings not found assert.ok(result === undefined || typeof result === "string"); }); + +test("findXlingsExecutable finds the xlings bundled in $MCPP_HOME/registry/bin", () => { + const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-mcpp-home-")); + const registryBin = path.join(home, "registry", "bin"); + const xlingsPath = path.join(registryBin, xlingsBinaryName); + mkdirSync(registryBin, { recursive: true }); + writeFileSync(xlingsPath, "#!/bin/sh\n"); + try { + assert.equal( + findXlingsExecutable({ home, env: { MCPP_HOME: home } }), + xlingsPath, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("findXlingsExecutable falls back to $HOME/.mcpp/registry/bin when MCPP_HOME is unset", () => { + const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-home-")); + const registryBin = path.join(home, ".mcpp", "registry", "bin"); + const xlingsPath = path.join(registryBin, xlingsBinaryName); + mkdirSync(registryBin, { recursive: true }); + writeFileSync(xlingsPath, "#!/bin/sh\n"); + try { + assert.equal( + findXlingsExecutable({ home, env: {} }), + xlingsPath, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("findXlingsExecutable honors MCPP_VENDORED_XLINGS", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-vendored-")); + const vendored = path.join(root, "opt-mcpp", "registry", "bin", xlingsBinaryName); + mkdirSync(path.dirname(vendored), { recursive: true }); + writeFileSync(vendored, "#!/bin/sh\n"); + try { + assert.equal( + findXlingsExecutable({ home: root, env: { MCPP_VENDORED_XLINGS: vendored } }), + vendored, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveXlingsExecutable reads the xlings binary from `mcpp self env`", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-selfenv-")); + const xlingsPath = path.join(root, "registry", "bin", xlingsBinaryName); + mkdirSync(path.dirname(xlingsPath), { recursive: true }); + writeFileSync(xlingsPath, "#!/bin/sh\n"); + const runner = async () => ({ + exitCode: 0, + stdout: `MCPP_HOME = ${root}\nxlings binary = ${xlingsPath}\nxlings pinned = 2026.8.8.1\n`, + stderr: "", + }); + try { + assert.equal( + await resolveXlingsExecutable("/tools/mcpp", runner), + xlingsPath, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("resolveXlingsExecutable passes a timeout to `mcpp self env`", async () => { + let captured: { timeoutMs?: number } | undefined; + const runner = async ( + _executable: string, + _args: string[], + _cwd?: string, + options?: { timeoutMs?: number }, + ) => { + captured = options; + return { exitCode: 0, stdout: "", stderr: "" }; + }; + await resolveXlingsExecutable("/tools/mcpp", runner); + assert.equal(captured?.timeoutMs, 60_000); +}); + +test("resolveXlingsExecutable falls back to path probing when the reported path does not exist", async () => { + const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-fallback-missing-")); + const runner = async () => ({ + exitCode: 0, + stdout: "xlings binary = /no/such/xlings\n", + stderr: "", + }); + try { + assert.equal( + await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }), + undefined, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); + +test("resolveXlingsExecutable falls back to path probing when `mcpp self env` fails", async () => { + const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-fallback-fail-")); + const runner = async () => ({ exitCode: 1, stdout: "", stderr: "boom\n" }); + try { + assert.equal( + await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }), + undefined, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/test/mcppTomlCompletion.test.ts b/test/mcppTomlCompletion.test.ts new file mode 100644 index 0000000..33983d7 --- /dev/null +++ b/test/mcppTomlCompletion.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computeMcppTomlCompletions, + type McppTomlSuggestion, +} from "../src/mcppTomlCompletion"; + +function labels(suggestions: McppTomlSuggestion[]): string[] { + return suggestions.map((suggestion) => suggestion.label); +} + +test("suggests section headers on a partial bracket line", () => { + const suggestions = computeMcppTomlCompletions(["[dep"], 0, 4); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "section")); + assert.ok(labels(suggestions).includes("[dependencies]")); + assert.ok(labels(suggestions).includes("[build-dependencies]")); + assert.ok(labels(suggestions).includes("[workspace]")); + assert.ok(labels(suggestions).includes("[indices]")); + // 每条建议都带显式替换范围(覆盖已输入的 "[dep")。 + for (const suggestion of suggestions) { + assert.deepEqual(suggestion.range, { startCharacter: 0, endCharacter: 4 }); + } + // 参数化段插入 snippet。 + const targets = suggestions.find((suggestion) => suggestion.label === "[targets.]"); + assert.equal(targets?.insertSnippet, "[targets.${1:name}]"); +}); + +test("offers nothing inside [[...]] array-table headers", () => { + // mcpp manifest 不使用 TOML 数组表([[...]]):[[ 内不出建议, + // 避免把用户意图的数组表悄悄替换成普通段 [x]。 + assert.deepEqual(computeMcppTomlCompletions(["[[dep"], 0, 5), []); + assert.deepEqual(computeMcppTomlCompletions(["[[dependencies]"], 0, 3), []); +}); + +test("suggests section headers at the top of the document", () => { + const suggestions = computeMcppTomlCompletions([""], 0, 0); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "section")); +}); + +test("offers nothing in unknown sections", () => { + // 附录 A:不支持包自定义 toml 键;未知段不提供任何建议。 + assert.deepEqual(computeMcppTomlCompletions(["[mytool]", ""], 1, 0), []); + assert.deepEqual(computeMcppTomlCompletions(["[mytool]", "key = "], 1, 6), []); +}); + +test("offers no static field keys (removed, waiting for upstream schema)", () => { + // 静态字段键/枚举刻意不做:等上游版本化 manifest schema。 + assert.deepEqual(computeMcppTomlCompletions(["[package]", ""], 1, 0), []); + assert.deepEqual(computeMcppTomlCompletions(["[package]", "standard = "], 1, 11), []); + assert.deepEqual(computeMcppTomlCompletions(["[targets.app]", "kind = "], 1, 7), []); +}); + +test("suggests dependency writing templates", () => { + const suggestions = computeMcppTomlCompletions(["[dependencies]", ""], 1, 0); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "template")); + const names = labels(suggestions); + assert.ok(names.includes('name = "version"')); + assert.ok(names.includes("name = { git = ..., tag = ... }")); + assert.ok(names.includes("name = { version = ..., tools = [...] }")); + assert.deepEqual(suggestions[0].range, { startCharacter: 0, endCharacter: 0 }); +}); + +test("suggests dependency templates in conditional dependency sections", () => { + const suggestions = computeMcppTomlCompletions(["[target.'cfg(windows)'.dependencies]", ""], 1, 0); + assert.ok(labels(suggestions).includes('name = "version"')); +}); + +test("suggests dependency templates in build-dependencies", () => { + const suggestions = computeMcppTomlCompletions(["[build-dependencies]", ""], 1, 0); + assert.ok(labels(suggestions).includes('name = "version"')); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "template")); +}); + +test("suggests templates in free-key sections", () => { + const features = computeMcppTomlCompletions(["[features]", ""], 1, 0); + assert.ok(features.every((suggestion) => suggestion.kind === "template")); + assert.ok(labels(features).includes("name = { defines = [...] }")); + + const capabilities = computeMcppTomlCompletions(["[capabilities]", ""], 1, 0); + assert.ok(labels(capabilities).includes('capability = "provider"')); + + const generated = computeMcppTomlCompletions(["[generated_files]", ""], 1, 0); + assert.ok(labels(generated).includes('"path" = "content"')); +}); + +test("offers nothing at value positions", () => { + // 版本候选等动态数据层落地前,值位置不出建议。 + assert.deepEqual(computeMcppTomlCompletions(["[dependencies]", 'zlib = "'], 1, 8), []); + assert.deepEqual(computeMcppTomlCompletions(["[package]", 'name = "'], 1, 7), []); +}); + +test("offers nothing inside nested inline tables", () => { + // containerPath 非空(内联表深处)不出建议。 + assert.deepEqual(computeMcppTomlCompletions(["[features]", "simd = { flags = [ { "], 1, 21), []); +}); + +test("replacement range covers a partially typed key", () => { + const suggestions = computeMcppTomlCompletions(["[dependencies]", "na"], 1, 2); + assert.ok(suggestions.length > 0); + for (const suggestion of suggestions) { + assert.deepEqual(suggestion.range, { startCharacter: 0, endCharacter: 2 }); + } +}); diff --git a/test/mcppTomlContract.test.ts b/test/mcppTomlContract.test.ts new file mode 100644 index 0000000..a9506fb --- /dev/null +++ b/test/mcppTomlContract.test.ts @@ -0,0 +1,228 @@ +// mcpp.toml 契约测试:用真实 mcpp 二进制验证扩展手写的 manifest 语义 +// (段头清单、模板键、条件段规则)与权威 parser 一致。 +// +// 没有 mcpp 的环境整个文件 skip。mcpp 调用每次 60s 超时,全程串行。 + +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; + +import { SECTION_HEADERS, type SectionHeaderSpec } from "../src/mcppTomlCompletion"; + +/** 探测 mcpp 是否可用;不可用则全部跳过。 */ +function detectMcpp(): string | undefined { + try { + const result = spawnSync("mcpp", ["--version"], { timeout: 10_000 }); + if (result.error !== undefined || result.status !== 0) { + return undefined; + } + return String(result.stdout).trim(); + } catch { + return undefined; + } +} + +const mcppVersion = detectMcpp(); +const skipReason = mcppVersion === undefined ? "未检测到 mcpp 二进制,跳过契约测试" : false; +if (skipReason !== false) { + console.log(skipReason); +} else { + console.log(`契约测试使用 ${mcppVersion}`); +} + +const MCPP_TIMEOUT_MS = 60_000; + +interface McppRun { + code: number; + output: string; +} + +/** 在指定工程目录串行执行 `mcpp build`,汇总 stdout+stderr。 */ +function runMcppBuild(projectDir: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("mcpp", ["build"], { cwd: projectDir, timeout: MCPP_TIMEOUT_MS }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.on("error", reject); + child.on("close", (code, signal) => { + if (signal !== null) { + reject(new Error(`mcpp build 被信号 ${signal} 终止(疑似超时):\n${output}`)); + return; + } + resolve({ code: code ?? -1, output }); + }); + }); +} + +/** 每次新建唯一临时工程目录,注册到清理列表。 */ +const tempDirs: string[] = []; +function makeProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpp-contract-")); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "main.cpp"), "int main() { return 0; }\n"); + return dir; +} + +process.on("exit", () => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const BASE_MANIFEST = '[package]\nname = "probe"\nversion = "0.1.0"\n'; + +/** 写 manifest(基线 + 附加内容)与额外卖文件,然后跑 mcpp build。 */ +function buildWith(extraManifest: string, files: Record = {}): Promise { + const dir = makeProject(); + fs.writeFileSync(path.join(dir, "mcpp.toml"), `${BASE_MANIFEST}\n${extraManifest}`); + for (const [relative, content] of Object.entries(files)) { + const target = path.join(dir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return runMcppBuild(dir); +} + +/** 核心断言:构建成功且输出无 unsupported / error(大小写不敏感)。 */ +function assertClean(run: McppRun, what: string): void { + assert.equal(run.code, 0, `${what}:mcpp build 退出码 ${run.code}\n${run.output}`); + assert.ok( + !/unsupported|error/i.test(run.output), + `${what}:输出含 unsupported/error 诊断\n${run.output}`, + ); +} + +/** + * 把段头注册表条目变成可构建的具体 manifest 片段。 + * snippet 占位符取默认值(如 [targets.${1:name}] → [targets.name])。 + */ +function fixtureFor(entry: SectionHeaderSpec): { manifest: string; files: Record } { + const header = entry.header.replace(/\$\{\d+:([^}]*)\}/g, "$1"); + assert.ok(!header.includes("$"), `段头 ${entry.label} 的占位符未能全部展开`); + if (entry.group === "package") { + // [package] 永远在基线工程里,直接验证基线本身。 + return { manifest: "", files: {} }; + } + if (entry.group === "targets") { + // [targets.] 需要 kind;kind = "lib" 需要 src/.cppm 消除 lib-root 警告。 + return { + manifest: `${header}\nkind = "lib"\n`, + files: { "src/name.cppm": "export module name;\n" }, + }; + } + return { manifest: `${header}\n`, files: {} }; +} + +test("段头注册表形态:数量与关键段齐全(防止测试与实现脱节)", { skip: skipReason }, () => { + assert.ok(SECTION_HEADERS.length >= 20, `段头注册表只有 ${SECTION_HEADERS.length} 条(期望 ≥ 20)`); + const groups = new Set(SECTION_HEADERS.map((entry) => entry.group)); + for (const key of ["package", "dependencies", "build-dependencies", "workspace", "pack"]) { + assert.ok(groups.has(key), `段头注册表缺少关键段 ${key}`); + } + const labels = SECTION_HEADERS.map((entry) => entry.label); + assert.equal(new Set(labels).size, labels.length, "段头注册表存在重复 label"); +}); + +test("段头清单契约:每个注册段 mcpp 都接受、无诊断", { skip: skipReason, timeout: 600_000 }, async (t) => { + for (const entry of SECTION_HEADERS) { + await t.test(entry.label, async () => { + const fixture = fixtureFor(entry); + const run = await buildWith(fixture.manifest, fixture.files); + assertClean(run, `段 ${entry.label}`); + }); + } +}); + +test("[indices] 带 path 条目被接受(项目级索引重定向)", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith('[indices]\nmyidx = { path = "/tmp/mcpp-contract-nonexistent-index" }\n'); + assertClean(run, "[indices] 索引重定向"); +}); + +// 依赖 spec 的 12 个键:与 src/mcppTomlCompletion.ts 的 DEPENDENCY_TEMPLATES +// 保持同步(模板未逐一列出键名,此处按 mcpp manifest schema 硬编码)。 +// 注意:features/backend/tools/host-module/reexport 不是「锚定键」——单独出现 +// 时 mcpp 会把内联表当成嵌套依赖表报错,必须搭配 version/path/git/workspace +// 之一;tag/branch/rev 则必须搭配 git。写在 [feature-deps.] 下只解析、 +// 不下载(feature 未激活),适合断言「键被接受、无诊断」。 +const DEP_SPEC_CASES: ReadonlyArray = [ + ["version", 'dep = { version = "1.0.0" }'], + ["path", 'dep = { path = "../x" }'], + ["git+tag", 'dep = { git = "https://example.com/r.git", tag = "v1" }'], + ["git+branch", 'dep = { git = "https://example.com/r.git", branch = "main" }'], + ["git+rev", 'dep = { git = "https://example.com/r.git", rev = "abc123" }'], + ["features", 'dep = { version = "1.0.0", features = ["f"] }'], + ["backend", 'dep = { version = "1.0.0", backend = "cmake" }'], + ["tools", 'dep = { version = "1.0.0", tools = ["t"] }'], + ["host-module", 'dep = { version = "1.0.0", host-module = true }'], + ["reexport", 'dep = { version = "1.0.0", reexport = true }'], + ["workspace", "dep = { workspace = true }"], +]; + +test("模板键契约:[feature-deps] 下依赖 spec 各键被接受", { skip: skipReason, timeout: 600_000 }, async (t) => { + for (const [name, spec] of DEP_SPEC_CASES) { + await t.test(name, async () => { + const run = await buildWith(`[feature-deps.f1]\n${spec}\n`); + assertClean(run, `[feature-deps.f1] 依赖 spec 键 ${name}`); + }); + } +}); + +test("模板键契约:[features] 表形式与数组简写被接受", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith([ + "[features]", + 'f1 = { defines = ["X"], implies = [], sources = ["src/**"] }', + 'f2 = ["f1"]', + 'f3 = { requires = ["blas"] }', + "", + ].join("\n")); + assertClean(run, "[features] 表形式键"); +}); + +// 其余写法模板的完整实例(依赖/feature 之外的模板条目)。capabilities 的 +// provider 绑定在「无包 require 该能力」时不解析,静默通过;generated_files +// 的条目会在源 glob 展开前写入工程树,这里同时验证生成机制生效(编译成功 +// 即说明模块文件被正常纳入构建)。 +test("模板实例契约:capabilities / xlings / tools.overrides / generated_files", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith( + [ + "[capabilities]", + 'blas = "compat.openblas"', + "", + "[xlings.workspace]", + 'clang = "20.1.7"', + "", + "[xlings.envs]", + 'FOO = "1"', + "", + "[tools.overrides]", + '"compat.protobuf:protoc" = "/usr/bin/protoc"', + "", + "[generated_files]", + '"src/gen/wrap.cppm" = """', + "export module wrap;", + '"""', + "", + ].join("\n"), + ); + assertClean(run, "模板实例(capabilities/xlings/tools.overrides/generated_files)"); +}); + +test("条件段规则:[target.'cfg(windows)'.build] 接受 build inputs", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith("[target.'cfg(windows)'.build]\ndefines = [\"A=1\"]\n"); + assertClean(run, "条件段 build inputs"); +}); + +test("条件段规则:反向断言——cache 在条件段必须报 unsupported", { skip: skipReason, timeout: 120_000 }, async () => { + // 钉住「条件段只接受 build inputs」:cache 属于 profile 设置,必须告警。 + const run = await buildWith("[target.'cfg(windows)'.build]\ncache = \"off\"\n"); + assert.equal(run.code, 0, `条件段 cache:mcpp build 退出码 ${run.code}\n${run.output}`); + assert.ok( + /unsupported key 'cache'/i.test(run.output), + `条件段 cache:期望 unsupported-key 警告\n${run.output}`, + ); +}); diff --git a/test/mcppTomlParser.test.ts b/test/mcppTomlParser.test.ts new file mode 100644 index 0000000..fb29820 --- /dev/null +++ b/test/mcppTomlParser.test.ts @@ -0,0 +1,461 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + contextAt, + parseMcppToml, + resolveSection, + type TomlKeyValueNode, + type TomlSectionNode, +} from "../src/mcppTomlParser"; + +function sectionAt(lines: string[], index: number): TomlSectionNode { + const node = parseMcppToml(lines).nodes[index]; + assert.equal(node?.type, "section"); + return node as TomlSectionNode; +} + +function keyValueAt(lines: string[], index: number): TomlKeyValueNode { + const node = parseMcppToml(lines).nodes[index]; + assert.equal(node?.type, "keyValue"); + return node as TomlKeyValueNode; +} + +// ---- 段头解析 ---- + +test("解析普通段头及其范围", () => { + const section = sectionAt(["[package]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["package"]); + assert.equal(section.segments[0].quoted, false); + assert.equal(section.isArray, false); + assert.equal(section.open, false); + assert.equal(section.line, 0); + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 9 }); + assert.deepEqual(section.segments[0].range, { startLine: 0, startCharacter: 1, endLine: 0, endCharacter: 8 }); +}); + +test("解析参数化段头 [targets.myapp]", () => { + const section = sectionAt(["[targets.myapp]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["targets", "myapp"]); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 9, endLine: 0, endCharacter: 14 }); +}); + +test("解析单引号段 [target.'cfg(windows)'.build]", () => { + const section = sectionAt(["[target.'cfg(windows)'.build]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["target", "cfg(windows)", "build"]); + assert.equal(section.segments[1].quoted, true); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 8, endLine: 0, endCharacter: 22 }); +}); + +test("解析双引号段 [runtime.\"opengl.glx.driver\"](带点的段名是一段)", () => { + const section = sectionAt(['[runtime."opengl.glx.driver"]'], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["runtime", "opengl.glx.driver"]); + assert.equal(section.segments[1].quoted, true); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 9, endLine: 0, endCharacter: 28 }); +}); + +test("解析数组表段头 [[build.flags]]", () => { + const section = sectionAt(["[[build.flags]]"], 0); + assert.equal(section.isArray, true); + assert.equal(section.open, false); + assert.deepEqual(section.segments.map((s) => s.name), ["build", "flags"]); + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 15 }); +}); + +test("未闭合段头解析为 open 状态且不抛错", () => { + const document = parseMcppToml(["[dep", 'name = "x"']); + const section = document.nodes[0] as TomlSectionNode; + assert.equal(section.type, "section"); + assert.equal(section.open, true); + assert.deepEqual(section.segments.map((s) => s.name), ["dep"]); + // 后续行继续正常解析。 + const keyValue = document.nodes[1] as TomlKeyValueNode; + assert.equal(keyValue.type, "keyValue"); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["name"]); +}); + +// ---- 键值解析 ---- + +test("解析裸键字符串键值对及其范围", () => { + const keyValue = keyValueAt(["[package]", 'name = "demo"'], 1); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["name"]); + assert.deepEqual(keyValue.keyPath[0].range, { startLine: 1, startCharacter: 0, endLine: 1, endCharacter: 4 }); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, "demo"); + assert.deepEqual(keyValue.value?.range, { startLine: 1, startCharacter: 7, endLine: 1, endCharacter: 13 }); + assert.deepEqual(keyValue.value?.contentRange, { startLine: 1, startCharacter: 8, endLine: 1, endCharacter: 12 }); +}); + +test("解析引号键(键名带点仍是一段)", () => { + const keyValue = keyValueAt(['"chriskohlhoff.asio" = "1.28"'], 0); + assert.equal(keyValue.keyPath.length, 1); + assert.equal(keyValue.keyPath[0].name, "chriskohlhoff.asio"); + assert.equal(keyValue.keyPath[0].quoted, true); + assert.equal(keyValue.value?.text, "1.28"); +}); + +test("解析点分键 capi.lua", () => { + const keyValue = keyValueAt(['capi.lua = "1.0"'], 0); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["capi", "lua"]); + assert.deepEqual(keyValue.keyPath[1].range, { startLine: 0, startCharacter: 5, endLine: 0, endCharacter: 8 }); +}); + +test("解析整数与布尔值", () => { + const integer = keyValueAt(["opt = 2"], 0); + assert.equal(integer.value?.kind, "integer"); + assert.equal(integer.value?.text, "2"); + assert.deepEqual(integer.value?.range, { startLine: 0, startCharacter: 6, endLine: 0, endCharacter: 7 }); + + const boolean = keyValueAt(["debug = true"], 0); + assert.equal(boolean.value?.kind, "boolean"); + assert.equal(boolean.value?.text, "true"); + assert.deepEqual(boolean.value?.range, { startLine: 0, startCharacter: 8, endLine: 0, endCharacter: 12 }); +}); + +test("解析跨行数组", () => { + const keyValue = keyValueAt(["sources = [", ' "a",', ' "b",', "]"], 0); + assert.equal(keyValue.value?.kind, "array"); + assert.equal(keyValue.value?.open, false); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 10, endLine: 3, endCharacter: 1 }); + assert.deepEqual(keyValue.value?.elements?.map((element) => element.text), ["a", "b"]); +}); + +test("未闭合数组解析为 open 状态", () => { + const keyValue = keyValueAt(["flags = [ { glob = \"x\" },"], 0); + assert.equal(keyValue.value?.kind, "array"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.elements?.length, 1); + assert.equal(keyValue.value?.elements?.[0].kind, "inlineTable"); +}); + +test("解析嵌套内联表", () => { + const keyValue = keyValueAt(['dep = { version = "1", opts = { a = 1 } }'], 0); + assert.equal(keyValue.value?.kind, "inlineTable"); + assert.equal(keyValue.value?.open, false); + const entries = keyValue.value?.entries ?? []; + assert.deepEqual(entries.map((entry) => entry.keyPath.map((s) => s.name)), [["version"], ["opts"]]); + assert.equal(entries[0].value?.kind, "string"); + assert.equal(entries[1].value?.kind, "inlineTable"); + assert.equal(entries[1].value?.entries?.[0].value?.kind, "integer"); +}); + +test("未闭合内联表解析为 open 状态并保留已输入条目", () => { + const keyValue = keyValueAt(['dep = { version = "1"'], 0); + assert.equal(keyValue.value?.kind, "inlineTable"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.entries?.length, 1); + assert.deepEqual(keyValue.value?.entries?.[0].keyPath.map((s) => s.name), ["version"]); +}); + +// ---- 注释、字符串与行尾 ---- + +test("注释被跳过,字符串内的 # 不算注释", () => { + const keyValue = keyValueAt(["# 顶部注释", 'name = "a # b" # 行尾注释'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.text, "a # b"); +}); + +test("字符串内的转义引号不结束字符串", () => { + const keyValue = keyValueAt(['desc = "a \\"b\\" c"'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, 'a \\"b\\" c'); +}); + +test("解析三引号多行字符串", () => { + const keyValue = keyValueAt(['text = """', "hello", '"""'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.multiline, true); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, "\nhello\n"); +}); + +test("未闭合字符串解析为 open 状态", () => { + const keyValue = keyValueAt(['name = "hel'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.text, "hel"); +}); + +test("CRLF 行尾被剥除,列号按剥除后计算", () => { + const document = parseMcppToml(["[package]\r", 'name = "demo"\r']); + const section = document.nodes[0] as TomlSectionNode; + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 9 }); + const keyValue = document.nodes[1] as TomlKeyValueNode; + assert.deepEqual(keyValue.keyPath[0].range, { startLine: 1, startCharacter: 0, endLine: 1, endCharacter: 4 }); + assert.deepEqual(keyValue.value?.contentRange, { startLine: 1, startCharacter: 8, endLine: 1, endCharacter: 12 }); +}); + +test("无法识别的垃圾行不抛错、不中断后续解析", () => { + const document = parseMcppToml(["!!garbage!!", "[package]"]); + const section = document.nodes.find((node) => node.type === "section") as TomlSectionNode; + assert.deepEqual(section.segments.map((s) => s.name), ["package"]); +}); + +// ---- resolveSection 段归属语义 ---- + +test("resolveSection 精确匹配已知段", () => { + assert.deepEqual(resolveSection(["package"]), { kind: "known", group: "package" }); + assert.deepEqual(resolveSection(["build"]), { kind: "known", group: "build" }); + assert.deepEqual(resolveSection(["workspace", "dependencies"]), { kind: "known", group: "workspace.dependencies" }); + assert.deepEqual(resolveSection(["pack", "bundle-project"]), { kind: "known", group: "pack.bundle-project" }); + assert.deepEqual(resolveSection(["tools", "overrides"]), { kind: "known", group: "tools.overrides" }); + assert.deepEqual(resolveSection(["xlings", "envs"]), { kind: "known", group: "xlings.envs" }); +}); + +test("resolveSection 参数化段归入基组", () => { + assert.deepEqual(resolveSection(["targets", "myapp"]), { kind: "known", group: "targets" }); + assert.deepEqual(resolveSection(["profile", "release"]), { kind: "known", group: "profile" }); + assert.deepEqual(resolveSection(["feature-deps", "simd"]), { kind: "known", group: "feature-deps" }); + assert.deepEqual(resolveSection(["dependencies", "capi"]), { kind: "known", group: "dependencies" }); + assert.deepEqual(resolveSection(["dependencies", "capi", "lua"]), { kind: "known", group: "dependencies" }); + assert.deepEqual(resolveSection(["dev-dependencies", "ns"]), { kind: "known", group: "dev-dependencies" }); + assert.deepEqual(resolveSection(["workspace", "dependencies", "ns"]), { kind: "known", group: "workspace.dependencies" }); +}); + +test("resolveSection target 选择器与子表递归", () => { + assert.deepEqual(resolveSection(["target"]), { kind: "known", group: "target" }); + assert.deepEqual(resolveSection(["target", "x86_64-linux-gnu"]), { kind: "known", group: "target" }); + assert.deepEqual(resolveSection(["target", "cfg(windows)", "build"]), { kind: "known", group: "build" }); + assert.deepEqual(resolveSection(["target", "x", "feature-deps", "codegen"]), { kind: "known", group: "feature-deps" }); + assert.deepEqual(resolveSection(["target", "x", "dependencies", "capi"]), { kind: "known", group: "dependencies" }); +}); + +test("resolveSection runtime capability 子表", () => { + assert.deepEqual(resolveSection(["runtime"]), { kind: "known", group: "runtime" }); + assert.deepEqual(resolveSection(["runtime", "opengl.glx.driver"]), { kind: "known", group: "runtime.capability" }); +}); + +test("resolveSection 区分未知段与文档顶部", () => { + assert.deepEqual(resolveSection(["custom"]), { kind: "unknown", segments: ["custom"] }); + assert.deepEqual(resolveSection(["build", "flags"]), { kind: "unknown", segments: ["build", "flags"] }); + assert.deepEqual(resolveSection([]), { kind: "top" }); +}); + +// ---- contextAt 光标上下文 ---- + +test("contextAt 未闭合段头内:段头上下文与替换范围", () => { + const context = contextAt(["[tar"], 0, 4); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.deepEqual(context.segments, []); + assert.equal(context.isArray, false); + assert.deepEqual(context.replaceRange, { startCharacter: 1, endCharacter: 4 }); +}); + +test("contextAt 段头点号之后:已解析段作为前缀", () => { + const context = contextAt(["[targets."], 0, 9); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.deepEqual(context.segments, ["targets"]); + assert.deepEqual(context.replaceRange, { startCharacter: 9, endCharacter: 9 }); +}); + +test("contextAt 段头中间段的替换范围覆盖整个 token", () => { + const context = contextAt(["[targets.myapp]"], 0, 11); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.deepEqual(context.segments, ["targets"]); + assert.deepEqual(context.replaceRange, { startCharacter: 9, endCharacter: 14 }); +}); + +test("contextAt 数组表段头刚输入 [[ 时", () => { + const context = contextAt(["[["], 0, 2); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.equal(context.isArray, true); + assert.deepEqual(context.segments, []); +}); + +test("contextAt 已知段内空行:键位置并携带段归属", () => { + const context = contextAt(["[package]", ""], 1, 0); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.section, { kind: "known", group: "package" }); + assert.deepEqual(context.containerPath, []); + assert.deepEqual(context.keyPrefix, []); + assert.deepEqual(context.replaceRange, { startCharacter: 0, endCharacter: 0 }); +}); + +test("contextAt 区分文档顶部与未知段", () => { + const top = contextAt([""], 0, 0); + assert.equal(top.kind, "key"); + if (top.kind === "key") { + assert.deepEqual(top.section, { kind: "top" }); + } + + const unknown = contextAt(["[custom]", ""], 1, 0); + assert.equal(unknown.kind, "key"); + if (unknown.kind === "key") { + assert.deepEqual(unknown.section, { kind: "unknown", segments: ["custom"] }); + } +}); + +test("contextAt 点分键中间:keyPrefix 与替换范围", () => { + const context = contextAt(["capi.la"], 0, 7); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.containerPath, []); + assert.deepEqual(context.keyPrefix, ["capi"]); + assert.deepEqual(context.replaceRange, { startCharacter: 5, endCharacter: 7 }); +}); + +test("contextAt 字符串值内部:insideString 与替换范围", () => { + const context = contextAt(['kind = "bin"'], 0, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["kind"]); + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.equal(context.stringOpen, false); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 11 }); +}); + +test("contextAt 未闭合字符串内:stringOpen 为 true", () => { + const context = contextAt(['name = "hel'], 0, 11); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["name"]); + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.equal(context.stringOpen, true); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 11 }); +}); + +test("contextAt 等号后值未开始:valueKind 为 undefined", () => { + const context = contextAt(["kind = "], 0, 7); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["kind"]); + assert.equal(context.valueKind, undefined); + assert.equal(context.insideString, false); + assert.deepEqual(context.replaceRange, { startCharacter: 7, endCharacter: 7 }); +}); + +test("contextAt 数组元素槽位", () => { + const context = contextAt(["sources = [ "], 0, 12); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["sources"]); + assert.equal(context.valueKind, undefined); + assert.deepEqual(context.replaceRange, { startCharacter: 12, endCharacter: 12 }); +}); + +test("contextAt 深度下钻:features 值 → flags 数组 → 内联表条目的键位置", () => { + const context = contextAt(["[features]", "simd = { flags = [ { "], 1, 21); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.section, { kind: "known", group: "features" }); + assert.deepEqual(context.containerPath, ["simd", "flags"]); + assert.deepEqual(context.keyPrefix, []); + assert.deepEqual(context.replaceRange, { startCharacter: 21, endCharacter: 21 }); +}); + +test("contextAt 内联表条目的字符串值:keyPath 含下钻路径", () => { + const context = contextAt(['dep = { version = "1.0" }'], 0, 20); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["dep", "version"]); + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 19, endCharacter: 22 }); +}); + +test("contextAt 数组内内联表条目的值:keyPath 穿过数组", () => { + const context = contextAt(['flags = [ { glob = "src/**" } ]'], 0, 23); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["flags", "glob"]); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 20, endCharacter: 26 }); +}); + +test("contextAt 布尔 token 中间:valueKind 为 boolean 且替换范围覆盖 token", () => { + const context = contextAt(["debug = true"], 0, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "boolean"); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 12 }); +}); + +test("contextAt 兼容 CRLF 行尾", () => { + const context = contextAt(["[package]\r", 'name = "demo"\r'], 1, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.section, { kind: "known", group: "package" }); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 12 }); +}); + +test("contextAt 越界坐标被钳制而不抛错", () => { + const context = contextAt(["[package]"], 99, 99); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.section, { kind: "known", group: "package" }); +}); + +// ---- 回归:值槽位替换范围 / 引号停止符 / 裸值停止字符 ---- + +test("contextAt 光标在 = 与值 token 之间的空白上:替换整个 token", () => { + const context = contextAt(["cmdline = true"], 0, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["cmdline"]); + assert.equal(context.valueKind, "boolean"); + assert.deepEqual(context.replaceRange, { startCharacter: 10, endCharacter: 14 }); +}); + +test("contextAt 光标恰在值 token 首字符:替换范围覆盖整个 token", () => { + const context = contextAt(["cmdline = true"], 0, 10); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "boolean"); + assert.deepEqual(context.replaceRange, { startCharacter: 10, endCharacter: 14 }); +}); + +test("contextAt 光标在字符串 token 前的空白上:替换范围覆盖整个带引号 token", () => { + const context = contextAt(['name = "demo"'], 0, 6); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, false); + assert.deepEqual(context.replaceRange, { startCharacter: 7, endCharacter: 13 }); +}); + +test("contextAt 单引号字符串内的双引号不截断替换范围", () => { + const context = contextAt(["s = 'a\"b'"], 0, 7); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 5, endCharacter: 8 }); +}); + +test("contextAt 双引号字符串内的单引号不截断替换范围", () => { + const context = contextAt(["s = \"a'b\""], 0, 7); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 5, endCharacter: 8 }); +}); + +test("裸值不停在 [ / { 之外的后续结构:x = 1 [dep", () => { + const document = parseMcppToml(["x = 1 [dep"]); + const keyValue = document.nodes[0] as TomlKeyValueNode; + assert.equal(keyValue.type, "keyValue"); + assert.equal(keyValue.value?.kind, "integer"); + assert.equal(keyValue.value?.text, "1"); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 4, endLine: 0, endCharacter: 5 }); + const section = document.nodes[1] as TomlSectionNode; + assert.equal(section.type, "section"); + assert.equal(section.open, true); + assert.deepEqual(section.segments.map((s) => s.name), ["dep"]); +}); + +test("裸值在 { 前停止:y = 2 {k = 1}", () => { + const keyValue = keyValueAt(["y = 2 {k = 1}"], 0); + assert.equal(keyValue.value?.kind, "integer"); + assert.equal(keyValue.value?.text, "2"); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 4, endLine: 0, endCharacter: 5 }); +}); diff --git a/test/moduleSetup.test.ts b/test/moduleSetup.test.ts index c6fad77..2796c97 100644 --- a/test/moduleSetup.test.ts +++ b/test/moduleSetup.test.ts @@ -210,7 +210,7 @@ test("阻止原因按未信任、未知清单、忙碌、项目覆盖的顺序 ); }); -test("模块设置命令使用固定 argv 数组且始终构建", () => { +test("模块设置命令仅在工具链变化时清缓存", () => { assert.deepEqual( mcppModuleSetupCommands({ kind: "ready", installLlvm: true, switchDefault: true }), [ @@ -221,7 +221,7 @@ test("模块设置命令使用固定 argv 数组且始终构建", () => { ); assert.deepEqual( mcppModuleSetupCommands({ kind: "ready", installLlvm: false, switchDefault: false }), - [{ stage: "build", mode: "task", args: ["build", "--no-cache"] }], + [{ stage: "build", mode: "task", args: ["build"] }], ); }); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index e56019b..7c3b1c3 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,43 @@ 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("releases an IDE operation queue when an external operation exceeds its deadline", async () => { + const stalled = new Promise(() => { + // 模拟 clangd.restart 或 mcpp configure-only 永久等待。 + }); + + const result = await workflow.withTimeout(stalled, 10, "timed-out"); + + assert.equal(result, "timed-out"); +}); + test("serializes reconciliation and merges pending restart requests", async () => { let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { @@ -369,30 +409,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", () => {