diff --git a/.agents/docs/README.md b/.agents/docs/README.md new file mode 100644 index 0000000..c2d029b --- /dev/null +++ b/.agents/docs/README.md @@ -0,0 +1,32 @@ +# .agents/docs + +本目录存放 **面向贡献者与 agent 的设计/背景文档**:对 mcpp-vscode 当前架构、跨仓库契约 +与决策的记录。使用说明请看仓库根目录的 `README.md`。 + +| 文件 | 内容 | 状态 | +| --- | --- | --- | +| [`architecture.md`](architecture.md) | mcpp-vscode 的分层结构、模块职责与扩展点 | 现状 | +| [`mcpp-integration.md`](mcpp-integration.md) | 与 `mcpp-community/mcpp`(mcpp CLI)的接口契约 | 现状 | +| [`mcppls-integration.md`](mcppls-integration.md) | 与 `sunrisepeak.mcpp-language-server`(mcppls)的依赖与命令桥接契约 | 现状 | + + +边界: + +- `docs/`(仓库根)= **用户文档**(安装、命令、设置、排错); +- `.agents/docs/` = **贡献者/agent 文档**(设计、契约、方案、决策); +- `.agents/superpowers/` = **过程文档**(原 `docs/superpowers/`,plans/specs); +- `.agents/reviews/` = **分析与评审记录**,已加入 `.gitignore`,不进入版本库。 + +背景分析见 `.agents/reviews/2026-10-02-mcpp-vscode-architecture-and-mcppls-dependency-review.md`。 + +## archive/ + +`archive/` 存放**已按其执行完毕**的方案文档(round 制的评审与决定记录),按日期命名。 +当前实现与其偏差以代码与 CHANGELOG 为准: + +| 文档 | 内容 | +| --- | --- | +| [`archive/2026-10-02-implementation-plan.md`](archive/2026-10-02-implementation-plan.md) | 0.5.0 基座实施(纯模块、注册表、门禁) | +| [`archive/2026-10-02-plugin-optimisation-plan.md`](archive/2026-10-02-plugin-optimisation-plan.md) | 插件优化方案 v4(侧边栏、编辑体验、缓存、i18n、配置面板) | +| [`archive/2026-10-02-ui-ux-optimisation-plan.md`](archive/2026-10-02-ui-ux-optimisation-plan.md) | 0.6.0 UI/UX 方案与 23 轮作者反馈记录 | +| [`archive/2026-10-03-repo-engineering-plan.md`](archive/2026-10-03-repo-engineering-plan.md) | 仓库工程化(双市场发布、WebviewDocument、目录收敛) | diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md new file mode 100644 index 0000000..9e8174a --- /dev/null +++ b/.agents/docs/architecture.md @@ -0,0 +1,103 @@ +# mcpp-vscode 架构 + +> 第 1–6 节保留 0.4.0 迁移背景;Libraries 的当前架构与边界见第 7 节(2026-10-18)。 + +## 1. 一句话定位 + +mcpp-vscode 是 **mcpp CLI 的 IDE 前端**:工程发现、任务(build/run/test/clean)、工具链管理、 +`mcpp.toml` 的语法与结构补全。C++ 模块语义(诊断/补全/跳转/引用/模块图/状态)由扩展依赖 +`sunrisepeak.mcpp-language-server`(mcppls)提供,本扩展**不创建 LSP 客户端**。 + +## 2. 分层 + +``` +extension.ts VS Code 装配层:激活、命令注册、provider 注册、资源释放 +├── cliController.ts CLI 编排层:快捷菜单、任务、工具链、新工程、操作互斥 +│ ├── newProject.ts 新建工程流程(纯函数 + 注入的动作) +│ └── commands.ts 命令 ID 与快捷菜单清单(纯数据) +├── languageServer.ts mcppls 桥接层:只调用 4 个公开 VS Code 命令 +├── moduleSetup.ts 一键流程状态机(build → 刷新语言服务) +├── inProject.ts `mcpp.inProject` 上下文键 +├── mcppTomlCompletion.ts mcpp.toml 补全查询层(段头 + 写法模板) +├── mcppTomlParser.ts 容错 TOML 解析器(纯函数) +├── discovery.ts 最近的 mcpp.toml 发现(纯函数) +├── cli.ts mcpp CLI 输出解析(工具链清单) +├── process.ts runProcess 封装 +└── tasks.ts 任务计划、退出码分类、操作注册表(纯函数) +``` + +**约束**:`tasks.ts` / `moduleSetup.ts` / `discovery.ts` / `cli.ts` / `mcppTomlParser.ts` / +`newProject.ts` 不 import `vscode`,因此可用 `node --test` 直接测。`extension.ts` 与 +`cliController.ts` 是唯一接触 VS Code API 的地方。 + +## 3. 运行时边界 + +| 触发 | 行为 | +| --- | --- | +| 打开含 `mcpp.toml` 的工作区 | 激活;不执行 `mcpp`,不下载工具链 | +| 执行 mcpp 命令 | 通过 `vscode.Task` + `ProcessExecution` 在专用终端运行 | +| build 结束(含失败) | 调用一次 `mcppls.restartServer`(单飞,取消除外) | +| run/test/clean 结束 | 不触碰语言服务 | +| 未受信任工作区 | 只保留语法高亮与 TOML 结构补全 | + +## 4. 并发模型 + +`McppOperationRegistry`(`tasks.ts`)做两级互斥: + +- **项目级**(键 = 工程根):build/run/test/clean 互斥; +- **全局**:工具链安装 / 设置全局默认,与所有项目级操作互斥。 + +任务结束**先释放锁、再刷新语言服务**,避免刷新期间用户操作被拒。该顺序由 +`test/artifacts.test.ts` 钉住。 + +## 5. 与 mcppls 的唯一接口 + +`languageServer.ts` 是全部耦合面: + +| 本扩展命令 | 转发到 | +| --- | --- | +| `mcpp.configureLanguageServer`(+ 弃用别名 `mcpp.configureClangd`) | `mcppls.selectContext` | +| `mcpp.checkModuleSupport` | `mcppls.restartServer` | +| `mcpp.showModuleGraph` | `mcppls.showModuleGraph` | +| `mcpp.showLanguageServerLogs` | `mcppls.showLogs` | +| build 之后 | `mcppls.restartServer` | + +不读 `mcppls.*` 设置、不解析 LSP、不写跨扩展配置。详见 +[`mcppls-integration.md`](mcppls-integration.md)。 + +## 6. 已知的结构性债务 + +- `src/configureOnly.ts`、`src/ideWorkflow.ts` 在 0.4.0 迁移后已无生产调用者(仅被自身测试引用), + 它们描述的是 `compile_commands.json` 时代的行为,与当前职责边界冲突。 +- `cli.ts` 用正则解析 `mcpp toolchain list` 的**人类输出**,而 mcpp 已提供 + `--format json` 的稳定契约。 +- 命令 ID `mcpp.refreshCompilationDatabase` / `mcpp.checkModuleSupport` 是历史拼写,与当前语义 + (build / restart)不符。 + +具体建议见 `.agents/reviews/` 下的评审报告。 + + +## 7. Libraries(2026-10-18) + +- `library/indexLocator.ts`:显式索引路径或 CLI home 下的官方索引发现;新位置 `indices/mcpplibs` 优先,兼容 `registry/data/mcpplibs`。不默认收集 xim。缓存按源路径、平台、描述符 stat 和更新标记区分;项目状态是单独的 overlay。 +- `library/projectState.ts`:复用 TOML 解析器,区分普通、开发与构建声明、源码依赖、锁定结果和有歧义的短名;生成清单预览的目标 section/key。 +- `library/libraryView.ts` 与 `library/detailPanel.ts`:VS Code 生命周期和 CLI 编排;搜索响应绑定请求代次,添加操作绑定包、项目、页面代次,详情读取还单独校验渲染请求顺序。 +- `library/libraryHtml.ts`、`library/detailHtml.ts`:纯渲染器。初始文档只安装一次;mounted 仅请求重放最新数据,后续用 message 更新局部 DOM,保留查询、版本、依赖类型、折叠状态与焦点,不形成 reload loop。 +- `library/projectWatcher.ts`:只比较选中项目的 manifest/lock 内容;详情实际读到的内容更新比较基线,保证短暂 lock 状态也能恢复。监听和计时器随窗口或项目切换释放。 +- `library/addDependency.ts`:普通和开发依赖由 `mcpp add` 修改,构建依赖/分组迁移只提供复制片段与打开清单。扩展不自行改写 TOML,也不将 lock-only 误称为已声明/已安装。 + +本地真实 UI 验证入口:`tools/test-libraries-ui.mjs`;专用 profile 和产物均在 gitignored 的 `.dev-profile/`。 + +- `library/examples.ts`:官方安装版索引未携带测试时,读取带来源提交的内置测试快照;checkout 的本地测试优先,自定义索引不使用此兜底。完整代码通过只读 `mcpp-example` 文档打开。 +- `projects/dependencyGraph.ts`:纯声明图遍历,默认三跳(公共画布可请求更深数据),处理路径包、共享节点和循环;lock 只补版本。`dependencyGraphPanel.ts` 负责只读文件编排和本地 Mermaid Webview。Mermaid 固定版本与许可证由编译脚本复制至 `dist/vendor`,无需 CDN。 + +- `views/models.ts` 与 Libraries 共用直接声明解析,树节点携带项目、依赖和分组身份;点击进入声明图并居中高亮对应节点。Webview 使用 SVG 平移/缩放变换,滚轮保留鼠标锚点,Pointer Events 区分点击与拖动;Fit 可恢复完整图。 + +## 8. Shared dependency canvas and C++ Modules graph + +- `graphs/model.ts` defines a provider-independent graph, component roots, depth/branch projection, 150-node/300-edge layout budgets and escaped Mermaid output. Complete module data is retained independently of the visible subgraph. +- `projects/dependencyGraphPanel.ts` owns one persistent Webview with separate per-project/per-kind graph and navigation states. `graphs/panelHtml.ts` and `media/graph.js` share rendering, wheel/pointer navigation, folding, focus, search and theme handling. Layouts are serialized; obsolete provider requests are discarded. +- `cli/moduleGraph.ts` is a trust-gated, time/output-bounded consumer of mcppls CLI JSON. This extends the integration boundary with structured queries, without taking over C++ parsing, resolution, LSP ownership or mcppls settings. Prefer the installed dependency extension's payload, with PATH fallback when absent. +- `projects/moduleGraph.ts` validates the result, reads manifest entry conventions and determines file ownership by the nearest project manifest. Preserve all internal edges and just the first hop into another package; `external=false` alone cannot identify project ownership. Generated sources outside the project boundary are conservatively treated as external. +- `webview/readOnlyDocuments.ts` is the shared content-addressed virtual-document store used by examples and graph navigation; graph messages contain node identities, and the host opens only provider files attached to those identities. +- Basics contains C++ Modules Deps; declared package Dependencies follow as a collapsed sibling before Common commands. Existing language-server module-graph forwarding commands remain compatible. diff --git a/.agents/docs/archive/2026-10-02-implementation-plan.md b/.agents/docs/archive/2026-10-02-implementation-plan.md new file mode 100644 index 0000000..229e9b2 --- /dev/null +++ b/.agents/docs/archive/2026-10-02-implementation-plan.md @@ -0,0 +1,342 @@ +# mcpp-vscode 0.5.0 实施计划:任务拆分、依赖关系与验收 + +- 日期:2026-10-02 +- 上游设计:[`2026-10-02-plugin-optimisation-plan.md`](2026-10-02-plugin-optimisation-plan.md)(方案 v4) +- 分支:`feat/plugin-optimisation-v0.5.0`,**单 PR**,目标版本 `0.5.0` +- 本文只讲"怎么落地":任务、依赖、并行分组、每个任务自己的验收方式。 + +--- + +## 1. 六个评审角度 → 落到哪条硬约束 + +| 角度 | 硬约束(可机械检查) | 落在 | +|---|---|---| +| **架构** | `src/` 按用途分组;纯逻辑模块不 import `vscode`;每个新模块有对应测试 | T01、T03、T18、T24、T30、T33 | +| **稳定性** | 所有 `runProcess` 有超时;解析优先 JSON;任何上游失败都不能把成功的 `mcpp build` 变成失败 | T05–T09、T14 | +| **优雅简洁** | 单一事实源(配置 registry、i18n bundle、schema 快照);无重复的命令 ID 字面量;无死代码 | T02、T03、T24、T30 | +| **用户体验** | modal 只用于不可逆操作;每个视图有三态;每个清理有两步以上确认;文案跟随语言 | T15、T20–T23、T34、T37、T02 | +| **兼容性** | 不注册 `mcppls.*` 命令;`extensionDependencies` 语义不变;旧命令/旧设置保留为别名 | T11–T14、T04、T38 | +| **跨平台** | 无平台假设的路径/分隔符;CI 覆盖 linux-x64 + darwin-arm64;平台矩阵只在文档与 release note 声明 | T39、T40、T42 | +| **一致性** | 命令 ID / 设置键 / 文案 key 三者在 registry 与 package.json 之间由 CI 校验 | T03(check-config)、T02(l10n-check) | +| **无感升级** | 0.4.x 用户的设置、键位、命令 ID 全部继续工作;新增行为默认"不打扰";CHANGELOG 写迁移说明 | T04、T10、T38、T41 | + +--- + +## 2. 任务表 + +> **并行组**:同一组内的任务互不触碰同一文件,可以同时做;跨组有依赖。 +> **状态**:✅ 已完成 / 🔄 进行中 / ⬜ 未开始 + +### P0 地基(必须先做:后面所有任务都往这三处加东西) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T01 | 目录按用途分组 | `src/{cli,projects,toml,mcppls,commands,workflows}/` | — | P0-a | `npm test` 179 通过;`git log --follow` 可用 | ✅ | +| T02 | i18n 机制 | `data/i18n/{en,zh-cn}.json`、`l10n/bundle.l10n*.json`(生成)、`package.nls*.json`、`src/i18n/t.ts`、`tools/generate-l10n.mjs`、`tools/l10n-check.mjs` | T01 | P0-b | 缺 key 时 `l10n-check` 退出 1;`mcpp.ui.language` 三态有单测 | ⬜ | +| T03 | 配置模块 | `data/config-registry.json`、`src/config/{registry,access,validate,migrate}.ts`、`tools/check-config.mjs` | T01 | P0-c | registry ↔ package.json 语义一致的 CI 门禁;60 项全部可读且默认值正确 | ⬜ | +| T04 | 版本与迁移说明 | `package.json` 0.5.0、`CHANGELOG.md`(英) | — | P0-d | tag 与版本一致(release workflow 已有校验) | ⬜ | + +### P1 稳定性基座 + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T05 | mcpp 协议探测 | `src/cli/protocol.ts` | T03 | P1-a | 对真实 mcpp 解析出 `mcpp.protocol`;对旧输出返回"不支持"且不抛 | ⬜ | +| T06 | 工具链清单 JSON 优先 | `src/cli/toolchain.ts` | T05 | P1-a | JSON 路径与文本路径对同一份样本给出一致结果 | ⬜ | +| T07 | 超时与输出上限 | `src/cli/process.ts` | T03 | P1-b | 超时返回可识别错误;截断保留尾部并置标志 | ⬜ | +| T08 | 错误分层(SPEC-003) | `src/cli/errors.ts` | T07 | P1-b | 2/4/1/70/127 各有断言 | ⬜ | +| T09 | `mcpp.path` 校验 | `src/cli/controller.ts` | T05 | P1-c | 配置变更时校验一次并在频道输出 | ⬜ | +| T10 | 激活面收敛 | `package.json` activationEvents | T03 | P1-c | 只留 workspaceContains + onLanguage:mcpp-* | ⬜ | + +### P2 mcppls 韧性与状态(方案 §2、§3.9) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T11 | 能力表 | `src/mcppls/contract.ts` | T02 | P2-a | 表驱动单测:ID 唯一、danger 合法、无 `mcppls.` 前缀的本扩展命令 | ⬜ | +| T12 | 能力探测 | `src/mcppls/capabilities.ts` | T11 | P2-a | 静态声明 + 运行期分类;`onDidChange` 失效有单测 | ⬜ | +| T13 | 状态读取适配层 | `src/mcppls/state.ts` | T11 | P2-a | 5 种输入(正常/无 API/抛异常/未知 state/字段缺失)各有断言 | ⬜ | +| T14 | 桥接改造 | `src/mcppls/bridge.ts` | T12、T13 | P2-b | 候选链回退;危险级确认;`build` 永不因 mcppls 失败 | ⬜ | +| T15 | 「C++ Modules」视图 | `src/views/languageServerView.ts` | T14、T33 | P2-c | 三态(未安装/未启用/未暴露状态)可测 | ⬜ | +| T16 | e2e 夹具 5 个 + 套件 | `test/e2e/fixtures/mcppls-stub*` | T12 | P2-d | 5 个变体各自断言降级 | ⬜ | +| T17 | 环境自检命令 | `src/cli/selfCheck.ts` | T05、T13 | P2-e | 快照含版本/协议/能力/状态/已改设置 | ⬜ | + +### P3 缓存统计与两级清理(方案 §3.4) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T18 | 缓存聚合(纯函数) | `src/cli/cache.ts` | T07 | P3-a | 空/单条/incomplete/大数/缺 accessed 各有用例 | ⬜ | +| T19 | 项目产物体积 | `src/cli/artifacts.ts` | T07 | P3-a | 临时目录树用例;越界与权限失败降级 | ⬜ | +| T20 | 缓存 TreeView | `src/views/cacheView.ts` | T18、T19、T33 | P3-b | 节点由纯函数产出,可单测 | ⬜ | +| T21 | 缓存面板 webview | `src/views/cachePanel.ts`、`media/*` | T18、T33 | P3-c | HTML 由纯函数生成;CSP 无外链;预算模拟器有单测 | ⬜ | +| T22 | 清理命令族 | `src/cli/clean.ts` | T18、T19 | P3-a | 五级危险分级各有参数断言 | ⬜ | +| T23 | 清理确认流 | `src/views/*` + `src/cli/clean.ts` | T22 | P3-d | L1 与 `--all` 走两步确认;`--dry-run` 预演先行 | ⬜ | + +### P4 `mcpp.toml` 编辑体验(方案 §3.3) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T24 | schema 快照 + 生成 | `data/toml-schema.json`、`tools/generate-toml-schema.mjs`、`src/toml/schema.ts` | T03 | P4-a | 生成物稳定;读不到源时**失败而非产出空表** | ⬜ | +| T25 | 键与枚举值补全 | `src/toml/completion.ts` | T24 | P4-b | 段/键/值三类上下文各有断言 | ⬜ | +| T26 | 悬停 | `src/toml/hover.ts` | T24 | P4-b | 段头、键、legacy 键各有断言 | ⬜ | +| T27 | 诊断 | `src/toml/diagnostics.ts` | T24 | P4-c | 7 条规则正反例;严重度可配 | ⬜ | +| T28 | 跳转 | `src/toml/navigation.ts` | T24 | P4-c | `workspace`/`path`/`features` 三类 | ⬜ | +| T29 | 依赖版本补全(默认关) | `src/cli/search.ts` | T25 | P4-d | 解析样本;超时/离线降级为空 | ⬜ | + +### P5 `build.mcpp` 智能(方案 §3.2) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T30 | API 快照 + 生成 | `data/buildscript-api.json`、`tools/generate-buildscript-api.mjs` | T03 | P5-a | 31 条指令 / 5 role / 协议 15;源缺失时失败 | ⬜ | +| T31 | 符号表与模块清单 | `src/buildscript/{api,modules}.ts` | T30 | P5-b | `std`/`mcpp.*` 被判为已知模块 | ⬜ | +| T32 | provider 集 | `src/buildscript/providers.ts` | T31、T24 | P5-c | 7 条静态诊断正反例;补全/hover/片段 | ⬜ | + +### P6 视图、视觉与格式化 + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T33 | 主题与格式化 | `src/views/theme.ts`、`src/util/format.ts`、`src/util/text.ts` | T02 | P6-a | 字节/时长/相对时间/截断各有断言 | ⬜ | +| T34 | 工程视图与状态栏 | `src/views/{projectView,status}.ts` | T33、T06 | P6-b | 状态栏三态由纯函数产出 | ⬜ | +| T35 | contributions | `package.json` viewsContainers/views/colors/menus | T20、T15、T34 | P6-c | `artifacts` 测试断言结构 | ⬜ | +| T36 | 命令清单落地 | `src/commands/ids.ts` + 注册 | T20–T23、T15、T37 | P6-d | 每个 ID 在 package.json 与会话中一致 | ⬜ | + +### P7 配置面板(方案 §4.3) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T37 | 配置面板 webview | `src/config/panel.ts`、`media/config.css/js` | T03、T33 | P7-a | HTML 由纯函数生成;scope/来源/生效时机都渲染 | ⬜ | +| T38 | 面板设置写入与边界提示 | `src/config/access.ts` | T37 | P7-b | 不写 `mcppls.*`;资源型设置带 URI | ⬜ | + +### P8 CI、文档与交付 + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T39 | CI 体系 | `.github/workflows/ci.yml`(漂移 job、l10n/check-config 门禁、linux+macos matrix、VSIX 依赖版本断言) | T02、T03、T16 | P8-a | 本地可复跑的等价命令写进 docs | ⬜ | +| T40 | 用户文档 | `README.md`(en)、`README.zh-CN.md`、`docs/*.md`(8 篇) | T02–T38 | P8-b | README 的每个链接都存在(测试断言) | ⬜ | +| T41 | CHANGELOG(英) | `CHANGELOG.md` | T04 | P8-c | 0.5.0 段含迁移与新增 | ⬜ | +| T42 | 本地验收 profile | `tools/dev-profile.mjs`(隔离 extensions/user-data + 安装 VSIX + 打印启动命令) | T39 | P8-d | 脚本自身可跑通并打印可复制的命令 | ⬜ | +| T43 | 自我 review | `.agents/reviews/` 下的 v0.5.0 review | 全部 | P8-e | 每条风险有对应的测试或文档 | ⬜ | + +--- + +## 3. 依赖图(关键路径) + +``` +T01 ─┬─ T02 ─┬─ T11 ─┬─ T12 ─┐ + │ │ └─ T13 ─┼─ T14 ─┬─ T15 ─┐ + │ │ │ └─ T16 │ + │ └─ T33 ─────────┼────────────────┼─ T35 ─ T36 + ├─ T03 ─┬─ T05 ─ T06 ───┘ │ + │ ├─ T07 ─┬─ T08 │ + │ │ └─ T18 ─┬─ T20 ──────────┤ + │ │ ├─ T21 ──────────┤ + │ │ └─ T22 ─ T23 ────┤ + │ ├─ T09 │ + │ ├─ T10 │ + │ ├─ T24 ─┬─ T25 ─ T29 ───────────┤ + │ │ ├─ T26 │ + │ │ ├─ T27 │ + │ │ └─ T28 │ + │ ├─ T30 ─ T31 ─ T32 ─────────────┤ + │ ├─ T37 ─ T38 ───────────────────┤ + │ └─ T17 ──────────────────────────┘ + └─ T04 ─ T41 + T34 ─┘ + T39 ─ T42 + T40, T43 在最后 +``` + +**关键路径**:`T01 → T03 → T24/T30 → T32 → T35 → T36 → T39 → T40 → T43`。 +`T02`(i18n)必须在任何"写文案"的任务之前完成,否则要写两遍。 + +--- + +## 4. 提交策略(单 PR、多 commit) + +| # | commit | 内容 | +|---|---|---| +| 1 | `refactor: 目录按用途分组…` | ✅ T01 | +| 2 | `feat(i18n): 中英文案跟随 VS Code` | T02 | +| 3 | `feat(config): 统一配置注册表与一致性门禁` | T03 | +| 4 | `feat(cli): mcpp 协议探测、超时与错误分层` | T05–T10 | +| 5 | `feat(mcppls): 能力探测与 C++ Modules 状态视图` | T11–T17 | +| 6 | `feat(cache): 缓存统计与两级清理` | T18–T23 | +| 7 | `feat(toml): mcpp.toml 键/值补全、悬停、诊断与跳转` | T24–T29 | +| 8 | `feat(buildscript): build.mcpp 智能` | T30–T32 | +| 9 | `feat(views): 视图容器、状态栏、配色与配置面板` | T33–T38 | +| 10 | `ci+docs: CI 门禁、双语文档、0.5.0 迁移说明` | T39–T42 | +| 11 | `docs(review): 0.5.0 自我 review` | T43 | + +每个 commit 必须保持 `npm test` 绿;`npm run test:e2e` 在 CI 与本地(有显示时)绿。 + +--- + +## 5. 验证矩阵 + +| 层 | 命令 | 覆盖 | +|---|---|---| +| 类型 | `npm run compile` | 全部 | +| 单测 | `npm test` | 纯逻辑:协议、能力、状态、缓存聚合、schema、诊断、格式化、生成物一致性 | +| 契约 | `npm test`(含 `test/toml/contract.test.ts`) | 真实 mcpp 的 `mcpp.toml` 段清单(无 mcpp 时跳过) | +| 生成物 | `tools/check-config.mjs`、`tools/l10n-check.mjs`、漂移 job | registry ↔ package.json ↔ nls ↔ docs | +| E2E | `npm run test:e2e` | 激活、命令注册、5 个 mcppls stub 变体、缓存视图节点、清理走 dry-run | +| 打包 | `npm run package` + `unzip -t` | VSIX 结构与依赖声明 | +| 人工 | `tools/dev-profile.mjs` 产出的隔离 profile | 用户在真实 VS Code 里验收 | + +--- + +## 6. 无感升级检查表(发布前逐条确认) + +- [ ] 0.4.x 的全部命令 ID 仍然存在(旧的转发命令与 `mcpp.clean` 保留为弃用别名) +- [ ] 0.4.x 的设置键仍然存在(`mcpp.path`、`mcpp.tomlCompletion`、3 个弃用设置) +- [ ] 新增设置默认值全部"不打扰"(新视图不影响既有布局的最小化:面板与视图都可关) +- [ ] `build` 之后的行为与 0.4.x 一致(刷新语言服务),只是优先用轻量重载 +- [ ] 未受信任工作区的行为不放松 +- [ ] CHANGELOG 写清"什么变了 / 什么没变 / 需要手动做什么" + +--- + +## 7. 进展(round 1,2026-10-02) + +分支 `feat/plugin-optimisation-v0.5.0`,`npm test` 390 通过,三个生成物/一致性门禁全绿。 + +| 任务 | 状态 | 产物 | +|---|---|---| +| T01 目录重构 | ✅ | `9 个提交之一`;179→390 测试 | +| T02 i18n 机制 | ✅ | `data/i18n/zh-cn.json`、`l10n/`(生成)、`package.nls*.json`、`src/i18n/{t,translate}.ts`、`tools/{generate-l10n,l10n-check}.mjs`;`package.json` 用户可见字符串全部 `%key%` | +| T03 配置模块 | ✅ | `data/config-registry.json`(64 项/10 组/29 公开)、`src/config/{registry,validate,access,migrate,presets}.ts`、`tools/{check-config,generate-settings-docs}.mjs`、`docs/settings.md`(生成) | +| T05 协议探测 | ✅ | `src/cli/protocol.ts`(已对真实 mcpp 2026.9.30.2 交叉验证) | +| T08 错误分层 | ✅ | `src/cli/errors.ts`(SPEC-003,含 101 仅 mcpp run / 4 / 127) | +| T11–T14 mcppls | ✅ | `src/mcppls/{contract,capabilities,state,bridge,messages}.ts`;15 项能力、候选链、惰性分类、状态白名单、结构化结果 | +| T18/T19 缓存聚合 | ✅ | `src/cli/{cache,artifacts}.ts` | +| T22 清理计划 | ✅(表) | `src/cli/clean.ts`(五级危险、预演、二次确认);命令接线待做 | +| T24/T27 TOML | ✅ | `data/toml-schema.json`(30 段/97 键)、`src/toml/{schema,diagnostics}.ts`、`tools/generate-toml-schema.mjs` | +| T30–T32 buildscript | ✅(分析层) | `data/buildscript-api.json`(31 指令/5 role/协议 15)、`src/buildscript/{api,modules,analysis,providers}.ts` | +| T33 格式化 | ✅ | `src/util/{format,text}.ts` | +| 视图模型 | ✅(模型层) | `src/views/models.ts`(三棵树,标签即 key)、`src/projects/summary.ts` | + +**下一轮的起点**:`package.json` 的 contributions(commands / viewsContainers / views / colors / +menus / activationEvents)、`src/views/` 的 provider 与命令实现、`src/extension.ts` 接线、 +`tools/dev-profile.mjs`、CI 工作流、README 双语与 `docs/*`、0.5.0 版本与 CHANGELOG。 + +**上游反馈已记录**:生成脚本对照 mcpp 源码时发现方案文档里两个不存在的键名 +(`[profile.].opt_level` 实为 `opt`;`bidi_schedule` 实为 `bmi_schedule`),已修正。 + +--- + +## 8. Round 2 审计出的缺口(下一轮的输入) + +文档子代理在写 `docs/` 时逐项核对了代码,发现"设置已声明但无人读取"的地方。这不是文档 +缺陷,而是**功能缺陷**:声明了却不生效,比没有这个设置更糟。按严重度排列: + +| # | 缺口 | 证据 | 处理 | +|---|---|---|---| +| G1 | `mcpp.toml` 的 hover 与跳转不存在 | `src/toml/providers.ts` 只注册补全与诊断;`mcpp.toml.hover`/`navigation` 无人读 | 实现 `src/toml/hover.ts` + `src/toml/navigation.ts`(schema 已有键与文档锚点) | +| G2 | 键/枚举值补全不存在 | `src/toml/completion.ts` 用的是手写 25 条段头表,**没有读** `data/toml-schema.json` | 让补全读 schema(键 + 枚举 + legacy 提示) | +| G3 | 依赖版本补全不存在 | `mcpp.toml.indexCompletion*` 无人读,`mcpp search` 无人调用 | 实现 `src/cli/search.ts` + 接入补全 | +| G4 | 缓存面板不是 webview | `mcpp.showCachePanel` 打开只读 Markdown 预览 | 实现 `src/views/cachePanelHtml.ts` + `cachePanel.ts`(叠条/年龄分布/TopN/预算模拟器) | +| G5 | 未贡献任何快捷键 | `contributes.keybindings` 缺失,方案 §3.5 承诺了 ctrl+alt+b/r/t/m/, | 加 keybindings | +| G6 | `mcpp.cache.gc.confirmAboveGiB` 无人读 | gc 超过阈值不会追加确认 | 接入 | +| G7 | pre-v1 遗留缓存节点永不显示 | `state.legacyBytes` 从未赋值 | 从 `cache dir` 的 legacy 路径估算体积并赋值 | +| G8 | 大量设置无人读 | 64 项中仅 16 项被生产代码读取 | 逐项接线:`mcpp.task.*`、`mcpp.languageService.*`、`mcpp.views.*.show`、`mcpp.ui.*`、`mcpp.runtime.*`、`mcpp.log.level`、`mcpp.buildScript.intelligence/snippets/imports.knownModules`、`mcpp.cache.statusBar/warnAboveGiB/autoRefreshSeconds/showLegacy` | +| G9 | 环境自检的缓存段永远是"not read" | `showSelfCheck` 没有传 `cache` | 采集后传入 | +| G10 | `src/cli/errors.ts` 与 `src/config/migrate.ts` 没有生产调用者 | 退出码分层与旧键迁移提示都没接 | 接入错误提示与首次激活的迁移提示 | +| G11 | i18n 仍有硬编码中文 | `src/cli/controller.ts`、`src/extension.ts` 多处直接写中文 | 全部改走 `t()`,并补 zh 条目 | +| G12 | `VERIFIED_MCPPLS_RANGE` 无人读 | 没有版本提示 | 在会话头/自检里提示(不作为门禁) | +| G13 | `mcpp.ui.numberFormat` 无人读 | `formatBytes` 恒为二进制单位 | 接入 | + +**验收方式**:新增一条单测,遍历 `data/config-registry.json`,断言每个设置键在 `src/` +中至少被"读取一次"(用一张显式的"由谁读取"映射表,而不是正则扫描),缺一项即失败。 +这条测试是这一轮的产出物之一——它把"声明了就要生效"变成可执行的约束。 + +--- + +## 9. 进展(round 2,2026-10-02) + +`npm test` 420 通过;`check-config` / `l10n-check` / `check-generators` 全绿; +VSIX 打包 69 文件 180 KB;本地隔离 profile 已就绪(含 mcppls 0.0.9 与一个可用的 mcpp 工程)。 + +| 任务 | 状态 | 说明 | +|---|---|---| +| T15/T20/T21/T34–T38 视图与命令 | ✅ | 三棵视图、42 个命令、视图容器、两个颜色 ID、配置面板 | +| T16 e2e 五变体 | ✅(代码)/ ⚠️(本地未跑通) | 变体生成 + 每变体一次隔离 Extension Host;本地因无法下载固定版 VS Code 未完成 | +| T39 CI | ⬜ | 工作流尚未更新 | +| T40 文档 | ✅ | README 英/中 + 7 篇 `docs/`,52 条链接全部解析 | +| T41 CHANGELOG | ✅ | 0.5.0 英文段 | +| T42 本地 profile | ✅ | `tools/dev-profile.mjs` | +| G1–G13(§8 审计缺口) | ⬜ | 下一轮的输入 | + +**本地 e2e 的限制**:`@vscode/test-electron` 需要下载 135 MB 的 1.91 稳定版,本机网络在 +下载中途反复中断;改用已安装的 VS Code(`MCPP_E2E_CODE`,本轮新增)时 Extension Host +以 0 退出但没有执行套件。因此 **e2e 的结论仍以 CI 为准**(CI 里有 xvfb 与固定版本), +本地只验证到"编译通过、变体生成正确、命令面完整"。这一条必须在本轮报告里如实说明。 + +--- + +## 10. 进展(round 3,2026-10-02) + +`npm test` **471 通过**(round 2 为 420);三个门禁全绿;CI 重写。 + +| 项 | 状态 | 说明 | +|---|---|---| +| G1 `mcpp.toml` hover/跳转 | ✅ | `src/toml/{hover,navigation}.ts` + `providers.ts` 注册,两个开关生效 | +| G2 键/枚举补全读 schema | ✅ | `completion.ts` 改为快照驱动;快照没有但 mcpp 接受的段(如 `[workspace.dependencies]`)由手写清单补齐 | +| G3 依赖版本补全 | 🟡 | `src/cli/search.ts` 解析器已写并测试;尚未接进补全(唯一一处解析人类输出,注释已说明) | +| G4 缓存 webview 面板 | ✅ | `src/views/cachePanel{,Html}.ts` + `media/cache.css`,叠条/年龄分布/TopN/预算模拟器,已替换原来的 Markdown 预览 | +| G5 快捷键 | ⬜ | 仍未贡献 keybindings | +| G6 gc 阈值与遗留缓存节点 | 🟡 | `warnAboveGiB` 已在面板生效;`confirmAboveGiB` 与 `legacyBytes` 未接 | +| G8 设置接线 | 🟡 | 已接:task 参数四键、状态栏开关、菜单开关、并发作用域、超时、输出缓冲、语言服务刷新模式、视图可见性、启动自检、buildscript 总开关、数字格式。其余见下面的例外表 | +| G11 i18n | 🟡 | 新增一条"硬编码中文不得增长"的门禁(当前 162 行封顶) | +| G13 数字格式 | ✅ | `mcpp.ui.numberFormat` 已作用于缓存状态栏与面板 | +| T39 CI | ✅ | 见下 | + +### 新增的两条"不得倒退"门禁 + +- `test/config/wiring.test.ts`:注册表里每个设置必须被代码读取,或在 `EXCEPTIONS` 里 + 写明理由并指向 §8 的缺口编号。例外表当前 **40** 条,只允许变小(有一条测试专门断言这点)。 + 已弃用的 4 个设置反过来断言"绝不被读取"。 +- `test/i18n/hardcoded.test.ts`:`src/` 里未走 `t()` 的中文行数封顶 162,只允许下降。 + +### CI(`.github/workflows/ci.yml`) + +| job | 内容 | +|---|---| +| `gates` | ubuntu + macos-14 矩阵跑 `npm test`(注册表/i18n/生成物漂移/单测) | +| `generated-drift` | 检出 mcpp 仓库并设置 `MCPP_REPO`,让快照漂移门禁真正执行;另断言 `docs/settings.md` 无 diff | +| `package` | 打包、`unzip -t`、清单承诺校验(依赖未变、无第二个语言客户端、无 `onCommand:*`)、VSIX 必须包含 l10n/nls/registry/css、上传产物 | +| `extension-host-e2e` | `xvfb-run` 下跑全部五种 mcppls 变体 | +| `isolated-install` | 把 VSIX 装进私有 profile,让 VS Code 解析依赖并断言 `sunrisepeak.mcpp-language-server@<版本>` 真实存在 | + +--- + +## 11. 进展(round 4,2026-10-02) + +| 项 | 状态 | 说明 | +|---|---|---| +| G5 快捷键 | ✅ | ctrl/cmd+alt + b/r/t/l/m,作用域 `mcpp.inProject`,并有断言 | +| G8 设置接线 | 🟡 | 三个并行工作流(controller 组 / views+mcppls 组 / buildscript+toml 组)在跑 | +| G10 `errors.ts`、`migrate.ts` | ✅ | 重命名提示接入激活路径(每工作区一次,旧键保留);退出码指引由 controller 组接入 | +| G12 版本提示 | ✅ | 低于 `VERIFIED_MCPPLS_RANGE` 时在输出频道写一句,仅提示不做门禁 | +| 视图可见性 | ✅ | `setContext` + `contributes.views.*.when` | +| 例外表 | 🟡 | 31 条,新增一条'已接线的设置必须从例外表删除'的断言,防止例外表腐烂 | + +--- + +## 12. 进展(round 5,2026-10-02) + +| 项 | 状态 | 说明 | +|---|---|---| +| 设置文档中英对照 | ✅ | `gen:docs` 直接复用 `package.nls.zh-cn.json` 的描述,64 个设置中英并列,不产生第三份译文 | +| 发布流程守卫 | ✅ | `release.yml` 与 CI 同样校验 VSIX 体积与目录(那个 64 MB 的事故若发生在发布流程里会直接进 Release 附件) | +| 死代码 | ✅ | 除入口 `src/extension.ts` 外,`src/` 已无未被引用的模块 | +| G11 i18n(162 行硬编码中文) | 🟡 | 后台工作流正在清零,完成后需合并译文并把门禁上限降到实际值 | +| Windows CI | ⬜ | 仍未覆盖;macOS 也在矩阵里,但同样未经本机验证(本机只有 Linux) | + +### round 5 收尾 + +| 项 | 状态 | +|---|---| +| G11 i18n | ✅ **162 → 0**,门禁改为断言 0;`t.ts` 真正变成 vscode-free(import type + 惰性 require) | +| 架构门禁 | ✅ 新增 `test/architecture.test.ts`:41 个纯模块既不得 `import vscode`,也必须能在无编辑器进程中加载 | +| 设置文档 | ✅ 64 个设置中英对照 | +| 发布守卫 | ✅ `release.yml` 与 CI 同样校验体积与目录 | +| Windows CI | ⬜ 已知缺口(本机只有 Linux,macOS 同样未经本机验证) | +| e2e / CI 本机运行 | ⬜ 环境限制,结论以 CI 为准 | diff --git a/.agents/docs/archive/2026-10-02-plugin-optimisation-plan.md b/.agents/docs/archive/2026-10-02-plugin-optimisation-plan.md new file mode 100644 index 0000000..df0e0bf --- /dev/null +++ b/.agents/docs/archive/2026-10-02-plugin-optimisation-plan.md @@ -0,0 +1,1115 @@ +# mcpp-vscode 插件优化方案(v4,待评审) + +- 日期:2026-10-02 +- 基线:`mcpp-vscode` `main@20f1076`(0.4.0) +- **硬约束:不改动 `mcpp` 与 `mcppls` 的任何代码。** 全部改动落在 mcpp-vscode 仓库内; + 对上游只读(贡献者机器上读源码生成快照,运行期不新增依赖)。 +- 前置分析:`.agents/reviews/2026-10-02-mcpp-vscode-architecture-and-mcppls-dependency-review.md` +- 版本历史:v1 首版 → v2 用真实 mcppls payload 实测后重写 `build.mcpp` 一节、缓存收敛为两级、 + 补 i18n / TOML 编辑 / 配色 / UI 对比 → v3 定稿评审意见、新增主线 D「统一配置模块与配置面板」、 + 补完整设置清单、去掉模式 B 的实现(仅文档备注)、加自我 review → + **v4 新增 §3.9「把 mcppls 的状态与管理并入 mcpp」、`cache clean --all` 的确认分级、 + 第一版公开设置清单(§4.6)、mcppls 状态读取的防御式设计与两个新 e2e 夹具。** + +--- + +## 0. 已确认的决策 + +| # | 决策 | 落点 | +|---|---|---| +| 1 | 目录树重构 + 两提交执行;UI 采用 **U2**(视图容器 + 2 TreeView + 1 Webview 面板) | §1.2、§3.6 | +| 2 | README 英文主 + `README.zh-CN.md`;`package.json.description` 与 `CHANGELOG.md` 改英文 | §1.3 | +| 3 | `docs/superpowers/` → `.agents/superpowers/` | §1.4 | +| 4/5 | `build.mcpp` **只做模式 A(隔离 + 自研 mcpp:: 智能)**;模式 B 不实现,仅在文档备注 | §3.2、附录 D | +| 6 | 清理收敛为**两级**:`mcpp clean` / `mcpp clean --stale` | §3.4 | +| 7 | i18n 第一版:`package.nls.*` 全量 + 关键流程文案;运行时文案渐进迁移 | §3.7 | +| 8 | 激活面收敛(去掉 `onLanguage:cpp`) | §3.1 | +| 9 | 查询类命令默认超时 30 s(`0` = 不限) | §3.1 | +| 10 | 配色 / 可视化 / `build.mcpp` / `mcpp.toml` 编辑 / mcpp 功能都要完整设计 | §3.3–§3.9 | +| 11 | `mcpp.cache.staleDays` 默认 **3** 天 | §3.4 | +| 12 | 全局缓存**暴露更多功能**,并配更好的可视化 | §3.4.3 | +| 13 | `mcpp.toml` 未知段/未知键的默认严重度 = **warning** | §3.3 | +| 14 | 依赖版本补全**进方案**(默认关,唯一的"解析人类输出"例外) | §3.3.2 | +| 15 | **凡可配置的功能都要有设置项,并由统一的配置模块管理 + 配置面板操作** | **§4(主线 D)** | +| 16 | 第一版**公开发布 29 项设置**,其余进入 `advanced` 默认隐藏 | §4.4、§4.6 | +| 17 | **保留** `mcpp.ui.language` 手动覆盖,并在面板与 docs 明确标注"部分界面不受影响" | §3.7 | +| 18 | **保留**依赖版本补全(`mcpp.toml.indexCompletion`) | §3.3.2 | +| 19 | 全局缓存**暴露 `cache clean --all`**,但点击必须走**确认提示** | §3.4.3、§3.4.4 | +| 20 | 配置面板定位:**不取代**原生设置页,只做集中 / 解释 / 预设 / 边界提示 | §4.3 | +| 21 | **把 mcppls 的核心状态与管理并入 mcpp-vscode**(LSP 状态、引擎、问题、缓存重置、诊断包…) | **§3.9** | + +--- + +## 1. 主线 A:仓库结构与文档 + +### 1.1 目标 + +1. `src/` 从"15 个文件平铺"变成"按用途分组",`test/` 镜像; +2. 新增的 UI、缓存、build 脚本、i18n、config 五块有明确归属; +3. `README.md` ≤ 120 行、英文,`README.zh-CN.md` 同结构; +4. 用户文档(`docs/`)、贡献者文档(`.agents/docs/`)、过程文档(`.agents/superpowers/`)边界明确。 + +### 1.2 目录树 + +``` +src/ + extension.ts # 唯一装配点:activate/deactivate + commands/{ids.ts,menu.ts} # 命令 ID 与快捷菜单清单(纯数据) + config/ # 【新】主线 D:统一配置模块 + registry.ts # 读 data/config-registry.json,导出类型化条目 + access.ts # 类型安全读写 + 生效值来源(default/user/workspace/folder) + validate.ts # 枚举/范围/正则校验,非法值回退默认并提示一次 + migrate.ts # 旧键 → 新键的 alias 与一次性迁移提示 + panel.ts # 配置面板 webview + cli/ # mcpp CLI 适配层 + process.ts # 原 process.ts(+ 默认超时、输出截断) + protocol.ts # 【新】--protocol-version 探测与信封解析 + toolchain.ts # 原 cli.ts(JSON 优先,文本 fallback) + controller.ts # 原 cliController.ts + tasks.ts # 原 tasks.ts + newProject.ts # 原 newProject.ts + artifacts.ts # 【新】target/ 体积估算(只读) + cache.ts # 【新】cache/clean 的调用与聚合(纯函数优先) + search.ts # 【新】`mcpp search` 解析(依赖版本补全,默认关) + projects/{discovery.ts,context.ts} + toml/{parser.ts,schema.ts,completion.ts,hover.ts,diagnostics.ts,navigation.ts} + buildscript/{api.ts,modules.ts,providers.ts} + mcppls/{contract.ts,capabilities.ts,state.ts,bridge.ts} + views/{status.ts,projectView.ts,cacheView.ts,cachePanel.ts,languageServerView.ts,theme.ts} + i18n/t.ts # 【新】文案解析(auto → vscode.l10n;否则读自带表) + util/{text.ts,format.ts} + +data/ + config-registry.json # 【新】主线 D 的单一事实源(提交) + buildscript-api.json # 【新】mcpp 构建脚本 API 快照(提交) + toml-schema.json # 【新】mcpp.toml 段/键/枚举快照(提交) + i18n/{en.json,zh-cn.json} # 【新】运行时文案的单一来源(提交) +media/ # webview 静态资源(CSS/SVG/JS),CSP 安全、无网络 +l10n/ # 由 data/i18n 生成,勿手改 + bundle.l10n.json + bundle.l10n.zh-cn.json +package.nls.json # 【新】package.json 的英文文案(默认) +package.nls.zh-cn.json # 【新】package.json 的中文文案 +tools/ + generate-buildscript-api.mjs # 从 mcpp 仓库生成 data/buildscript-api.json + generate-toml-schema.mjs # 从 mcpp docs/04 + SPEC-004 生成 data/toml-schema.json + generate-l10n.mjs # data/i18n/*.json → l10n/bundle.l10n*.json + check-config.mjs # 【新】registry ↔ package.json 语义一致性门禁 + l10n-check.mjs # 文案 key 与 bundle 覆盖门禁 +test/ # 与 src/ 镜像 + e2e/fixtures/{fake-mcpp.js, mcppls-stub/, mcppls-stub-partial/, mcppls-stub-renamed/, project/} +``` + +**必须一起改的地方**: + +| 位置 | 影响 | +|---|---| +| `tsconfig.json` | 增加 `resolveJsonModule: true`(要 `import` `data/*.json`);`rootDir`/`outDir` 不变 | +| `package.json` `main` | 不变(`./dist/src/extension.js`) | +| `package.json` `test` | `node --test dist/test/*.test.js` → `node --test "dist/test/**/*.test.js"` | +| `test/**` 的 `import "../src/xxx"` | 全部改为新路径 | +| `test/artifacts.test.ts` | 路径 + 把"源码文本断言"改成行为断言 | +| `.vscodeignore` | **排除** `tools/**`、`docs/**`、`.agents/**`、`data/**`(JSON 在编译期被 `import`,产物在 `dist/data/`);**必须保留** `media/**`、`l10n/**`、`package.nls*.json`、`dist/**` | +| `docs/superpowers/**` | `git mv` → `.agents/superpowers/{plans,specs}/` | + +> **执行建议**:提交 1 = `git mv` + import 路径替换 + 测试 glob(零行为变化,门禁 +> `npm test && npm run test:e2e`);提交 2 起才加新文件与功能。 + +### 1.3 README 双语与瘦身 + +`README.md`(英文,≤120 行)+ `README.zh-CN.md`(中文,同结构),顶部语言切换。 +章节:What this is → Why → Install(含平台矩阵)→ Quick start 60s → Features(6 条,各一行) +→ Commands(表)→ Settings(表,链接到 `docs/settings.md`)→ Editing `mcpp.toml` / `build.mcpp` +(各 3 行 + 链接)→ Troubleshooting(5 条一行 + 链接)→ Develop → License。 + +详情拆到 `docs/`:`architecture.md`、`commands.md`、`settings.md`、`mcpp-toml.md`、 +`build-script.md`、`cache.md`、`troubleshooting.md`、`compatibility.md`。 +`package.json` 的 `displayName` / `description` 改为 `%key%` 占位(见 §3.7)。 + +### 1.4 目录边界 + +| 目录 | 读者 | 进版本库 | 内容 | +|---|---|---|---| +| `docs/` | 用户 | ✅ | 用法、设置、排错、兼容性 | +| `.agents/docs/` | 贡献者/agent | ✅ | 设计、契约、决策、本方案 | +| `.agents/superpowers/` | 过程 | ✅ | 历史 plans/specs(原 `docs/superpowers/`) | +| `.agents/reviews/` | 贡献者 | ❌ 已 gitignore | 分析报告、评审记录 | + +--- + +## 2. 主线 B:mcppls 依赖的"永不折断"设计 + +### 2.1 能力模型 + +`src/mcppls/contract.ts` 只有数据。**两类能力**:`forward`(转发 mcppls 的命令)与 +`readState`(只读 mcppls 的状态,见 §3.9)。 + +```ts +export interface Capability { + key: string; // 稳定的内部名,用作日志/设置键 + kind: "forward" | "readState"; + titleKey: string; // 文案 key,走 §4 的 i18n + commands: string[]; // 候选链,按优先级(readState 为空) + required: boolean; // 全部为 false + /** 使用它需要多重的确认。 */ + danger: "none" | "confirm" | "destructive"; + degradedHintKey: string; +} + +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; +export const VERIFIED_MCPPLS_RANGE = ">=0.0.4"; // 实测 0.0.9 后收紧 + +export const CAPABILITIES: readonly Capability[] = [ + // ── 第一批:v1 已有 ── + { key: "refresh", kind: "forward", commands: ["mcppls.reloadBuildDescription", "mcppls.restartServer"], danger: "none" }, + { key: "selectContext", kind: "forward", commands: ["mcppls.selectContext"], danger: "none" }, + { key: "moduleGraph", kind: "forward", commands: ["mcppls.showModuleGraph"], danger: "none" }, + { key: "logs", kind: "forward", commands: ["mcppls.showLogs"], danger: "none" }, + // ── 第二批:§3.9 新增 ── + { key: "readState", kind: "readState", commands: [], danger: "none" }, + { key: "restartEngine", kind: "forward", commands: ["mcppls.restartClangd"], danger: "confirm" }, + { key: "resetCache", kind: "forward", commands: ["mcppls.resetWorkspaceCache"], danger: "destructive" }, + { key: "report", kind: "forward", commands: ["mcppls.collectReport"], danger: "none" }, + { key: "diagnosticBundle", kind: "forward", commands: ["mcppls.exportDiagnosticBundle"], danger: "none" }, + { key: "conflicts", kind: "forward", commands: ["mcppls.turnOffOtherCppFeatures", "mcppls.restoreOtherCppFeatures"], danger: "confirm" }, + { key: "runBuildTool", kind: "forward", commands: ["mcppls.runBuildToolInTerminal"], danger: "confirm" }, + { key: "enableInWorkspace", kind: "forward", commands: ["mcppls.turnOnInWorkspace", "mcppls.turnOffInWorkspace"], danger: "confirm" }, + { key: "installTools", kind: "forward", commands: ["mcppls.installCommandLineTools"], danger: "confirm" }, + { key: "review", kind: "forward", commands: ["mcppls.review.run", "mcppls.review.clear"], danger: "none" }, +]; +``` + +`refresh` 用候选链:上游哪天注册了 `reloadBuildDescription`,本插件自动升级,**无需改代码**。 +`mcpp.languageService.refreshAfterBuild`(§4)可把这条链强制成 `reload` / `restart` / `off`。 + +**`review.run` / `review.clear` 是服务器广告的命令**(由 `vscode-languageclient` 注册), +只在服务器运行后存在;能力探测按"调用失败即缺失"处理,无需特判。它们还受 +`mcppls.ai.enabled` 约束 —— 本插件只**转发**,不读、不写该设置。 + +### 2.2 探测:静态声明 + 惰性运行期分类 + +- **不要**在激活时逐个调用探测:多数 UI 命令都有副作用(弹 QuickPick、开面板、清缓存)。 +- **不要**把 `commands.getCommands()` 当唯一判据(是否包含未激活的贡献命令无保证)。 +- **`readState` 特殊**:它不靠命令,靠 `extension.exports`(见 §3.9),因此有自己的探测方式 + (形状探测 + try/catch),与命令能力分开。 +- **静态**:`getExtension(id)?.packageJSON.contributes.commands` —— 零副作用,激活时 + + `extensions.onDidChange` 刷新。读不到只**置灰并带说明**,不隐藏。 +- **运行期**:首次真正使用时调用一次并分类错误(`command '…' not found` → `missing`)。 + `missing` 才从菜单隐藏,并写一行日志 + 一次性提示(受 + `mcpp.languageService.notifyOnDegraded` 控制)。 + +### 2.3 降级矩阵与不变式 + +| 能力 | 缺失时 | 用户看到 | +|---|---|---| +| `refresh` | build 照常成功,不重试、不弹错 | 输出频道一行;成功提示照常 | +| `readState` | §3.9 的「C++ Modules」视图只显示版本/激活/启用 + 我们自己的刷新历史 + 转发按钮 | 状态区块显示"已安装的 mcppls 未暴露状态" | +| 其余 `forward` | 菜单/视图项隐藏(静态缺失时置灰) | 一次性 info + "查看兼容性"按钮 | + +**不变式(写成测试)**:① mcppls 的任何失败都不能把成功的 `mcpp build` 变成失败; +② 不能让其它命令不可用;③ `deactivate()` 不留悬挂 Promise; +④ `readState` 抛异常或返回未知形状时,**视图仍然可用**(只剩降级内容)。 + +### 2.4 版本只用于提示 + +`getExtension(id)?.packageJSON.version` → 会话头 + 环境自检 + 低于已验证区间的一次性提示。 +**不按版本开关功能**(版本号预测不了改名,能力探测才是判据)。 + +### 2.5 测试 + +五个 e2e 夹具:`mcppls-stub`(全部命令 + 同形状 `exports`)/ `mcppls-stub-partial` +(只有 `restartServer`)/ `mcppls-stub-renamed`(改名)/ **`mcppls-stub-noapi`**(不导出 API)/ +**`mcppls-stub-throwing`**(`lastStatus()` 抛异常、返回未知 `state`)。 +断言:降级、菜单隐藏、"build 永不因 mcppls 失败而失败"、**视图在无 API/异常时仍可用**。 +另加纯单测对 `CAPABILITIES` 与 §3.9 的状态适配层做表驱动形态断言。 + +### 2.6 新命令 `mcpp: 环境自检` + +输出可复制快照:扩展/VS Code/平台、工作区信任与根数、工程根、mcpp 路径+版本+协议+kinds、 +mcppls 版本 + 全部能力状态(`forward` 与 `readState` 分开列)、**mcppls 的状态摘要** +(`state` / `project.source` / `engine` / `issues` 的 code 列表,见 §3.9)、上次刷新时间与结果、 +缓存规模、**所有被改过的设置**(来自 §4 的 registry)、最近错误。把 90% 支持问题一次问清, +且不需要任何上游改动。 + +--- + +## 3. 主线 C:稳定性、编辑体验、缓存、交互与视觉 + +### 3.1 稳定性基座 + +| 项 | 目标 | +|---|---| +| 输出协议 | JSON 优先:`--protocol-version` 判协议 → `toolchain list --format json`;文本解析保留为 legacy 分支并单独测试 | +| 超时 | 查询类默认 30 s(`mcpp.runtime.timeoutSeconds`,`0` = 不限);`build/run/test/install` 走任务终端不设硬上限;`cache gc/prune` 300 s | +| 输出上限 | 保留 16 MiB `maxBuffer`;超限按行截断并提示"完整输出见 `mcpp` 输出频道" | +| 错误分层 | SPEC-003:`2` 用法 / `4` 环境未就绪 / `1` 运行失败 / `70` 内部 / `127` 未知命令 → 不同文案 + 下一步建议 + `mcpp self explain ` 一键跳转 | +| `mcpp.path` | 配置变更时跑一次 `mcpp --protocol-version`(无副作用),版本写进输出频道;不可执行时状态栏警告 | +| 激活面 | 收敛为 `workspaceContains:mcpp.toml` + `onLanguage:mcpp-toml` + `onLanguage:mcpp-build` | +| 未受信任工作区 | 缓存视图降级为纯文件系统估算并注明;清理一律要求信任 | + +### 3.2 `build.mcpp`:只做模式 A + +#### 3.2.1 实测结论(本机真实 mcppls 0.0.8 payload + clangd 23.1.0) + +``` +$ mcppls check build.mcpp --payload +root …/examples/11-features/greeter +database 6 entries, 2 standard library units, 0 left out +module build.mcpp:2: module 'mcpp' not found +E [module_not_found] Line 1: module 'std' not found +E Failed to build module mcpp; due to Don't get the module unit for module mcpp +clangd exit 3 + +$ mcppls check src/main.cpp --payload +database 6 entries, 2 standard library units, 0 left out +clangd exit 0 ← 零诊断 +``` + +并且 `mcpp emit build-database --spec compile-commands --format json` 的 6 条记录里 +**没有 `build.mcpp`**;它只出现在 `data.watch` 里。 + +**三条硬事实**:① mcpp 有意不把构建程序放进编译数据库;② mcppls 的模型里没有 +`build.mcpp`(它只处理 `target/.build-mcpp/deps/...`);③ 因此交给 clangd 后 +**`import std` 与 `import mcpp` 都会报 `module not found`**,不是"只有 mcpp 报错"。 + +而 **VS Code 没有公开 API 能过滤/删除另一个扩展发布的诊断**。所以"交给 C++ 语言服务、 +但抹掉 `import mcpp` 的红线"在不改上游的前提下**做不到**。 + +#### 3.2.2 本版设计:模式 A(隔离)—— 零报错 + +- `build.mcpp` 保持独立语言 id `mcpp-build`,语法即 C++(现有 `include: source.cpp` + + 模块注入语法),**mcppls 不介入 → 永不报错**; +- 在此之上补 **mcpp-vscode 自研的 `mcpp::` 智能**(数据来自 mcpp 的机器可读表,见 §3.2.3): + 补全 / 悬停 / 签名 / 静态诊断 / 片段 / 文档符号 / 折叠; +- **"认识 std 与 mcpp"**:内嵌"构建脚本可用模块名"清单 + (`std`、`std.compat`、`mcpp`、`mcpp.core`、`mcpp.plugins.*`),对其做**高亮 + 悬停说明 + + 文档链接**,并**永不对其报"找不到"**(`mcpp.buildScript.imports.knownModules`)。 + 这就是"std ok"在本层的实现:**认识它、解释它、不为难你**; +- 诚实写明代价:**没有 std 的符号级补全/跳转**。 + +#### 3.2.3 数据来源(贡献者期生成) + +`tools/generate-buildscript-api.mjs`(Node,无依赖)读 `MCPP_REPO`(默认 `../mcpp`): + +| 源 | 取什么 | +|---|---| +| `modules/buildmcpp/src/directives.cppm:297` | `kTable[31]`:wire / tag / slot / scope / transform / must / missingPrefix / missingSuffix / sinceProtocol | +| `modules/buildmcpp/src/directives.cppm:1132-1134` | 5 个 role 常量 | +| `modules/buildmcpp/src/program_protocol.cppm:121,147` | `kProtocolVersion = 15`、`kCacheEpoch = 3` | +| `modules/buildmcpp/src/provisions.cppm:80` | `tool` / `host-module` / `dep-dir` | +| `docs/specs/build-plugins.md`、`docs/30-build-mcpp.md` | 规则号与 hover 章节链接 | + +输出 `data/buildscript-api.json`(含 `sourceVersion` / `sourceCommit` / `protocolVersion`)。 +CI 漂移 job:用 `mcpp@main` 重新生成 + `git diff --exit-code`。 + +**静态诊断规则**(纯文本,不执行任何东西,未受信任工作区可用;严重度由 +`mcpp.buildScript.diagnostics.severity` 控制,默认 warning): +① 未知 `mcpp::`;② `role` 用字符串字面量(R3.6);③ `prepare` 未声明 `output_dir`(R3.3); +④ `link_flag` 含 `-Wl,-rpath`(R4.4);⑤ `cxxflag`/`link_flag` 含 `-I`/`-L`(R2.1); +⑥ action 命令含 `NAME=value cmd` 或 `cd x &&`(R3.8);⑦ `mcpp::action` 未声明 role 或输出。 + +### 3.3 `mcpp.toml` 编辑体验 + +#### 3.3.1 数据:`data/toml-schema.json` + +`tools/generate-toml-schema.mjs` 从 `docs/specs/manifest-semantics.md`(SPEC-004 §2 的平面划分) +与 `docs/04-mcpp-toml.md` 的字段小节生成,人工补**枚举值**与 **legacy 标记**: + +```jsonc +{ + "sourceVersion": "2026.10.1.3", + "sections": [ + { "header": "[package]", "plane": "identity", "doc": "docs/04-mcpp-toml.md#21-package", + "keys": [ + { "key": "name", "type": "string", "required": true }, + { "key": "standard", "type": "enum", "values": ["c++20","c++23","c++26"], "default": "c++23" }, + { "key": "mcpp", "type": "string", "pattern": "^>=.*", "since": "2026.9.28.3" } + ] }, + { "header": "[language]", "plane": "legacy", "deprecatedBy": "[package].standard" } + ], + "rules": [ { "id": "plane-separation", "messageKey": "toml.rule.planeSeparation" } ] +} +``` + +#### 3.3.2 功能矩阵 + +| 功能 | 内容 | 开关(§4) | +|---|---|---| +| 段头补全 | 已有,改为读 schema(平面分组 + legacy 标记) | `mcpp.toml.completion` | +| **键补全** | 已知段的键位置 → 该段全部键(类型、默认值、legacy),已存在的键剔除 | 同上 | +| **枚举值补全** | `standard`、`kind`、`linkage`、`opt`、`[profile.*]`、`[target.]` selector 词表 | 同上 | +| **悬停** | 段头/键 → 类型、默认值、平面、起始版本、legacy 迁移建议、`docs/04` 章节链接 | `mcpp.toml.hover` | +| **诊断** | ① TOML 语法错误;② 未知段;③ 已知段的未知键;④ `[dependencies]` 里的 `xim:` / `[xlings]` 里的 mcpp 包(SPEC-004 §2);⑤ `[package].mcpp` 非 `>=` 形式(SPEC-007 R9.8);⑥ legacy 键;⑦ `[[...]]` 数组表 | `mcpp.toml.diagnostics.*`(**未知段/未知键默认 warning**) | +| **跳转** | `workspace = true` → `[workspace.dependencies]` 同名键;`path = "../x"` → 那个 `mcpp.toml` 的 `[package]`;`features = ["a"]` → `[features.a]` | `mcpp.toml.navigation` | +| **依赖版本补全** | 在 `[dependencies]` 的值位置给候选版本 | `mcpp.toml.indexCompletion`(**默认 false**) | +| 格式化 | **不做** | — | + +**依赖版本补全的诚实边界**(本方案唯一的"解析人类输出"例外): +`mcpp search ` **没有机读格式**(实测 `mcpp search --help` 只有人类列表 + +`--all-versions`),而且它默认会刷新索引(联网)。因此: + +- 默认关闭,开启时给一次性提示"这会执行 mcpp 并可能联网"; +- 用 `mcpp search --all-versions` + `mcpp.toml.indexCompletionTimeoutSeconds`(默认 20 s); +- 解析 `名称 (版本)` 行,**按会话缓存**,失败/超时/离线即静默降级为"无候选"; +- 在 `docs/mcpp-toml.md` 写明这是临时方案,**mcpp 一旦提供机读格式就替换**。 + +### 3.4 缓存统计与清理 + +#### 3.4.1 可用数据(本机实测) + +| 命令 | 数据 | 机读 | +|---|---|---| +| `mcpp cache list --format json` | kind `mcpp.cache`:`data.root` + `data.entries[]`,每条 `{accessed(Unix 秒), bytes, complete, dir, files, key, kind, label}`。实测 **657 条 / 7.20 GiB / pkg 576 · std 81 / 2 条 incomplete / 83 个 label / 最旧 2026-09-23** | ✅ 信封 | +| `mcpp cache dir` | 缓存根 + `legacy (unused, removable with mcpp cache clean --legacy): ` | ❌ 文本 | +| `mcpp cache info ` | `dir/key/package/size/file count/last used/complete/inputs(JSON)` | ❌ 文本 | +| `mcpp cache verify` | 校验条目清单与磁盘 | ❌(看退出码与文本) | +| `mcpp cache prune --older-than ` | 按未使用时长丢弃 | — | +| `mcpp cache gc --max-size --older-than ` | LRU 收敛到预算 | — | +| `mcpp cache clean [--deps\|--std\|--all\|--legacy]` | 分类清空 | — | +| `mcpp clean [--stale] [--older-than …] [--dry-run] [--bmi-cache]` | 见 §3.4.2 | `--dry-run` 文本 | + +`target/` 体积只能自己 walk。mcpp 明确写"`target/` 下的内容不是接口",因此:体积一律标 +**"估算"**;**"过期集合"的权威来源永远是 `mcpp clean --dry-run`**;walk 失败显示"未知"。 + +#### 3.4.2 两级清理 + +| 级别 | 按钮 | 命令 | 语义 | +|---|---|---|---| +| **L1** | 清理项目产物 | `mcpp clean` | 删整个 `target/` | +| **L2** | 清理过期产物 | `mcpp clean --stale --older-than d` | 只删"不是当前构建"的 fingerprint 目录,保留最近 N 天 | + +`N` = `mcpp.cache.staleDays`,默认 **3**(下拉 1 / 3 / 7 / 30 / `0`=一个都不留)。 +不写时 mcpp 用 1 天,我们显式写出以保证可预期。 + +**全局缓存不并入这两级**(`--bmi-cache` 会清掉所有工程共享的缓存 → 全量重建)。 +「连全局缓存一起清」只作为 L1 确认框里**第三个、默认不勾选**的选项。 + +#### 3.4.3 全局缓存:暴露更多功能 + 更好的可视化 + +| 能力 | 命令 | 危险级 | 说明 | +|---|---|---|---| +| 浏览 | `cache list --format json` | 只读 | 树:按包分组 → 展开到条目(key/大小/最近使用/完整) | +| 详情 | `cache info ` | 只读 | 原文展示到只读预览文档(**不解析**) | +| 校验 | `cache verify` | 只读 | 报告损坏/缺失条目;失败项高亮 + "查看输出" | +| 按时间收敛 | `cache prune --older-than d` | 中 | 默认 `mcpp.cache.pruneAgeDays` = 30 | +| 按预算收敛 | `cache gc --max-size GiB [--older-than …]` | 中 | 默认预算 `mcpp.cache.gc.defaultBudgetGiB`(0 = 每次追问) | +| 分类清空 | `cache clean --deps` / `--std` / `--all` | 高 | 逐级更重的确认 | +| 清遗留 | `cache clean --legacy` | 低 | pre-v1 `$MCPP_HOME/bmi` | + +**可视化(webview 面板 `mcpp: 缓存统计`,纯 CSS + 内联 SVG,无图表库、无网络、严格 CSP)** + +1. **顶部两个大数字**:全局缓存 / 项目产物,各带"上次采集时间"; +2. **构成条**:按 `kind`(pkg / std)+ pre-v1 遗留 的横向堆叠条,颜色语义见 §3.5; +3. **年龄分布条**:把条目按 `accessed` 分桶(`<1d` / `1–7d` / `7–30d` / `>30d`,桶由 + `mcpp.views.cache.ageBuckets` 配置)→ 直观回答"能回收多少"; +4. **Top N 表**(`mcpp.views.cache.topN`,默认 5):label / 条目数 / 大小 / 最旧使用。 + 行内只提供「详情」(`cache info` 原文预览)与「复制命令到终端」—— + ⚠ **mcpp 没有"按包删除"的命令**(`cache clean` 只有 `--deps/--std/--all/--legacy` 四档), + 所以**不提供按包删除**,避免做出一个假的按钮; +5. **预算模拟器**:选目标 GiB → 本地按 `accessed` 升序做 LRU 模拟 → 预估"将删除 N 条 / 释放 X"。 + 标注为**预估**(mcpp 的 LRU 还受 `complete` 等影响),执行后以 mcpp 输出为准; +6. **不完整条目**区块:`complete=false` 的条目列出 + 一键 `cache verify`; +7. **pre-v1 遗留**区块:来自 `cache dir`,一键清理; +8. **项目产物**区块:按 triple 分组的条形 + 「过期产物 N 项 / X MiB」(来自 `clean --dry-run`)。 + +**危险操作分级**(`cache clean --all` **照评审意见暴露**,但确认最重): + +| 级 | 操作 | 确认方式 | +|---|---|---| +| 1 只读 | `list` / `dir` / `info` / `verify` | 无 | +| 2 中 | `prune --older-than` / `gc --max-size` | **必看预演**(列出将删除或预估释放;`clean --dry-run` 原文) | +| 3 高 | `clean --deps` / `--std` | modal,标题写明将释放的字节数 | +| 4 极重 | **`clean --all`** | **两步**:modal(写明"将删除本机所有 mcpp 工程共享的包缓存与标准库模块条目")+ 二次勾选确认 | +| 5 最重 | L1 清理 + `--bmi-cache` | modal + 二次勾选 + 文案点名"会影响本机所有 mcpp 工程" | + +所有清理都要求受信任工作区、走现有 `McppOperationRegistry`(与 build 互斥)、**绝不自动执行**。 +`mcpp.cache.gc.confirmAboveGiB`(默认 1)保证任何超过 1 GiB 的收敛动作都至少有 modal。 + +### 3.5 配色与可视化设计系统 + +单一来源 `src/views/theme.ts` + `media/*.css`。**所有颜色只用 VS Code 主题令牌**。 + +| 语义 | 令牌 | 用途 | +|---|---|---| +| 工程 / 主色 | `--vscode-charts-blue` | 工程视图、构建数值 | +| 缓存 / 正常 | `--vscode-charts-green` | 缓存总量、完整条目 | +| 标准库缓存 | `--vscode-charts-purple` | `kind: std` 分段 | +| 过期 / 可清理 | `--vscode-charts-yellow` | 过期产物、L2 按钮 | +| 警告 | `--vscode-charts-orange` + `$(warning)` | 不完整条目、超阈值 | +| 危险 | `--vscode-errorForeground` | L1 确认、`--bmi-cache` | +| C++ 语义(mcppls) | `--vscode-charts-blue` + `$(beaker)` | 「C++ Modules」视图、状态项 | +| 次要文字 | `--vscode-descriptionForeground` | 单位、时间、计数 | +| 进行中 | `--vscode-progressBar-background` + `$(sync~spin)` | 状态栏、视图 | + +- 贡献两个可覆盖颜色 ID:`mcpp.cacheOkForeground`、`mcpp.cacheStaleForeground` + (与 mcppls 的 `mcppls.statusReadyForeground` 同做法)。 +- 图标只用 codicon:`$(tools)` 构建、`$(play)` 运行、`$(beaker)` 测试、`$(trash)` 清理、 + `$(database)` 缓存、`$(graph)` 统计、`$(refresh)` 刷新、`$(history)` 按时间、`$(check)` 校验、 + `$(warning)` 警告、`$(package)` 包、`$(chip)` 工具链、`$(target)` 目标、`$(sync~spin)` 进行中、 + `$(settings-gear)` 配置面板。 +- **数字格式化统一**(`src/util/format.ts`):字节按 `mcpp.ui.numberFormat`(默认二进制 `GiB`); + 三位有效数字;时间相对("3 天前")+ 悬停绝对时间;计数千分位。 +- **三态设计**:每个视图/面板都要有「空 / 加载(`$(sync~spin)` + 上次采集时间)/ + 错误(`$(error)` + 一行原因 + 打开输出频道)」。 +- **通知策略**:modal 只用于不可逆操作(`mcpp.ui.confirmDestructiveOnly`);成功默认不弹 toast; + 失败弹 toast + "查看输出";同一原因去重 `mcpp.ui.notifications.dedupeMinutes`。 +- **状态栏**:一个 `mcpp` 项(priority 40):`$(tools) mcpp` / `$(sync~spin) mcpp: 构建中` / + `$(warning) mcpp: 缓存 8.1 GiB`。tooltip = 工程 + 工具链 + target + 缓存。 +- **快捷键**:`ctrl+alt+b` 构建、`ctrl+alt+r` 运行、`ctrl+alt+t` 测试、`ctrl+alt+m` 菜单、 + `ctrl+alt+,` 配置面板。清理类**不给快捷键**。 +- **与 mcppls 的视觉边界**(v3 修订):**不新增第二个状态栏项**(mcppls 已有 C++ Language + Status Item);输出频道保持独立(`mcpp`)与 `C++ Modules` 并列;但我们**新增第三个视图 + 「C++ Modules」**(§3.9),并在它的标题/描述里**明确标注提供方**,只做展示与转发, + 不冒充 mcppls、不复制它的状态项。 + +### 3.6 UI 形态(已定 U2) + +> v1/v2 比较过三种形态:**U1**(只加命令面板 + 状态栏,视觉侵入最低、可发现性差)、 +> **U2**(Activity Bar 容器 + 2 TreeView + 1 Webview 面板,可视化最好)、 +> **U3**(只加一个控制面板,折中)。评审选定 **U2**;**U1 保留为兜底**——状态栏项与命令面板 +> 永远可用,视图容器被拖走或视图报错时功能不减;U3 作为"若 Activity Bar 的侵入感被否决"的 +> 退路,面板内做同样的可视化,只是没有常驻条目。 + +``` +mcpp ← Activity Bar 容器 $(tools) +├── 工程 (mcpp.project) ← TreeView +│ ├── greeter 0.1.0 · c++23 +│ ├── 工具链 llvm@22.1.8 · target x86_64-unknown-linux-gnu +│ ├── 目标 greet (bin) · 测试 tests/ +│ └── [$(tools) 构建] [$(play) 运行] [$(beaker) 测试] +├── 缓存 (mcpp.cache) ← TreeView +│ ├── 项目产物 … 1.4 GiB(估算)· 3 个 fingerprint +│ │ ├── 过期产物 12 项 · 820 MiB [$(trash) 清理过期产物…] +│ │ └── [$(trash) 清理项目产物…] +│ ├── 全局构建缓存 … 7.20 GiB · 657 条目 +│ │ └── [$(refresh) 刷新] [$(graph) 统计面板] [$(history) 收敛到预算…] [$(check) 校验] +│ └── pre-v1 遗留缓存 … 167.5 MiB [$(trash) 清理遗留缓存] +└── C++ Modules (mcpp.languageServer) ← TreeView,由 mcppls 提供内容(§3.9) + ├── 状态 ready · 引擎 clangd 23.1.0 · 问题 0 + └── [$(refresh) 重启] [$(output) 日志] [$(report) 诊断报告] … +``` + +**兜底**:状态栏项与命令面板永远可用;即使视图容器被用户拖走或视图报错, +所有功能仍可从命令面板触达。 + +**新增命令清单**(全部用 `mcpp.` 前缀;**绝不使用 `mcppls.` 前缀**,见 `.agents/docs/mcppls-integration.md` §3) + +| 命令 ID | 标题 | 分类 | 视图内联 | 备注 | +|---|---|---|---|---| +| `mcpp.openSettings` | mcpp: 打开设置面板 | mcpp | — | §4.3 | +| `mcpp.showCachePanel` | mcpp: 缓存统计 | mcpp | ✅ 缓存视图 | §3.4.3 | +| `mcpp.refreshCacheStats` | mcpp: 刷新缓存统计 | mcpp | ✅ 缓存视图 | 只读 | +| `mcpp.cleanStaleArtifacts` | mcpp: 清理过期产物 | mcpp | ✅ 项目产物 | L2,必看预演 | +| `mcpp.cleanProjectArtifacts` | mcpp: 清理项目产物 | mcpp | ✅ 项目产物 | L1;替换现有 `mcpp.clean` | +| `mcpp.gcGlobalCache` | mcpp: 收敛全局缓存… | mcpp | ✅ 全局缓存 | `cache gc --max-size` | +| `mcpp.pruneGlobalCache` | mcpp: 按时间清理缓存… | mcpp | ✅ 全局缓存 | `cache prune --older-than` | +| `mcpp.verifyGlobalCache` | mcpp: 校验缓存 | mcpp | ✅ 全局缓存 | 只读 | +| `mcpp.cleanLegacyCache` | mcpp: 清理遗留缓存 | mcpp | ✅ 遗留缓存 | `cache clean --legacy` | +| `mcpp.showCacheEntry` | mcpp: 查看缓存条目详情 | mcpp | ✅ 条目 | `cache info` 原文 | +| `mcpp.updateDependencies` | mcpp: 更新依赖 | mcpp | ✅ 工程视图 | `mcpp update`,带预演 | +| `mcpp.selfDoctor` | mcpp: 环境诊断 | mcpp | — | `mcpp self doctor` | +| `mcpp.selfCheck` | mcpp: 环境自检 | mcpp | ✅ 工程视图 | §2.6 | +| `mcpp.explainCode` | mcpp: 解释错误码… | mcpp | — | `mcpp self explain` | +| `mcpp.addDependency` | mcpp: 添加依赖… | mcpp | ✅ 工程视图 | `mcpp add` 向导 | +| `mcpp.removeDependency` | mcpp: 移除依赖… | mcpp | ✅ 工程视图 | `mcpp remove` | +| `mcpp.searchPackages` | mcpp: 搜索包… | mcpp | — | `mcpp search` | +| `mcpp.openMcpplsSettings` | mcpp: 打开 C++ Modules 设置 | mcpp | — | 只打开别人的设置页,**不写** | + +**转发 mcppls 的命令**(§3.9;同样用 `mcpp.` 前缀,**不是** `mcppls.`) + +| 命令 ID | 标题 | 视图内联 | 危险 | 转发到 | +|---|---|---|---|---| +| `mcpp.languageServer.refreshState` | mcpp: 刷新 C++ Modules 状态 | ✅ C++ Modules | none | (只读 `readState`) | +| `mcpp.languageServer.restart` | mcpp: 重启 C++ Modules 语言服务 | ✅ | none | `mcppls.restartServer` | +| `mcpp.languageServer.restartEngine` | mcpp: 重启 clangd… | ✅ | confirm | `mcppls.restartClangd` | +| `mcpp.languageServer.resetWorkspaceCache` | mcpp: 重置本工作区缓存… | ✅ | **destructive** | `mcppls.resetWorkspaceCache` | +| `mcpp.languageServer.selectContext` | mcpp: 选择 C++ 模块分析上下文 | ✅ | none | `mcppls.selectContext` | +| `mcpp.languageServer.showModuleGraph` | mcpp: 查看模块图 | ✅ | none | `mcppls.showModuleGraph` | +| `mcpp.languageServer.showLogs` | mcpp: 打开 C++ Modules 日志 | ✅ | none | `mcppls.showLogs` | +| `mcpp.languageServer.collectReport` | mcpp: 收集 C++ Modules 诊断报告 | ✅ | none | `mcppls.collectReport` | +| `mcpp.languageServer.exportDiagnosticBundle` | mcpp: 导出 C++ Modules 诊断包… | ✅ | none | `mcppls.exportDiagnosticBundle` | +| `mcpp.languageServer.runBuildToolInTerminal` | mcpp: 在终端运行构建工具… | ✅ | confirm | `mcppls.runBuildToolInTerminal` | +| `mcpp.languageServer.manageConflicts` | mcpp: 处理其它 C++ 语言特性… | ✅ | confirm | `mcppls.turnOff/turnOnOtherCppFeatures` | +| `mcpp.languageServer.toggleInWorkspace` | mcpp: 在本工作区启用/停用 C++ Modules… | ✅ | confirm | `mcppls.turnOn/turnOffInWorkspace` | +| `mcpp.languageServer.installTools` | mcpp: 安装 C++ Modules 命令行工具… | ✅ | confirm | `mcppls.installCommandLineTools` | +| `mcpp.languageServer.reviewChanges` | mcpp: 审查工作区改动 | ✅ | none | `mcppls.review.run` / `review.clear` | + +> 现有 4 个转发命令(`mcpp.configureLanguageServer`、`mcpp.checkModuleSupport`、 +> `mcpp.showModuleGraph`、`mcpp.showLanguageServerLogs`)**保留为弃用别名**, +> 指向上表的新 ID —— 与 `mcpp.configureClangd → mcpp.configureLanguageServer` 同一套做法。 + +现有 `mcpp.clean` 保留为 `mcpp.cleanProjectArtifacts` 的弃用别名(与 +`mcpp.configureClangd → mcpp.configureLanguageServer` 的既有做法一致)。 + +### 3.7 国际化(i18n):中/英跟随 VS Code + +| 面 | 做法 | +|---|---| +| `package.json` 用户可见字符串 | `%key%` 占位;`package.nls.json`(英,默认)+ `package.nls.zh-cn.json`(中)。覆盖 `displayName`、`description`、命令标题、设置标题/描述/弃用信息、`untrustedWorkspaces.description` | +| 运行期字符串 | 单一来源 `data/i18n/{en,zh-cn}.json` → `tools/generate-l10n.mjs` 生成 `l10n/bundle.l10n.json` + `l10n/bundle.l10n.zh-cn.json` | +| 解析 | `src/i18n/t.ts`:`mcpp.ui.language === "auto"` → `vscode.l10n.t(key)`;否则读自带表 | +| webview | `getHtml` 时把当前语言的字符串注入,或 `vscode.l10n.uri` | +| 防漏翻译 | `tools/l10n-check.mjs`:`l10n.t(` 的字面量与 bundle 键集合比对;`package.nls.zh-cn.json` 覆盖所有 `%key%`;缺失即 CI 失败 | +| 测试影响 | `test/artifacts.test.ts` 现在断言中文字面量 → 改为断言 `%key%` 形态 + nls 覆盖完整 | + +**第一版范围(已定)**:`package.nls.*` **100% 覆盖**(命令标题、设置项、弃用信息)+ +**关键流程文案**(命令成功/失败、清理确认、降级提示、进度标题)100%; +其余运行期文案随各里程碑渐进迁移,但 `l10n-check.mjs` 从第一天起就阻止**新增硬编码**。 + +**必须写进 docs 的 API 限制**:`mcpp.ui.language` 的手动覆盖**只影响运行时提示与我们的面板**; +命令面板标题、原生设置页里的设置名称**永远跟随 VS Code 语言**(VS Code 在启动时读 +`package.nls.*`,无法运行时切换)。所以用户在 `zh-cn` 界面下把 `mcpp.ui.language` 设成 `en` +会看到"设置名是中文、提示是英文"的混搭 —— 这是有意提供的逃生门,不是缺陷。 + +### 3.8 mcpp 相关功能补齐 + +| 功能 | 说明 | 优先级 | +|---|---|---| +| 工程视图 | 包名/版本/标准/目标/工具链/target/profile | 高 | +| 缓存视图 + 面板 + 两级清理 + 全局缓存功能 | §3.4 | 高 | +| 配置面板 | §4 | 高 | +| `mcpp update` | 重解析依赖并改写 `mcpp.lock`,带"将改动 N 个包"的预演 | 高 | +| `mcpp self doctor` | 环境诊断 → 频道 + 结构化摘要 | 高 | +| `mcpp: 环境自检` | §2.6 | 高 | +| `mcpp add` / `remove` | 依赖编辑向导,与 `mcpp.toml` 键补全联动 | 中 | +| `mcpp search` | 包搜索 → 插入依赖(复用 §3.3.2 的 search 适配层) | 中 | +| `mcpp self explain ` | 失败时一键解释 | 中 | +| `mcpp pack` / `publish --dry-run` | 打包与发布前检查 | 低 | + +### 3.9 把 mcppls 的状态与管理并入 mcpp + +> 目标(评审意见):**把 mcppls 的核心状态与管理纳入 mcpp**——LSP 状态、引擎、构建描述、 +> 问题、缓存情况与清理、诊断包等,让用户在**一个地方**看清并操作。 +> 硬约束仍然是:**不改 mcppls**,所以这里的一切都建立在"**读它已有的状态**" +> 和"**转发它已有的命令**"之上,绝不重新实现、绝不写它的内部文件。 + +#### 3.9.1 能读到什么:三条通道,各有明确边界 + +| 通道 | 内容 | 契约强度 | 本方案的用法 | +|---|---|---|---| +| **A. 扩展元数据** | `packageJSON.version`、`isActive`、`contributes.commands`、平台 VSIX 是否存在、`mcppls.enable` 的当前值(只读配置) | **正式、稳定** | 基础信息与能力探测 | +| **B. `extension.exports`** | mcppls 的 `activate()` 返回一个对象(它对内是"测试 API"),其中 **`lastStatus()` 返回完整的 `CxxModulesStatus`**、`statusBarText()`、`serverRunning()`、`serverEnabled()`、`serverCommands()`、`environment()`、`waitForState()` | ⚠️ **非契约**:它是测试 API,字段可能随 mcppls 变化 | **尽力而为读取**,见 §3.9.2 | +| **C. 转发命令** | §2.1 的 `forward` 能力表(重启、重启引擎、重置缓存、报告、诊断包、冲突处理、启用/停用、在终端运行构建工具、review) | **命令 ID 是事实契约**(同现状) | 所有"管理"动作 | +| ~~D. LSP `cxxModules/status`~~ | 真正的契约来源 | 正式 | ❌ **不可用**:本扩展没有 LSP 客户端,且 CI 明确禁止再建一个 | +| ~~E. mcppls 的磁盘缓存/日志文件~~ | 缓存目录、模型、引擎数据库 | **内部布局,不是接口** | ❌ **不读、不解析、不删除** | + +`CxxModulesStatus` 的形状是 **S3 规范级契约**(`docs/specs/s3-lsp-extensions.md` §4), +所以"内容怎么解释"是稳定的;不稳定的只是"能不能拿到这个对象"。这决定了下面的防御写法。 + +#### 3.9.2 `readState`:尽力而为,坏掉就降级 + +```ts +// src/mcppls/state.ts —— 只有一个入口,返回"一定有值"的视图模型 +export interface McpplsStateView { + available: boolean; // false ⇒ 只有降级内容 + version?: string; + active: boolean; + enabled: boolean; // 只读 mcppls.enable + state?: "starting" | "loading" | "preparing" | "ready" | "degraded" | "error"; + project?: { root: string; source: string; level?: number; tier?: number }; + profile?: { kind: string; compiler?: string; stdlib: string; target: string; standard?: string }; + engine?: { name: string; version: string }; + engines?: Array<{ name: string; version: string; role: string; state: string }>; + progress?: { done: number; total: number }; + issues?: Array<{ code: string; message: string; command?: { command: string; arguments?: unknown[] } }>; + notices?: Array<{ code: string; message: string }>; + onlineRun?: { outcome: string; message: string; at: string }; +} + +export function readState(): McpplsStateView; +``` + +读取纪律(每一条都会写成测试): + +1. `getExtension(id)?.exports` 必须是对象,`typeof exports.lastStatus === "function"`, + 否则 `available: false`——**不抛错、不提示、只是少一块内容**; +2. `lastStatus()` 包在 `try/catch` 里; +3. 返回值必须是对象且 `state` 属于 S3 枚举的六个值之一,否则视为**未知形状** → `available: false`; +4. `issues[].code` 与 `engines[].state` 都用**白名单**渲染,未知值原样显示文本但**不解释**; +5. `readState` 由设置 `mcpp.languageService.readState`(默认 `true`)控制,可完全关闭; +6. **不主动轮询**:以 `vscode.extensions.onDidChange` + 我们自己的命令完成回调 + + `mcpp.languageService.stateRefreshSeconds`(默认 0 = 关)为触发点;高频轮询被明确排除; +7. 视图渲染**永远不因 `readState` 失败而报错**(不变式 ④)。 + +`issues[].command` 是 **S3 自带的"修复动作"**("an optional action that fixes the issue")。 +我们把它渲染成按钮并**直接 `executeCommand(issue.command.command, ...args)`** —— +这是契约内的字段,比我们自己猜修复方式更可靠。典型如 `producer-needs-download` +(`askOnline: true`)→ 提示用户是否允许联网重述工程。 + +#### 3.9.3 缓存与管理:能做的做,做不到的**不假装** + +| 用户想要 | mcppls 侧的可用手段 | 本方案 | +|---|---|---| +| 看 LSP 状态 | `readState()` 的 `state` / `project` / `profile` / `engine(s)` / `progress` | ✅ 视图 + 环境自检 | +| 看模块问题 | `readState()` 的 `issues` / `notices`,含修复命令 | ✅ 逐条列出 + 修复按钮 | +| 看构建描述 | `readState().project.{source, level, tier}` | ✅(**条数/单元数**只有 `mcppls check`/`model` 才有,见下) | +| 看引擎 | `readState().engine` / `engines[]` | ✅ | +| 重启语言服务 / 重启 clangd | `mcppls.restartServer` / `mcppls.restartClangd` | ✅ `confirm` | +| **清理 mcppls 的缓存** | `mcppls.resetWorkspaceCache`(它自己的"重置本工作区缓存") | ✅ **`destructive` + modal 确认**;确认框写明"会清掉本工作区的模型缓存并重新准备,可能耗时数分钟;**不影响** mcpp/全局构建缓存与项目 `target/`" | +| **mcppls 缓存的体积 / 占比 / 可视化** | ❌ 缓存目录**不在** `environment()` 里(它只给 version / vscode / appName / appHost / uiKind / platform / remote),也没有公开命令返回路径或大小;磁盘布局是内部实现 | ❌ **不做**。视图里给"**打开日志**""**收集诊断报告**""**导出诊断包**"三个入口,并在该区块写明"缓存由 C++ Modules 扩展自行管理,本扩展不读取其内部目录"。**向上游提 change request**(附录 D.5):注册一个返回缓存目录/体量的命令 | +| 看诊断 | `readState().issues` + `mcppls.showLogs` + `mcppls.collectReport` / `exportDiagnosticBundle` | ✅ | +| 关掉别的 C++ 扩展 | `mcppls.turnOffOtherCppFeatures` / `restoreOtherCppFeatures` | ✅ `confirm` | +| 在本工作区启用/停用 C++ Modules | `mcppls.turnOnInWorkspace` / `turnOffInWorkspace`(它们写 `mcppls.enable`,是 mcppls 自己的命令) | ✅ `confirm`;视图里显示当前值(只读) | +| 在终端运行构建工具 | `mcppls.runBuildToolInTerminal` | ✅ `confirm` | +| Review Changes | `mcppls.review.run` / `review.clear`(服务器命令,受 `mcppls.ai.enabled` 约束) | ✅ 仅在能力可用时显示,**只转发** | + +> **为什么"构建描述条数"要打折**:`readState()` 只给 `source/level/tier`,不给条目数。 +> 想要"6 entries / 2 std units"这种数字,只能跑 `mcppls check ` 或 `mcppls model` +> —— 那是**另起一个进程加载工程**(重则数分钟),不适合放进视图。 +> 因此本方案的视图**不显示假的条目数**;重信息只在用户**显式点击** +> "环境自检 / 收集诊断报告"时由 mcppls 自己产出。 + +#### 3.9.4 UI:第三个视图 + +`mcpp` 容器新增 **「C++ Modules」** 视图(`mcpp.languageServer`),`package.json` 的 +`views.when` = `mcpp.hasMcppls`(mcppls 存在才显示)。**必须**在视图 `name`/`description` +里写清提供方,避免冒充: + +``` +C++ Modules(由 sunrisepeak.mcpp-language-server 提供,此视图只做展示与转发) +├── 状态 ready · 项目 greeter · 级别 3 +├── 语义配置 clangd 22.1.8 · libc++ · x86_64-linux-gnu · c++26 +├── 构建描述 mcpp(来源)· S1 level 3 · tier 1 +├── 引擎 clangd 23.1.0 [core/ready] · mcppls [modules/ready] +├── 问题(2) $(warning) producer-needs-download … [$(lightbulb) 允许联网重述] +│ $(error) unresolved-module … +├── 通知(1) $(info) 上游写入工程目录 +├── 最近在线运行 fetched · 2026-10-02T12:40:11Z (仅有值时显示) +└── 操作 + [$(refresh) 重启语言服务] [$(chip) 重启 clangd…] [$(history) 重置本工作区缓存…] + [$(symbol-interface) 选择上下文] [$(type-hierarchy) 模块图] [$(output) 打开日志] + [$(report) 收集诊断报告] [$(package) 导出诊断包…] [$(debug-alt) 在终端运行构建工具…] + [$(settings-gear) 打开 C++ Modules 设置] [$(circle-slash) 在本工作区停用…] +``` + +- 问题条目:`$(warning)` / `$(error)` 图标按 `code` 白名单着色(`charts-yellow` / `errorForeground`), + 有 `command` 的显示 `$(lightbulb)` 按钮; +- 状态项用 `charts-green`(ready)/ `charts-yellow`(degraded)/ `errorForeground`(error), + `starting|loading|preparing` 用 `$(sync~spin)`; +- 多根工作区:按 workspace folder 分组(S3 明确每 root 一条状态); +- **空态**:mcppls 未安装/未启用 → 一行说明 + "安装/启用 C++ Modules"按钮; + `readState` 不可用 → "已安装的 mcppls(0.0.x)未暴露状态;下列操作仍可用"; +- **不新增第二个状态栏项**。想要的话,`mcpp.ui.statusBar.showLanguageServer`(默认 **false**) + 会在我们自己的状态栏项里追加一段 `· C++ Modules: ready`,tooltip 里说明"状态由 mcppls 提供"。 + +#### 3.9.5 与 mcppls 自己的 UI 的关系 + +mcppls 已经有一个 C++ Language Status Item、一个 "C++ Modules" 输出频道和一批调色板命令。 +本视图**不取代**它们,定位是: + +- **mcpp 工程视角的汇总**:把"这个工程的 mcpp 侧"和"这个工程的 C++ 语义侧"放在同一棵树里; +- **可发现性**:把 mcppls 散落在命令面板里的管理动作变成可见按钮; +- **诊断入口**:问题 → 修复命令 / 日志 / 诊断包,一条路径走完。 + +因此**不复制** mcppls 的状态栏项,**不合并**输出频道,**不代理**它的设置写入 +(唯一例外是转发它自己的 `turnOn/TurnOffInWorkspace` 命令,那仍然由 mcppls 执行)。 + +--- + +## 4. 主线 D:统一配置模块与配置面板 + +> 设计目标来自评审意见:「凡可配置的功能都要支持配置;配置功能作为独立模块统一管理」。 +> **上游先例**:mcppls 就是这么做的 —— `src/config/settings.cppm` 是唯一的配置注册表, +> 文档、`package.json`、命令行全部由它派生,并由 `tests/test_settings.cpp` 把三者钉在一起。 +> 本方案采用同一套思路。 + +### 4.1 单一事实源 `data/config-registry.json` + +```jsonc +{ + "version": 1, + "groups": [ + { "id": "project", "titleKey": "config.group.project", "order": 10 }, + { "id": "cache", "titleKey": "config.group.cache", "order": 60 } + ], + "settings": [ + { + "key": "mcpp.cache.staleDays", + "type": "number", + "default": 3, + "minimum": 0, + "maximum": 365, + "scope": "resource", // resource | window | machine-overridable + "group": "cache", + "order": 40, + "titleKey": "config.cache.staleDays.title", + "descriptionKey": "config.cache.staleDays.description", + "applies": "next-clean", // immediate | next-build | next-clean | view-reload + "advanced": false, + "tier": "public", // public | advanced(§4.6) + "since": "0.5.0", + "docs": "docs/cache.md#staledays", + "aliases": [] + } + ] +} +``` + +每个条目回答四件事:**是什么、默认什么、什么时候生效、在文档哪一节**。 + +### 4.2 由 registry 派生的四样东西 + +| 产物 | 由谁生成 | 门禁 | +|---|---|---| +| `package.json` 的 `contributes.configuration` | **手写,但由 `tools/check-config.mjs` 做语义一致性校验**(key 集合、type、default、enum、scope、标题 key) | CI:不一致即失败并提示"运行 `npm run gen:config` 同步" | +| `package.nls.json` / `package.nls.zh-cn.json` 的设置文案骨架 | `tools/generate-config.mjs` 生成骨架,人工填中文 | `l10n-check.mjs` 覆盖门禁 | +| `docs/settings.md` 的设置表 | `tools/generate-config.mjs` 生成表格 | `git diff --exit-code` | +| 配置面板 + `mcpp: 环境自检` 的"已改设置"清单 | 运行期读 `src/config/registry.ts` | 单测 | + +> 为什么不直接生成 `package.json`:JSON 往返会重排整个文件,产生无法 review 的巨 diff。 +> 语义校验比文本生成更稳。 + +### 4.3 配置面板(`mcpp: 打开设置面板`) + +- webview,按 registry 的 `groups` 分节渲染;纯 HTML/CSS/JS,严格 CSP,无网络,无框架; +- **每一行**:标题、说明、控件(checkbox / 下拉 / 数字 / 路径选择 / 字符串数组)、 + **当前生效值 + 来源**(默认 / 用户 / 工作区 / 工作区文件夹)、`applies` 提示 + ("下次清理生效")、「重置为默认」、「在原生设置中打开」(`workbench.action.openSettings` + + `@ext:mcpp-community.mcpp-vscode `); +- 工具栏:搜索、**仅显示已修改**、分组折叠、预设(**默认 / 极简 / 重度使用 / 只读浏览**)、 + 导出/导入设置 JSON、全部重置; +- 顶部固定一条边界提示:**这里只影响 mcpp-vscode;`mcppls.*` 由 C++ Modules 扩展自己管理, + 这个面板不写它**;并提供"打开 C++ Modules 设置"按钮 + (`workbench.action.openSettings "@ext:sunrisepeak.mcpp-language-server"`); +- `advanced: true` 的条目默认折叠; +- 多根工作区:按工作区文件夹分别显示"工作区值",并显示最终生效值; +- 写入走 `WorkspaceConfiguration.update(key, value, target)`,`resource` 型设置必须带 URI。 + +### 4.4 完整设置清单 + +> 这是评审意见「很多功能可以做成配置的都要支持配置」的落地。共 **60 项**,全部有默认值, +> 且**默认值一律是"不打扰"**。`advanced` 标记表示面板默认折叠。 + +**工程** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.path` | string | `""` | resource | immediate | +| `mcpp.project.discoveryBoundary` | `workspaceFolder` \| `filesystem` | `workspaceFolder` | resource | immediate | + +**任务** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.task.buildArgs` | string[] | `[]` | resource | next-build | +| `mcpp.task.runArgs` | string[] | `[]` | resource | next-build | +| `mcpp.task.testArgs` | string[] | `[]` | resource | next-build | +| `mcpp.task.cleanArgs` | string[] | `[]` | resource | next-clean | +| `mcpp.task.confirmClean` | boolean | `true` | resource | immediate | +| `mcpp.task.revealTerminal` | `always` \| `onFailure` \| `never` | `always` | resource | immediate | +| `mcpp.task.focusTerminal` | boolean | `false` | resource | immediate | +| `mcpp.task.clearTerminal` | boolean | `true` | resource | immediate | +| `mcpp.task.problemMatcher` | boolean | `true` | resource | immediate | +| `mcpp.task.editorTitleButtons` | boolean | `true` | resource | immediate | + +**语言服务(mcppls 桥接与状态)** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.languageService.refreshAfterBuild` | `auto` \| `reload` \| `restart` \| `off` | `auto` | resource | immediate | +| `mcpp.languageService.menuItems` | boolean | `true` | resource | immediate | +| `mcpp.languageService.notifyOnDegraded` | boolean | `true` | resource | immediate | +| `mcpp.languageService.readState` | boolean | `true` | resource | immediate | +| `mcpp.languageService.stateRefreshSeconds` | number | `0`(关) | resource | immediate | +| `mcpp.languageService.confirmResetCache` | boolean | `true` | resource | immediate | + +**`mcpp.toml` 编辑** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.toml.completion` | boolean | `true` | resource | immediate | +| `mcpp.toml.hover` | boolean | `true` | resource | immediate | +| `mcpp.toml.navigation` | boolean | `true` | resource | immediate | +| `mcpp.toml.diagnostics.enabled` | boolean | `true` | resource | immediate | +| `mcpp.toml.diagnostics.syntax` | severity | `error` | resource | immediate | +| `mcpp.toml.diagnostics.unknownSection` | severity | **`warning`** | resource | immediate | +| `mcpp.toml.diagnostics.unknownKey` | severity | **`warning`** | resource | immediate | +| `mcpp.toml.diagnostics.planeSeparation` | severity | `warning` | resource | immediate | +| `mcpp.toml.diagnostics.legacyKeys` | severity | `info` | resource | immediate | +| `mcpp.toml.indexCompletion` | boolean | `false` | resource | immediate | +| `mcpp.toml.indexCompletionTimeoutSeconds` | number | `20` | resource | immediate | + +(`severity` = `error` \| `warning` \| `info` \| `off`) + +**`build.mcpp` 编辑** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.buildScript.intelligence` | boolean | `true` | resource | immediate | +| `mcpp.buildScript.diagnostics` | boolean | `true` | resource | immediate | +| `mcpp.buildScript.diagnostics.severity` | `warning` \| `info` \| `off` | `warning` | resource | immediate | +| `mcpp.buildScript.imports.knownModules` | boolean | `true` | resource | immediate | +| `mcpp.buildScript.snippets` | boolean | `true` | resource | immediate | + +**缓存与清理** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.cache.statusBar` | boolean | `false` | resource | immediate | +| `mcpp.cache.warnAboveGiB` | number | `0`(关) | resource | immediate | +| `mcpp.cache.staleDays` | number | **`3`** | resource | next-clean | +| `mcpp.cache.autoRefreshSeconds` | number | `0`(关) | resource | immediate | +| `mcpp.cache.estimateProjectBytes` | boolean | `true` | resource | immediate | +| `mcpp.cache.showLegacy` | boolean | `true` | resource | immediate | +| `mcpp.cache.gc.defaultBudgetGiB` | number | `0`(每次追问) | resource | immediate | +| `mcpp.cache.gc.confirmAboveGiB` | number | `1` | resource | immediate | +| `mcpp.cache.pruneAgeDays` | number | `30` | resource | immediate | + +**视图** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.views.project.show` | boolean | `true` | window | view-reload | +| `mcpp.views.cache.show` | boolean | `true` | window | view-reload | +| `mcpp.views.languageServer.show` | boolean | `true` | window | view-reload | +| `mcpp.views.cache.topN` | number | `5` | window | immediate | +| `mcpp.views.cache.ageBuckets` | string[] | `["1d","7d","30d"]` | window | immediate | + +**界面与通知** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.ui.language` | `auto` \| `en` \| `zh-cn` | `auto` | window | immediate | +| `mcpp.ui.statusBar.show` | boolean | `true` | window | immediate | +| `mcpp.ui.statusBar.showLanguageServer` | boolean | **`false`** | window | immediate | +| `mcpp.ui.notifications.success` | `silent` \| `statusBar` \| `toast` | `statusBar` | window | immediate | +| `mcpp.ui.notifications.dedupeMinutes` | number | `5` | window | immediate | +| `mcpp.ui.confirmDestructiveOnly` | boolean | `true` | window | immediate | +| `mcpp.ui.numberFormat` | `binary` \| `decimal` | `binary` | window | immediate | + +**诊断与日志** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.log.level` | `error` \| `warn` \| `info` \| `debug` | `info` | window | immediate | +| `mcpp.diagnostics.selfCheckOnStartup` | boolean | `false` | window | immediate | + +**高级(面板默认折叠)** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.runtime.timeoutSeconds` | number | `30`(0 = 不限) | resource | immediate | +| `mcpp.runtime.maxOutputMiB` | number | `16` | resource | immediate | +| `mcpp.runtime.concurrency` | `perProject` \| `global` | `perProject` | resource | immediate | + +**弃用(保留为别名,面板不显示)**:`mcpp.clangd.path`、`mcpp.modulesSupport`、 +`mcpp.configureCppTools`、`mcpp.tomlCompletion`(→ `mcpp.toml.completion`)。 + +### 4.5 配置模块的职责边界 + +| 归 mcpp-vscode 配置 | 不归 | +|---|---| +| 上表全部 `mcpp.*` | `mcppls.*`(C++ Modules 扩展自己管理,本插件**只读不写**) | +| 面板里明确提示这条边界,并给"打开 C++ Modules 设置"按钮 | 用户的 `clangd.*`(已不在依赖面内) | + +### 4.6 第一版公开发布的设置(29 项) + +按评审意见,第一版**只在原生设置页与面板的常用区公开这些**;其余进入 `advanced` / +`tier: "advanced"`,默认折叠、文档里可查、面板里可搜到。registry 的每个条目因此多一个 +`tier: "public" | "advanced"` 字段。 + +**public(29 项)** + +| 分组 | 键 | +|---|---| +| 工程(1) | `mcpp.path` | +| 任务(8) | `buildArgs`、`runArgs`、`testArgs`、`cleanArgs`、`confirmClean`、`problemMatcher`、`focusTerminal`、`editorTitleButtons` | +| 语言服务(1) | `refreshAfterBuild` | +| `mcpp.toml`(6) | `completion`、`hover`、`navigation`、`diagnostics.enabled`、`diagnostics.unknownSection`、`diagnostics.unknownKey` | +| `build.mcpp`(2) | `intelligence`、`diagnostics` | +| 缓存(4) | `staleDays`、`statusBar`、`warnAboveGiB`、`gc.defaultBudgetGiB` | +| 视图(3) | `views.project.show`、`views.cache.show`、`views.languageServer.show` | +| 界面(3) | `ui.language`、`ui.notifications.success`、`ui.numberFormat` | +| 日志(1) | `log.level` | +| **合计** | **29** | + +**advanced(其余,默认折叠)**:`project.discoveryBoundary`、`revealTerminal`、`clearTerminal`、 +`languageService.{menuItems,notifyOnDegraded,readState,stateRefreshSeconds,confirmResetCache}`、 +`toml.{diagnostics.syntax,diagnostics.planeSeparation,diagnostics.legacyKeys,indexCompletion,indexCompletionTimeoutSeconds}`、 +`buildScript.{diagnostics.severity,imports.knownModules,snippets}`、 +`cache.{autoRefreshSeconds,estimateProjectBytes,showLegacy,gc.confirmAboveGiB,pruneAgeDays}`、 +`views.cache.{topN,ageBuckets}`、`ui.{statusBar.show,statusBar.showLanguageServer,notifications.dedupeMinutes,confirmDestructiveOnly}`、 +`diagnostics.selfCheckOnStartup`、`runtime.*`。 + +> 面板里有一个"显示高级设置"开关;公开与高级**只影响默认展开与设置页排序**, +> 不影响功能可用性。下一版根据使用反馈决定是否把某些项提升为 public。 + +--- + +## 5. 实施顺序与里程碑 + +| 里程碑 | 内容 | 预估 | +|---|---|---| +| **M0** | 目录纯移动 + `docs/superpowers/` → `.agents/superpowers/` + README 中英 + `docs/*` + `tools/` 与 CI 漂移 job 骨架 | 3 天 | +| **M1** | i18n:`data/i18n/*` + `generate-l10n` + `l10n-check` + `package.nls.*` 全量 + 关键流程文案 + 测试改写 | 2–3 天 | +| **M2** | 配置模块:`config-registry.json` + `check-config` + `access/validate/migrate` + `docs/settings.md` 生成 | 2–3 天 | +| **M3** | 配置面板(`mcpp: 打开设置面板`):分组渲染、来源显示、预设、搜索、仅显示已修改 | 2–3 天 | +| **M4** | 稳定性基座(协议优先、超时分级、错误分层、`mcpp.path` 校验、激活面收敛) | 2–3 天 | +| **M5** | 依赖韧性(能力模型、降级矩阵、五个 stub、`mcpp: 环境自检`) | 2–3 天 | +| **M5.5** | §3.9 mcppls 状态与管理:「C++ Modules」视图、`readState` 适配层、转发命令(13 个)、问题与修复按钮 | 3 天 | +| **M6** | `mcpp.toml` 编辑体验(schema 快照 + 键/值补全 + 悬停 + 诊断 + 跳转 + 依赖版本补全) | 4 天 | +| **M7** | `build.mcpp` 智能(快照 + provider + 静态诊断 + 模块清单 + 片段) | 3–4 天 | +| **M8** | 缓存统计与清理(聚合 + TreeView + 面板可视化 + 预算模拟器 + 命令) | 5 天 | +| **M9** | 视觉与交互统一(主题令牌、三态、状态栏、快捷键、进度) | 2 天 | + +M0–M2 先做:M0 定文件位置,M1 定文案写法,M2 定设置写法 —— 后面所有功能都会往这三处加东西, +越晚做返工越大。 + +--- + +## 6. 风险与取舍 + +| 风险 | 说明 | 缓解 | +|---|---|---| +| **设置项数量(60 项)本身就是风险** | 认识负担与"简单易用"相矛盾;测试面变大 | 面板默认只展开常用分组,`advanced` 折叠;全部默认"不打扰";`docs/settings.md` 给"推荐配置";预设一键切换 | +| 目录大移动污染 review | 触碰所有 import | M0 拆两提交,提交 1 零行为变化 | +| 配置面板与原生设置页重复 | 用户困惑"改了不生效" | 每项显示 `applies` 与**生效值来源**;给"在原生设置中打开";面板顶部写明与 `mcppls.*` 的边界 | +| 生成的 nls/docs 与 registry 漂移 | 三份文件要同步 | `check-config.mjs` + `l10n-check.mjs` + `git diff --exit-code` 三重门禁 | +| `mcpp.ui.language` 的能力边界 | manifest 文案无法运行时切换 | 写进 `docs/settings.md`;默认 `auto`;定位为"逃生门" | +| 模式 A 下 std 没有符号级补全 | 真实体验缺口 | 文档写清;上游诉求(把构建程序写进 build database);模式 B 的设计保留在附录 D 备查 | +| 依赖版本补全靠解析人类输出 | 与本方案"JSON 优先"原则冲突 | 明确列为唯一例外、默认关、带超时/离线降级/一次性提示;docs 写明"上游给机读格式就替换" | +| 预算模拟器是本地 LRU 近似 | 与 mcpp 实际策略可能有差异 | 标注"预估",只用于"选预算",不承诺删多少;执行后以 mcpp 输出为准 | +| `cache info` 无机器格式 | 详情视图只能展示原文 | 只读预览文档,不解析、不据此决策 | +| U2 的实现成本最高 | TreeView + 面板 + CSP 资源维护面 | 面板可延后到 M8 后半段;先出 TreeView 与命令;逻辑抽成纯函数,UI 只渲染 | +| `onLanguage:cpp` 收敛有副作用 | 无 `mcpp.toml` 的目录不再出现 mcpp 状态栏 | 有意的;写进 CHANGELOG 与 `docs/architecture.md` | +| TreeView/webview 的 e2e 覆盖有限 | Extension Host 里不能点 UI | 逻辑抽纯函数(聚合、格式化、模拟器)单测覆盖;e2e 只断言命令与视图注册 | +| 里程碑估算偏乐观 | M6–M8 都依赖"生成脚本 + 快照 + CI 门禁"三件套 | M0 就把 `tools/` 与漂移 job 骨架搭好 | +| **`extension.exports` 是 mcppls 的测试 API** | 不是契约;上游重构可能让 §3.9 的状态区整块消失 | 形状探测 + try/catch + 枚举白名单;坏掉只少一块内容(不变式 ④);两个 e2e 夹具(`noapi` / `throwing`)钉住降级行为;由 `mcpp.languageService.readState` 可关 | +| **mcppls 的缓存体量无法可视化** | 缓存目录不在任何公开面里(`environment()` 只给版本/平台/宿主),磁盘布局是内部实现 | **不做假数字**;该区块只给"重置本工作区缓存(它自己的命令)+ 日志 + 诊断包",并向上游提 change request(附录 D.5)。若评审坚持要有数字,需要先拿到上游接口 | +| **`readState` 的轮询会放大风险** | 高频调用可能影响 mcppls 主线程 | 默认 `stateRefreshSeconds = 0`(不轮询),只在 `onDidChange`、命令完成、用户手动刷新时读 | + +--- + +## 7. 自我 review(v4) + +### 7.1 这一版改对了什么 + +1. **v2 的 `build.mcpp` 结论基于实测**,v3 把它固化成"只做模式 A",v4 把模式 B 留在附录 D 备查。 +2. **把配置提升为主线**:设置从"功能的一部分"变成"功能的入口"(registry + 面板 + 四重门禁)。 +3. **两级清理与全局缓存分离**:语义上正确(项目级 vs 机器级),避免 `--bmi-cache` 误点导致全机重建。 +4. **i18n 从"改字符串"变成"改机制"**:单一来源 + 生成 + 门禁。 +5. **v4 新增 §3.9**:把 mcppls 的状态与管理并入 mcpp,且**先说清"拿不到什么"** + —— 缓存体量拿不到就不做数字,构建描述条数拿不到就不显示假条数。 + 这一节的价值不在于多做了功能,而在于**把"能做的"和"做不到的"划在同一条界线上**, + 避免以后有人补一个看起来很美的假面板。 + +### 7.2 我仍然不放心的地方(按严重度) + +| # | 问题 | 我的判断 | +|---|---|---| +| 1 | **设置总数 60 项依然偏多**,即便第一版只公开 29 项 | 已按评审意见分 `public`/`advanced`(§4.6);建议下一版按使用反馈只增不减地调整,不新增"品味型"设置 | +| 2 | **§3.9 建在 `extension.exports` 上**,它明确是 mcppls 的**测试 API** | 这是"在不改上游的前提下能拿到 LSP 状态"的唯一通道。已做形状探测 + 白名单 + 两个 e2e 夹具 + 可关设置。**风险与收益都在这里**:如果评审认为不能用,§3.9 就只剩"转发命令"和版本信息 | +| 3 | **mcppls 缓存体量确实看不到** | 不假装。给它的三个替代入口(重置/日志/诊断包)+ 上游 change request。若你希望一定要有数字,需要先推动上游 | +| 4 | **`mcpp.ui.language` 的手动覆盖只能覆盖一半界面** | 保留但标注(已定) | +| 5 | **配置面板与原生设置页的长期关系** | 定位为"集中 + 解释 + 预设 + 边界提示",不取代(已定) | +| 6 | **`mcpp.toml` schema 的枚举需人工补** | 唯一无法全自动处;写进 `tools/` 注释说明来源 | +| 7 | **缓存面板的预算模拟器可能过度设计** | 若 M8 时间紧,**第一个砍它** | +| 8 | **`mcpp clean --dry-run` 只有文本** | 可接受;将来 mcpp 给机读就换 | +| 9 | **§3.9 会让视图容器从 2 个视图变成 3 个** | Activity Bar 的侵入感增加;但 `mcpp.views.languageServer.show` 可关,且视图在 mcppls 不存在时不显示 | +| 10 | **`mcpp search` 解析人类输出仍是唯一的口子** | 已列为待替换项 | +| 11 | **模式 A 的 `mcpp::` 智能依赖 mcpp 源码结构**(`directives.cppm` 的表位置) | 生成脚本读不到就**失败而不是产出空表**;快照带 `sourceVersion`;漂移 job 在 CI 里跑 | + +### 7.3 如果时间不够,砍的顺序 + +1. `mcpp.toml.indexCompletion`(依赖版本补全)—— 唯一需要联网、唯一解析人类输出; +2. 缓存面板的预算模拟器 —— 保留"选预算 + 执行 + 看结果"; +3. `mcpp.ui.language` 的手动覆盖 —— 只保留跟随 VS Code; +4. `mcpp.pack` / `publish --dry-run` 入口 —— 与 IDE 体验关系最弱; +5. `mcpp.ui.notifications.success` / `mcpp.ui.numberFormat` 这类"品味型"设置 —— 固定为默认值即可。 + +**不砍的**:两级清理、缓存统计与可视化、`build.mcpp` 智能、`mcpp.toml` 诊断、配置模块与面板、 +mcppls 降级能力与状态视图、i18n 机制本身。 + +**v4 新增第 0 顺位(最先砍)**:`mcpp.languageService.readState` 走 `exports` 的那条路 +—— 如果评审认为"依赖测试 API"不可接受,§3.9 退化成"只有转发命令 + 版本信息", +其余设计不受影响。 + +--- + +## 8. 评审已决的问题与遗留 + +**v3 的 5 个问题已全部拍板**(见 §0 第 16–20 条):① 第一版公开 31 项、其余 advanced; +② 保留 `mcpp.ui.language` 并标注;③ 保留依赖版本补全;④ 暴露 `cache clean --all` 但走两步确认; +⑤ 配置面板定位为"集中/解释/预设/边界提示",不取代原生设置页。 + +**v4 遗留(只有一条需要你决定)**: + +1. **§3.9 的状态读取是否接受 `extension.exports`**(mcppls 的测试 API)? + - **接受**(推荐):能显示 `state`/`project`/`profile`/`engine(s)`/`progress`/`issues` + (含 S3 自带的修复命令),代价是上游重构后这一块可能消失(已用形状探测 + 白名单 + + 两个 e2e 夹具 + 可关设置兜住)。 + - **不接受**:§3.9 只保留"转发命令 + 版本/激活/启用信息",视图仍然有用但内容少很多; + 我会同时把"上游提供一个正式的状态/缓存查询命令"提为**更高优先级的 change request**。 + +**v4 记录在案的上游诉求**(本方案不改上游,只记录): + +| # | 对象 | 诉求 | 本插件因此获得 | +|---|---|---|---| +| U.1 | mcpp | 在 `mcpp emit build-database` 里描述构建程序自身(含 `-fmodule-file=mcpp=…` 与 std 单元),该命令不写工程目录 | `build.mcpp` 的 std + mcpp 完整语义(附录 D.1 的模式 B 变成默认,无需改本插件) | +| U.2 | mcppls | 把 `mcppls.reloadBuildDescription` 注册为 VS Code 命令 | build 后从"整机重启"降级为"轻量重载" | +| U.3 | mcppls | 把 `mcppls.mcpp` 暴露为可配置设置 | `mcpp.path` 与语言服务看到同一个 mcpp | +| U.4 | mcppls | 书面承诺 4 个命令 ID 的稳定性 | 把下游测试护栏升级为上游承诺 | +| U.5 | mcppls | 注册一个返回**缓存目录与体量**(或直接清理)的公开命令 | §3.9.3 里目前做不到的"缓存占比可视化" | +| U.6 | mcpp | `mcpp search` 提供机读格式 | 去掉方案里唯一的"解析人类输出" | + +--- + +## 附录 A:本方案用到的 mcpp 命令 + +| 命令 | 用途 | 写盘 | 机读 | +|---|---|---|---| +| `mcpp --protocol-version` | 协议/能力探测 | 否 | ✅ | +| `mcpp self env --format json` | 版本、MCPP_HOME、默认工具链 | 否 | ✅ | +| `mcpp toolchain list --format json` | 工具链/target 矩阵 | 否 | ✅ | +| `mcpp cache list --format json` | 缓存条目与体积 | 否 | ✅ | +| `mcpp cache dir` / `mcpp cache info ` | 缓存根 / 条目详情 | 否 | ❌ | +| `mcpp cache verify` | 缓存校验 | 否 | ❌ | +| `mcpp cache prune --older-than d` | 按时间收敛 | 是 | — | +| `mcpp cache gc --max-size GiB` | 按预算收敛(LRU) | 是 | — | +| `mcpp cache clean --deps\|--std\|--all\|--legacy` | 分类清空 | 是 | — | +| `mcpp clean --stale --dry-run` | 过期产物**预演** | 否 | ❌ | +| `mcpp clean` | L1 清理 | 是 | — | +| `mcpp clean --stale --older-than d` | L2 清理 | 是 | — | +| `mcpp clean --bmi-cache` | L1 + 全局缓存(单独、更重确认) | 是 | — | +| `mcpp search [--all-versions]` | 依赖版本补全(默认关) | 否 | ❌ | +| `mcpp update` / `mcpp self doctor` | 依赖更新 / 环境诊断 | 是 / 否 | — | +| `mcpp build/run/test`、`mcpp toolchain install/default` | 现有任务 | 是 | — | + +## 附录 B:实测证据 + +| 结论 | 证据 | +|---|---| +| mcpp 不把 `build.mcpp` 放进编译数据库 | `examples/11-features/greeter` 上 `mcpp emit build-database --spec compile-commands --format json` → 6 条(counters.cppm / greeter.cppm / main.cpp / test_greet.cpp / std.cppm / std.compat.cppm);`build.mcpp` 只在 `data.watch` | +| mcppls 不认识 `build.mcpp` | 全仓 grep 只命中 `target/.build-mcpp/deps/...`(依赖的构建程序产物) | +| 交给 clangd 后 std 与 mcpp 都报错 | `mcppls check build.mcpp` → `module 'std' not found` + `module 'mcpp' not found` + `Failed to build module mcpp`,clangd exit 3 | +| 数据库内的文件零诊断 | `mcppls check src/main.cpp` → clangd exit 0 | +| 构建脚本 API 可机读 | `modules/buildmcpp/src/directives.cppm:297`(31 行表)、`:1132-1134`(5 role)、`program_protocol.cppm:121,147`、`provisions.cppm:80` | +| 缓存数据可机读 | `mcpp cache list --format json` → kind `mcpp.cache`,657 条 / 7.20 GiB / pkg 576 · std 81 / 2 incomplete | +| `--stale` 语义与默认 | `mcpp clean --help`:`--older-than … (default 1d; 0 keeps none; implies --stale)`;`--dry-run` 隐含 `--stale` 且不删 | +| `search` / `info` / `verify` 无机器格式 | 各自 `--help` 只有人类选项;`search` 只有 `--all-versions` | + +## 附录 C:配置项 ↔ 功能 对照(抽查) + +| 功能 | 可配置项 | +|---|---| +| 构建 | `mcpp.task.buildArgs`、`revealTerminal`、`focusTerminal`、`clearTerminal`、`problemMatcher` | +| 清理 | `mcpp.task.cleanArgs`、`confirmClean`、`mcpp.cache.staleDays`、`pruneAgeDays`、`gc.defaultBudgetGiB`、`gc.confirmAboveGiB` | +| 语言服务 | `mcpp.languageService.refreshAfterBuild`、`menuItems`、`notifyOnDegraded` | +| mcppls 状态与管理(§3.9) | `mcpp.views.languageServer.show`、`mcpp.languageService.readState`、`stateRefreshSeconds`、`confirmResetCache`、`mcpp.ui.statusBar.showLanguageServer` | +| `mcpp.toml` | `mcpp.toml.*`(11 项) | +| `build.mcpp` | `mcpp.buildScript.*`(5 项) | +| 视图 | `mcpp.views.*`(5 项) | +| 通知与视觉 | `mcpp.ui.*`(7 项) | +| 排障 | `mcpp.log.level`、`mcpp.diagnostics.selfCheckOnStartup`、`mcpp.runtime.*` | + +## 附录 D:本版**不实现**、仅文档备注的项 + +1. **`build.mcpp` 模式 B(交给 C++ 语言服务)**:实测会让 `import std` 与 `import mcpp` 都报错 + (附录 B),因此不发布开关。文档备注保留这条路径,等上游把构建程序写进 + `mcpp emit build-database`(诉求 **U.1**)之后再启用 —— 那条改动只在 mcpp 一侧, + 本插件无需改动即可受益。 +2. **mcppls 缓存目录的体积与占比**:拿不到(§3.9.3),等诉求 **U.5**。 +3. **未受信任工作区下的 mcpp 只读命令白名单**:当前是一律禁用;将来若 mcpp 的 + `--protocol-version` 能证明某命令零副作用,再考虑放开。 +4. **`mcpp.toml` 格式化**:不做(会改用户文件,且不是本扩展的职责)。 +5. **遥测**:不做。本扩展不收集任何遥测数据。 diff --git a/.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md b/.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md new file mode 100644 index 0000000..03749f3 --- /dev/null +++ b/.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md @@ -0,0 +1,1466 @@ +# UI/UX 优化方案 v2(待 review) + +v1 的四个问题已收到答复,本版按答复重做设计,并补上你新提的三件事:**图标用 mcpp 官方的**、 +**梳理侧边栏**、**侧边栏整体可开关(关掉时左边不出现 mcpp)**。 + +--- + +## 0. 你的答复与我的理解 + +| 问题 | 你的答复 | 确认后的设计 | +|---|---|---| +| Q1 缓存视图形态 | **webview view** | `mcpp.cache` 从原生树改为**侧边栏 WebviewView**,统计与图形都在侧边栏内 | +| Q2 `mcpp: Cache Statistics` | **删除** | 删掉该命令与 `createWebviewPanel` 那条路径 | +| Q3 工程视图的动作 | "什么意思 按理是要能点击有动作的" | **我原来说得不清楚,见 §2。结论:动作保留、可点,只是与数据分区** | +| Q4 图标 | "不能用 mcpp 的 icon 吗,mcppls 里也有 logo" | **可以,而且必须**——现在两个扩展的 logo **不是同一张图**,见 §4 | + +--- + +## 1. 新查明的事实(都有出处) + +| # | 事实 | 出处 | +|---|---|---| +| F1 | mcpp 官方 logo 在 mcppls 仓库里,而且**就是 mcppls 扩展的图标**(两文件字节完全相同,md5 `f9457903…`) | `mcpp-language-server/docs/imgs/mcpp-logo.png` 与 `editors/vscode/icon.png` | +| F2 | **mcpp-vscode 用的是另一张图**(200×200,md5 `2d723543…`),与 mcppls 不一致 | `mcpp-vscode/images/logo.png` | +| F3 | mcppls 的 `mcpp-logo.svg` **不是真矢量**:excalidraw 导出,内部 `href` 是 base64 PNG。**上游没有可用的单色矢量图标** | `docs/imgs/mcpp-logo.svg` 开头即 `` | +| F4 | mcppls **没有贡献 Activity Bar 容器**(`viewsContainers: null`),所以左侧那个 mcpp 图标只可能来自 mcpp-vscode,不存在冲突 | mcppls `package.json` | +| F5 | **"所有视图都隐藏 → 容器从 Activity Bar 消失"是可行的**,但 `when` 必须写成**否定形式**(`!mcpp.sidebarHidden`),否则默认就被隐藏,`activate()` 不会被调用,也就永远没机会把开关设回来 | [VS Code issue #49145](https://github.com/microsoft/vscode/issues/49145)(closed/verified)+ [#48704](https://github.com/microsoft/vscode/issues/48704)(`viewsContainers` 本身不支持 `when`) | +| F6 | 现有开关只有三个**分视图**开关,**缺总开关** | `data/config-registry.json`: `mcpp.views.{project,cache,languageServer}.show` | + +**F5 是本方案的关键**,因为它决定了"关掉侧边栏"到底能不能做到——能,但写法有讲究,不能想当然。 + +--- + +## 2. 回答 Q3:动作不会消失,只是不再和数字混在一起 + +我上一版问的是"是否完全不出现动作",措辞不好,导致看起来像要**拿掉可点击性**。不是这个意思。 + +真实问题不是"有动作",而是**动作和数据是同一层的同级行**,视觉上没有分界: + +``` +现在(工程视图) 改后 +├ 包名 greeter ├ 包名 greeter +├ 标准 c++23 ├ 标准 c++23 +├ Build ← 动作 ├ 目标 x86_64… +├ Run ← 动作 ├ 工具链 llvm@22.1.8 +├ Test ← 动作 └─ 操作 ← 可折叠分组 +├ Test ← 动作 ├ 构建 (tools) +└ Clean project artifacts ← 动作 ├ 运行 (play) + ├ 测试 (beaker) + └ 清理产物 (trash) +``` + +**修订后的总原则(比 v1 更简单)**: + +> **数据区只读;操作区可点;两区之间有明确分界,且操作区默认折叠收起。** + +- 操作**依然是树/面板里可点的行**,点一下即执行——满足你说的"要能点击有动作"。 +- 视图标题栏只放**视图级**动作(刷新、溢出菜单),不再重复放业务动作。 +- 好处:打开视图先看到"是什么状态",要看动作展开一次即可;不需要在两类语义之间反复切换注意力。 + +--- + +## 3. 侧边栏整体梳理 + +### 3.1 结构(容器 `mcpp`,三个视图,顺序固定) + +``` +mcpp +├─ 工程 mcpp.project 树 "这是什么工程" +├─ 缓存 mcpp.cache WebviewView "占了多少、谁占的" +└─ C++ Modules mcpp.languageServer 树 "语言服务什么状态、哪里不对" +``` + +**为什么还是三个而不是合并成两个**:三者的**回答对象不同**,而且 C++ Modules 的内容由另一个扩展提供(`sunrisepeak.mcpp-language-server`)。合并会让"谁负责什么"重新变模糊——这正是上一轮花力气划清的边界。三个视图的取舍权交给用户(§3.3 的开关)。 + +### 3.2 每个视图的内容分区 + +**工程(树)** +``` +身份:包名 / 标准 / 类型 +目标:triple / profile / 编译器 +工具链:当前有效 spec(只读,改它用命令) +操作(折叠):构建 / 运行 / 测试 / 清理 target +``` + +**缓存(WebviewView,三区 + 一个操作条)** +``` +┌ 缓存 ────────────────────────── [↻] [⋯] ┐ +│ 7.20 GiB · 657 项 · 最早 12 天前 │ 区1 数字摘要(只读) +│ ████████████░░░░░░░░ 项目 12% │ 区2 可视化:构成 + 时间分布 +│ ██████████░░░░░░░░░░ std 31% │ 按 200–320px 窄宽度设计 +│ ▸ 最大占用(前 5) ▸ 按类型 ▸ 按时间 │ 区3 明细:只做「查看」钻取 +│ ──────────────────────────────────────── │ 分隔(视觉 + 语义) +│ 操作 清理过期 · 清理工程 · 收敛 · 校验 │ 操作条(与数据区分离) +└──────────────────────────────────────────┘ +``` +- 现在树里的 11 个动作节点全部收敛到这一条。 +- **`mcpp.showCachePanel` 删除**(Q2),`createWebviewPanel` 路径一并删掉,`cachePanelHtml.ts` 的渲染逻辑改为喂给 WebviewView。 + +**C++ Modules(树)** +``` +状态:就绪 / 降级 / 缺失(图标 + 一句话) + C++ Modules 0.0.9(外部扩展,只读) +问题:有问题才出现(引擎未就绪 / 某能力不可用 / 依赖缺失) +操作(折叠):重启 / 重选上下文 / 模块图 / 日志 / 上报 / 诊断包 / 重置缓存 / 身份与冲突 +``` +11 个动作节点从平铺变为一个折叠分组,状态与问题在它上面。 + +### 3.3 开关:一个总闸 + 三个分闸 + +| 设置 | 默认 | 作用 | +|---|---|---| +| `mcpp.views.enabled`(**新增**) | `true` | 总闸。关掉 → **Activity Bar 上的 mcpp 图标整体消失** | +| `mcpp.views.project.show` | `true` | 单个视图开关(已有) | +| `mcpp.views.cache.show` | `true` | 同上 | +| `mcpp.views.languageServer.show` | `true` | 同上 | + +**实现(关键,照 F5 的写法)**: + +```jsonc +// package.json —— when 必须是否定形式,默认才可见 +"views": { "mcpp": [ + { "id": "mcpp.project", "when": "!mcpp.sidebarHidden && mcpp.views.project" }, + { "id": "mcpp.cache", "when": "!mcpp.sidebarHidden && mcpp.views.cache" }, + { "id": "mcpp.languageServer", "when": "!mcpp.sidebarHidden && mcpp.views.languageServer" } +]} +``` +```ts +// extension.ts:设置变化时同步 +void vscode.commands.executeCommand("setContext", "mcpp.sidebarHidden", !read("mcpp.views.enabled")); +``` + +**必须验证的一点**(写进 e2e):`activate()` 的激活事件里**不能只依赖视图**,否则一旦隐藏就再也激活不了。当前激活事件是 `workspaceContains:mcpp.toml` / `onLanguage:*` / 派生的 `onCommand:*`,不依赖视图,所以安全——但这条要作为约束记下来并测试。 + +--- + +## 4. 图标:统一到 mcpp 官方标识(回答 Q4) + +**现状问题**:F1/F2 —— mcppls 用官方的 mcpp logo,mcpp-vscode 用另一张图。同一家族两个扩展并排出现在扩展列表里,看起来像两个不相干的项目。 + +**改法分两处,注意它们的要求不同**: + +| 用途 | 现在 | 改成 | 理由 | +|---|---|---|---| +| 扩展图标(Marketplace / 扩展列表) | `images/logo.png`(200×200,与官方不同) | **官方 `mcpp-logo.png`**(396×396,与 mcppls 同一张) | 家族一致;这是你在扩展列表里看到的图 | +| Activity Bar 容器图标 | 同一张 200×200 彩色 PNG | **单色 24×24 SVG** | Activity Bar 惯例是单色、跟随主题;彩色 PNG 缩到 24px 会糊成暗块,这正是你觉得"没有图标"的原因 | + +**一个必须先说清楚的限制**:F3 —— **上游没有可用的单色矢量 logo**(那个 `.svg` 里包的是 PNG)。所以 Activity Bar 的单色图标**必须新画**,方式二选一: + +- **方案 A(推荐)**:我按官方 logo 的形态**重绘一个极简单色 SVG**(几何化的 mcpp 标记,`fill="currentColor"`),先出 2–3 个候选给你挑。 +- **方案 B**:直接用官方彩色 PNG 作为容器图标(能显示、不改主题、24px 下偏糊)。 + +> 我不建议"自动描摹"官方 PNG——那会得到一个杂乱的路径,比手绘的几何标记更糟。 + +另外,`views` 条目本身也支持 `icon`(视图标题旁的小图标),目前我们一个都没设。**建议不加**:三个视图名称已经足够清楚,再加图标只会让标题栏更挤。 + +--- + +## 5. 任务拆分与依赖 + +``` +T-UI-1 图标统一 依赖:你选 A / B + ├─ 扩展图标换成官方 mcpp-logo.png(与 mcppls 对齐) + └─ 容器图标:单色 SVG(方案 A 需你从候选中挑一个) + +T-UI-2 快捷菜单:ThemeIcon + 分组分隔 + 描述改成"动作后果" 无依赖,可立刻做,风险最低 +T-UI-3 侧边栏梳理:顺序、操作区折叠、"操作"分组 无依赖 +T-UI-4 C++ Modules 重组:状态 / 问题 / 操作(折叠) 依赖 T-UI-3 的分组约定 +T-UI-5 缓存:树 → 侧边栏 WebviewView 依赖 Q1=webview view(已确认) + └─ 删除 mcpp.showCachePanel 与 createWebviewPanel 路径 +T-UI-6 总闸 mcpp.views.enabled + setContext(!mcpp.sidebarHidden) 依赖 T-UI-3 +T-UI-7 一致性守卫(关键) 依赖 T-UI-3~6 + ├─ 断言:视图正文的数据区里不存在命令节点 + ├─ 断言:每个动作的"主入口"在登记表中唯一 + └─ e2e:三个视图全部 when=false 后容器消失;且 activate 仍能被 workspaceContains 触发 +T-UI-8 文档与截图 依赖全部 +``` + +- **T-UI-2 可以现在就做**,最直接回应你的图标反馈。 +- **T-UI-3/4 是纯重排**,不动数据层,可以并行。 +- **T-UI-5 最大**:`createWebviewPanel` → `registerWebviewViewProvider`,注意 WebviewView **不支持 `retainContextWhenHidden`**,切换视图会销毁重建,所以渲染必须是幂等的(现有 `renderPanelHtml(model)` 天然满足)。 +- **T-UI-7 是本方案能不能站住的关键**:把"数据/操作分区"和"动作主入口唯一"变成**测试断言**,否则下一轮很容易又混回去。这与上一轮"64 个设置必须被读取"用的是同一个手法。 +- **T-UI-6 有一个必须测的边界**:隐藏后 `activate()` 还能不能被触发(F5 的陷阱)。 + +--- + +## 6. 需要你定的 3 件事 + +1. **Activity Bar 图标**:走方案 A(我画 2–3 个单色候选给你挑)还是方案 B(直接用彩色 PNG)? +2. **扩展图标是否统一为官方 mcpp logo**(与 mcppls 相同)?我建议是——这正是你问的"不能用 mcpp 的 icon 吗"。 +3. **总闸设置名**:`mcpp.views.enabled` 还是 `mcpp.sidebar.enabled`?(前者与既有的 `mcpp.views.*` 命名一致,我倾向它。) + +**T-UI-2 我建议不等,直接先做**;T-UI-3/4 只要"数据区只读、操作区折叠"这条原则你认可,也可以并行开工。 + +--- + +## 7. 可交互原型(已生成,本地打开) + +**`.agents/prototypes/ui-prototype.html`** —— 单文件、自包含(内嵌 VS Code 真实的 codicon 字体与官方 logo), +双击或用浏览器打开即可。**控制台里可以切:改前 / 改后、三个视图、三种主题、三个图标候选、侧边栏开关**; +拖侧边栏右缘能改宽度,用来验证窄宽度下的表现。 + +原型里的配色不是我自己编的,取自本机 VS Code 1.125.1 的默认主题文件 +(`theme-defaults/themes/2026-dark.json`、`2026-light.json`、`hc_black.json`,含 include 链)。 + +### 图标候选 A 是"真货" + +`mcpp-mark-traced.svg` 是**从官方 `mcpp-logo.png` 自动描摹出来的单色 SVG**(44×44 网格、148 条 +水平游程、4.9 KB),不是手绘近似。做法:解码 PNG → 按亮度分离前景 → 覆盖率采样 → 输出 +`fill="currentColor"` 的路径。所以它能跟随主题变色,可以直接当 Activity Bar 图标用。 +代价是边缘是阶梯状(44 格量化),要更干净需要人工重绘成曲线。 + +另外两个候选用于对比方向:B 是手绘的"模块方块",C 就是现在那张 200×200 彩色 PNG。 + +### 想请你在原型里重点看 + +1. **缓存**:改前是树(11 个可点动作和统计混在一起)→ 改后是侧边栏内的三区布局(摘要 / 图形 / 明细 + 独立操作条)。 +2. **C++ Modules**:改前 11 个动作平铺 → 改后只剩状态与问题,动作收进折叠的「操作」。 +3. **工程**:改前 Build/Run/Test/Clean 与"标准/目标/工具链"同级 → 改后数据在上、操作在下且折叠。 +4. **快捷菜单**:改前无图标、描述是重复的分组名 → 改后有图标、有分组分隔、描述改成"动作后果"。 +5. **Activity Bar 图标**:A / B / C 三个候选直接切换对比。 +6. **侧边栏总闸**:切到"关",确认左侧 mcpp 图标消失、其余图标不动。 + +--- + +## 8. v3:按你的第二轮反馈改(原型已同步重建) + +### 8.1 缓存:分开项目 / 全局,全局默认折叠 + +- **项目缓存**常显:26px 主数字 + `MiB` 单位 + 右侧一行副信息(文件数 / 构建目录),下面一条 6px 细条, + 再一行"其中过期产物约 4.1 MiB",最后两个按钮。 +- **全局缓存**用原生 `
` **默认折叠**,摘要压在一行里(`7.20 GiB · 657 项`);展开后才是构成条、 + 时间条、"最大占用"(再嵌一层折叠)和三个次级按钮。 +- "更优雅"的具体手段(不是形容词):整块只有**一条主条**(6px,不是三条 14px);图例**压成一行**用 `·` 分隔 + 而不是每项一行;数字用 `tabular-nums` 对齐;字号分层(26 / 12 / 11);去掉多余色块边框,靠留白分节。 + +### 8.2 C++ Modules:不显示 clangd + +正文只剩 **状态 → 语言服务 → 问题 → 操作**。`clangd` / 引擎 / 语义 profile 全部移除, +只留 `语言服务 mcppls 0.0.9`。操作从 11 行压成 5 行,其余收进「其它 7 项…」。 +**已用渲染产物断言**:改后 HTML 里 `clangd` 出现 **0** 次。 + +### 8.3 图标:用 mcppls 的官方资源(实测配色,有个坑) + +实测官方 `mcpp-logo.png` 的像素构成:**纯黑 75.1%** · 橙 `#f08c00` 6.8% · 蓝 `#1971c2` 6.1% · 透明 10.4%。 + +**坑**:深色主题的 Activity Bar 背景是 `#191A1B`,**75% 的黑墨迹在它上面几乎不可见**——直接用原图, +看起来就是"没有图标",正是你最初的抱怨。浅色主题(`#F8F8F8`)则正常。 + +原型的三个候选(都不再手绘,上一版那张描摹已删): + +| 候选 | 做法 | 代价 | +|---|---|---| +| A | 官方 PNG 原样 | 深色主题下黑墨迹消失 | +| B | 官方 PNG 放在浅色圆角底上 | 保留全部色彩;Activity Bar 里多一个浅色小方块 | +| C | 官方图形里的**黑色部分机械换成 `currentColor`**(橙蓝丢弃) | 单色、随主题变色;不是手绘,但仍是一次派生 | + +### 8.4 库市场:可行性(已在 mcpp 源码里核对) + +| 能力 | 现状 | +|---|---| +| 写入 `mcpp.toml` | ✅ `mcpp add [--dev]`,官方命令就是"Add a dependency to mcpp.toml" | +| 搜索包 | ⚠️ `mcpp search [--all-versions]` — **没有 `--format json`** | +| 包详情 | ⚠️ `mcpp info ` 只对**已缓存**的包,且无 JSON | +| 包列表 | ❌ `mcpp list` 的三个都是"工具链 / 缓存条目 / 已配置 registry" | +| 索引数据 | `mcpp-index` 仓库:238 个 Lua 描述符,含 name/namespace/description/licenses/repo/deps/多平台版本 | +| 对比 | `build` `pack` `why` `build-database` `parse` `cache list` `env` 等约 10 个子命令**都有 `--format json`** | + +三条路:**(a)** 请上游加 `mcpp search --format json`(与既有 10 个命令一致,改动小)← 推荐; +**(b)** 解析人类输出(脆弱,违反本项目"只有一处解析人类输出"的原则); +**(c)** 直接读 index 仓库的 Lua(重复 mcpp 的职责,还要自己找索引路径)。 + +推荐 (a) + 优雅降级:没有机器输出时,界面显示"需要更新的 mcpp"而不是坏掉——能力探测机制已经在了。 + +形态上我倾向**轻的那条**:`mcpp: 添加依赖…` 一个 quick pick(关键词 → 列表 → `mcpp add`), +放在工程段「常用命令」里。理由是写 `mcpp.toml` 本来就是"工程"的事,也不给侧边栏再加一段噪音。 + +--- + +## 9. v4:三段结构 + 库生态(原型已同步重建) + +### 9.1 图标:采纳官方 PNG 原样 + +你确认 2026 Dark 下黑底与 Activity Bar 有可见差别。实测支持这个判断:logo 黑是 `#000000`, +深色主题 Activity Bar 是 `#191A1B`——不是纯黑对纯黑,所以能看出一块更深的底加亮色图形。 +**采纳候选 A**:扩展图标与 Activity Bar 容器图标都用官方 `mcpp-logo.png`, +两个扩展从此是同一张图(当前 mcpp-vscode 用的是另一张,md5 不同)。B/C 仅留作备查。 + +### 9.2 侧边栏改为三段:工程 / mcpp 库生态 / 缓存 + +`mcpp.languageServer` 视图取消,内容折进**工程 → 基本信息**,默认折叠。 + +**前提(否则这个折叠是有害的)**:折叠状态下必须能看出好坏。所以折叠那一行自带状态图标 +(健康绿 / 降级黄),降级时右侧显示 `mcppls 0.0.9 · 1 个问题`。**不展开也能发现异常**是 +这一条能被接受的条件;否则问题会被藏起来,比不放更糟。 + +**一个取舍**:C++ Modules 的内容由**另一个扩展**提供。折进工程会让"谁负责什么"这条边界重新 +变模糊。缓解:展开后的块里固定一行「提供方 sunrisepeak.mcpp-language-server」。 + +### 9.3 库生态:用 mcpp-index + `mcpp xpkg`,不动上游(已实测) + +| 环节 | 实测结果 | +|---|---| +| 描述符解析 | **`mcpp xpkg parse --json` 存在且好用**:`{"namespace":"compat","name":"argparse","versions":{"linux":["3.2"],"macosx":["3.2"],"windows":["3.2"]},"standard":"c++23","sources":[…],"include_dirs":[…],"targets":["argparse"],"unknown_keys":[]}` | +| 索引位置 | `mcpp index status` 给出表格,含每个 registry 的 `path`;**且 `~/.mcpp/registry/data/*/pkgs` 可直接 glob**,无需解析输出 | +| 目录规模 | `mcpplibs`(官方 C++ 库索引)**239 个包**;另有 `xim-pkgindex`、`xim-pkgindex-local` | +| 目录字段 | 每个 `.lua` 含 `namespace` / `name` / `description` / `licenses` / `repo` | +| 添加依赖 | `mcpp add @ [--dev]`,官方命令直接写 `mcpp.toml` | + +**关键限制**:`mcpp xpkg parse --json` **不含** `description` / `licenses` / `repo` +——那些属于 `package` 表的目录字段,不在 resolver 语法里。所以: + +- **列表**(239 条,必须便宜):容错读取每个 `.lua` 里的几个**单行字符串字段**。 +- **详情与版本**:按需调 `mcpp xpkg parse --json`(版本按平台分组,权威)。 +- **绝不为 239 个包各起一个进程**——那会卡死;批量阶段只读文本,进程只在点开某一行时起。 +- 读不出来的条目用文件名兜底,详情仍可用。 + +**索引路径**:默认 glob `/.mcpp/registry/data/*/pkgs`,配 `mcpp.library.indexPath` 覆盖。 +`XLINGS_HOME` 不在环境变量里,且 `mcpp index status --format json` 是 unknown option, +所以走 glob 而不是解析 `mcpp index status` 的表格——避免出现第二处"解析人类输出"。 + +### 9.4 还没定的(下一轮要你拍板) + +1. **"已添加"怎么判定**:mcpp 没有列依赖的子命令(`mcpp list` 三个分别是工具链 / 缓存条目 / + 已配置 registry)。所以只能读 `mcpp.toml` 的 `[dependencies]`——我们本来就在解析它。 +2. **要不要区分"已声明"与"已缓存"**?两者不同:声明在 `mcpp.toml`,缓存在全局缓存目录。 +3. **搜索范围**:只搜本地索引(离线、快、可预期),还是也允许 `mcpp search`(覆盖其它 registry、 + 可能联网)? +4. **版本怎么选**:默认最新 / 让用户挑 / 跟随工具链兼容性? + +--- + +## 10. v5 定稿:按你的四条决定收口 + +### 10.1 图标 +采纳官方 `mcpp-logo.png` 原样,扩展图标与容器图标同一张(B/C 撤销)。 + +### 10.2 库生态 + +**搜索分两档,开关默认关闭联网:** + +| 档 | 数据源 | 默认 | 成本 | +|---|---|---|---| +| 本地索引搜索 | `~/.mcpp/registry/data/*/pkgs` 的 239 个描述符 | **开** | 离线、一次扫完、可预期 | +| 全 registry 搜索 | `mcpp search ` | **关**(`mcpp.library.searchRegistries`) | 可能联网;且只有人类输出 | + +> 联网那一档只有人类输出,所以打开时按"尽力而为"处理:解析失败就退回本地结果并提示, +> **不抛错、不静默失败**。这条要说在设置描述里。 + +**版本默认最新——但 mcpp 要求显式版本,所以"最新"必须由我们算出来:** + +实测 `mcpp add compat.argparse`(不带版本)会被拒: +``` +error: package version required: `mcpp add compat.argparse@` (M2 supports exact-version only) +``` +所以流程是: +1. `mcpp xpkg parse --json` → `versions: {linux:[…], macosx:[…], windows:[…]}` +2. 取**当前平台**那组,按版本序取最大 → `3.2` +3. `mcpp add compat.argparse@3.2`(dev 依赖加 `--dev`) + +**语义澄清**:`mcpp add ` 与 `mcpp add @` 是两件事,前者会被拒。 +界面上显示"最新 3.2",实际执行的是带版本的命令——不要让人以为我们省略了版本。 + +### 10.3 依赖树:**只能做两级,且必须说清楚** + +我实测了三条数据源,结论如下: + +| 来源 | 能给什么 | 机器可读? | +|---|---|---| +| `mcpp.toml` | **声明的**依赖(`[dependencies]` / `[dev-dependencies]`,含 version/path/git/features) | ✅ 文本,我们已在解析 | +| `mcpp.lock` | **解析到的**包集合:`[package."ns.name"]` + `namespace`/`version`/`source`/`hash`,**扁平、无父子边** | ✅ 结构化 TOML | +| `mcpp why deps` | 传递依赖的解释 | ❌ **实测被拒**:`error: --format json is defined for 'mcpp why toolchain'; 'deps' has no machine-readable shape yet` | + +所以**今天做不出真正的传递依赖树**。方案改为诚实的**两级展示**: + +``` +依赖 (4) +├ mcpplibs.cmdline 最新 0.3.1 · 已解析 0.0.1 ← 声明 → lock 解析值 +├ compat.argparse 最新 3.2 · 已解析 — ← 声明了但还没 lock +├ compat.gtest dev 最新 1.15.2 · 已解析 — +└ counters path ../counters +``` + +- 第一级 = `mcpp.toml` 的声明(含 `dev` / `path` / `git` 标记)。 +- 第二级 = `mcpp.lock` 里按名字匹配到的解析版本;没有就是"—"(未解析或未构建过)。 +- **不画连线**,因为 lock 里没有父子关系,画了就是编的。 +- 真正的传递树挂到上游请求:**U.7 `mcpp why deps --format json`**(`why toolchain` 已经有了, + 补齐 `deps` 是同一类改动)。等它落地再把两级升级成树。 + +### 10.4 顺带修正一处此前的说法 + +v3 里我写"`mcpp why ` 有 `--format json`,可以解释谁把它拉进来的"——**这是错的**。 +实测只有 `why toolchain` 有 JSON,`why deps/sources/tool` 都没有。已在此更正。 + +--- + +## 11. 自我 review(对方案本身,不是对代码) + +### 11.1 我实测过、有证据的(可以当事实用) + +| 结论 | 证据 | +|---|---| +| `mcpp xpkg parse --json` 可用 | 实跑 `compat.argparse.lua`,返回含 `versions` 的 JSON | +| 官方 C++ 库索引 239 个包 | `find ~/.mcpp/registry/data/mcpplibs/pkgs -name '*.lua' \| wc -l` = 239 | +| 索引路径可 glob,无需解析输出 | `~/.mcpp/registry/data/*/pkgs` 三个 registry 都在 | +| `mcpp index status --format json` 不存在 | 实跑 → `error: unknown option: --format` | +| `mcpp add` 必须带精确版本 | 实跑 → `error: package version required` | +| `mcpp why --format json` 只对 toolchain | 实跑 → `'deps' has no machine-readable shape yet` | +| `mcpp.lock` 是扁平的解析集合 | 读 mcpp 仓库真实 lock:`[package."ns.name"]` + version/source/hash,无父子边 | +| 官方 logo 75.1% 纯黑 | 解码像素统计 | +| `mcpp why --format json` 走标准信封 | 顶层键 `data/diagnostics/effects/kind/kindVersion/mcpp/schemaVersion` | + +### 11.2 我没验证、属于推断的(**别当结论**) + +1. **库视图的性能**:239 个文件读一遍到底多快,我没有实测。若首次渲染 > 300ms, + 需要加缓存或懒加载——这一点等实现时先量再定。 +2. **描述符的字段容错**:我抽样读了 4 个 `.lua`,确认 `namespace/name/description` 是单行 + 字符串。**没有验证全部 239 个是否都规范**——可能有换行、引号转义、或缺失。设计里已要求 + 用文件名兜底,但没有实测兜底覆盖率。 +3. **`mcpp add` 是否联网**:加索引包大概要拉索引/校验,但我没测它的耗时与失败表现。 + 界面上"添加"必须给进度与失败路径,这一点我写进了方案但没验证。 +4. **WebviewView 在 200px 窄宽度下的真实表现**:原型是 HTML 模拟,不是真实 VS Code 窗口。 +5. **三个视图全关后容器是否真的消失**:依据是 VS Code issue #49145(closed/verified)和一篇 + 第三方文章,**我没有在真实 VS Code 里验证过**。这是整个"侧边栏可关闭"的地基,落地时 + 必须先写一个 e2e 把它钉死,再写别的。 +6. **C++ Modules 折叠的观感**:折叠后能否"不展开就看出异常",是我认为的关键,但只能靠你看原型判断。 + +### 11.3 方案自身的风险 + +| 风险 | 说明 | 对策 | +|---|---|---| +| 第二处"解析人类输出" | 联网搜索档要解析 `mcpp search` | 默认关闭 + 失败即退回本地;已登记 U.8 `mcpp search --format json` | +| 读 239 个 Lua 是否算"重造 mcpp 的职责" | 有争议 | 限度写死:只读 4 个单行目录字段;**任何权威值走 `mcpp xpkg parse`** | +| 索引路径硬编码 `~/.mcpp/registry` | 非默认安装会找不到 | `mcpp.library.indexPath` 可覆盖;找不到时给明确提示而不是空列表 | +| 三段结构让 C++ Modules 的归属变模糊 | 上一轮刚划清的边界 | 展开块固定一行「提供方」;折叠行显示 mcppls 版本 | +| 工程段因为加了「常用命令」反而变长 | 与"简洁"目标相悖 | 你已确认可接受;若要更短可把常用命令收成折叠 | + +### 11.4 我在前几版里说错、已更正的 + +1. v1 把 Q3 问成"是否完全不出现动作"——**措辞导致误解**,你回"按理是要能点击有动作的"。 + 实际问题是"动作与数据同级混排",不是"要不要动作"。 +2. v3 说 `mcpp why ` 有 JSON 可解释依赖来源——**错**,只有 `why toolchain` 有。 +3. v3 提议"我画一个单色 SVG"——你没要,实际做法是从官方图机械派生(后来连这个也撤销了, + 直接用官方原图)。 +4. v2 说容器图标"看起来像没有图标"是因为 PNG 糊——**部分对**:真实原因是当时那张与官方 + 不是同一张图,且黑墨迹在深色主题下对比度低。 + +--- + +## 12. v6 定稿:详情页 / 示例代码 / 索引站的定位 + +### 12.1 包详情放在编辑器区(已定) + +侧边栏 = 搜索 + 列表;编辑器 = 详情页。列表行必须自足(名称 / 一行描述 / 用法标签 / 版本 / 状态), +不强迫开详情页。 + +### 12.2 "要索引站干什么"——定位说清楚 + +**不是为了拿数据。** 数据全在本地(见 §13.2)。索引站只有三个作用: + +1. **深链**:实测包页 URL 是 `https://mcpplibs.github.io/mcpp-index/packages/./` + (站点根页面里就能看到 `packages/compat.argparse/`、`packages/mcpplibs.cmdline/`)。 + 详情页上放一个**低调的**「在索引站打开」链接即可。 +2. **词表来源**(真正的价值):`SURFACES` + 徽标 + facet 的权威定义就在 + `.xpkgindex/plugins/mcpp.py` 里。用它,两处说同一套词。 +3. **信息层级对齐**的参照:分组顺序、徽标位置、字段取舍。 + +结论:**索引站是一个链接和一个词表,不是数据源。** 没有它这个功能照样成立。 + +### 12.3 示例代码代替 README(已定) + +README 不做(索引里没有,要联网去上游拉)。 + +改为**官方索引的示例代码**,数据源就在索引仓库里、**完全离线**: + +| 事实 | 实测 | +|---|---| +| 位置 | `tests/examples//mcpp.toml` + `tests/examples//tests/*.cpp` | +| 规模 | **172 个示例工程** | +| 契约 | 插件 `_scan_examples` 的产物:包 → `{project, path, paths[], count}` | +| 价值 | 这些是 **CI 真实构建并运行过**的代码,不是为网站写的片段(插件注释原话) | +| 抽取 | 从 `tests/*.cpp` 里取 `import x.y;` / `#include ` 行——即 `SURFACES` 判定所依赖的同一批行 | + +### 12.4 openkal 标识保留(已定) + +`openkal-ecosystem` / `openkal-compat` 作为 facet,`posix 环境` / `使用平台接口` 作为徽标。 +注意插件里的原话:这两个是**实测结果**(`tests/openkal/compat.py` 记录在 +`.xpkgindex/openkal-compat.json`),描述符里**没有**对应字段。所以我们要读那个 json,不能推导。 + +### 12.5 标签词表最终确定 + +| 层 | 取值 | 来源 | +|---|---|---| +| **用法标签(主)** | `import` / `#include` / `tool` / 上游 mcpp.toml | `SURFACES`,由 `xpkg parse` 字段推导 | +| 徽标 | `✓ 有示例` / `国内镜像` / `openkal-ecosystem` / `openkal-compat` / `posix 环境` / `使用平台接口` | 示例扫描 + 描述符 url 里的 `CN` + `.xpkgindex/openkal-compat.json` | +| 命名空间 | `mcpplibs` / `compat` / `llvm` / `khronos` / `freedesktop` … | 描述符 `namespace` | +| 状态 | 已添加 / 可添加 / **有更新** | `mcpp.toml` + `mcpp.lock` vs 索引最新 | + +上一版的 A–I 形状**废弃**(那是"怎么构建",给索引维护者看;`SURFACES` 是"怎么使用",给读者看)。 + +--- + +## 13. 综合自我 review(v6,对整套方案) + +### 13.1 需求 → 落点对照(有没有漏的) + +| 你提的 | 落点 | 状态 | +|---|---|---| +| 缓存可视化更优雅 | §8.1 一条 6px 主条 + 单行图例 + tabular-nums + 字号分层 | ✅ | +| 项目缓存 / 全局缓存分开,全局默认折叠 | §8.1 `
` 默认折叠,摘要一行 | ✅ | +| 不显示 clangd,只显示 lsp mcppls | §8.2 正文只留 状态 → 语言服务 → 问题 → 操作 | ✅ | +| 用官方 PNG/SVG,不自己画 | §9.1 官方 `mcpp-logo.png` 同图 | ✅ | +| C++ Modules 折进工程·基本信息 | §9.2,**前提**:折叠行自带状态图标 | ✅ | +| 三段:工程 / mcpp 库生态 / 缓存 | §9.2 | ✅ | +| 工程段有基本信息 + 常用 mcpp 命令 | §9.2 两块分区 | ✅ | +| 库市场 | §10.2 + §12 | ✅ | +| 联网搜索开关,默认关 | §10.2 | ✅(v6 后只剩跨 registry 一个用途) | +| 默认最新版本 | §10.2 由 `xpkg parse` 算出再显式传入 | ✅ | +| 显示依赖树 | §10.3 **降级为两级、不画连线** | ⚠️ 受上游限制 | +| 标签参考 mcpp-index | §12.5 改用官方 `SURFACES` | ✅ | +| 包详情 | §12.1 编辑器区 | ✅ | +| README 换成官方索引的示例代码 | §12.3,172 个示例工程,离线 | ✅ | +| openkal 标识 | §12.4,读 `openkal-compat.json` | ✅ | +| 风格对齐官方网页 | 词表/层级/文案对齐;**配色只跟 VS Code 主题** | ⚠️ 有意不对齐配色 | + +### 13.2 数据源清单(每个字段从哪来、要不要联网、是不是权威) + +| 字段 | 来源 | 离线 | 权威性 | +|---|---|---|---| +| 包名 / 命名空间 | 描述符文件名 + `xpkg parse` | ✅ | 权威 | +| 描述 / 许可 / 仓库 | 描述符 `package.{description,licenses,repo}` | ✅ | 权威 | +| 版本(按平台) | `mcpp xpkg parse --json` → `versions` | ✅ | 权威 | +| 用法标签 | `xpkg parse` 字段组合 → `SURFACES` | ✅ | 推导 | +| 示例代码 | 索引仓库 `tests/examples//tests/*.cpp` | ✅ | **真实(CI 跑过)** | +| openkal facet | `.xpkgindex/openkal-compat.json` | ✅ | 实测记录 | +| 已声明依赖 | `mcpp.toml` | ✅ | 权威 | +| 已解析版本 | `mcpp.lock` | ✅ | 权威 | +| 有更新 | 索引最新 vs lock 解析 | ✅ | 推导 | +| 传递依赖树 | — | — | ❌ 上游没有机器可读形状 | +| README / star | GitHub API | ❌ | **不做** | + +**一个重要的简化**:v6 之后,**整个库视图零网络**。联网只剩"跨 registry 搜索"这一个用途, +所以那个开关的语义变清晰、风险也变小了。 + +### 13.3 我没验证的(别当结论) + +1. **"所有视图关闭 → 容器从 Activity Bar 消失"没有在真实 VS Code 里验证过**(依据是 issue #49145 + 已 closed/verified + 第三方文章)。这是"侧边栏可关闭"的地基,**落地第一步就要写 e2e 钉死**。 +2. 233 个描述符读一遍的耗时没实测;若 > 300ms 需要缓存。 +3. 全部 233 个描述符的字段规范性没验证(只抽样了 6 个)。 +4. `mcpp xpkg parse` 的输出我只看了 3 个包;`unknown_keys` 非空的包会怎样没试。 +5. WebviewView 在 200px 窄宽度的真实表现——原型是 HTML 模拟。 +6. `mcpp add` 是否联网、耗时多少、失败表现,没测。 +7. `.xpkgindex/openkal-compat.json` 的字段结构没细看(只确认它存在且是实测记录)。 + +### 13.4 风险 + +| 风险 | 对策 | +|---|---| +| 联网搜索档要解析 `mcpp search` 人类输出(项目原则是只有一处) | 默认关闭 + 失败退回本地;登记 U.8 | +| 读 Lua 描述符是否算"重造 mcpp 职责" | 限度写死:只读目录字段与示例关联;**任何权威值走 `mcpp xpkg parse`** | +| 索引路径硬编码 `~/.mcpp/registry` | `mcpp.library.indexPath` 可覆盖;找不到给明确提示而非空列表 | +| C++ Modules 归属变模糊 | 展开块固定「提供方」行 | +| 工程段因加常用命令变长 | 已确认可接受;需要更短时收成折叠 | +| 词表跟着上游变 | 词表**从插件推导而非硬编码**;上游改了词会跟着变(也可能破坏,需容错) | + +### 13.5 我在这个过程中说错、已更正的(累计 5 条) + +1. v1 把 Q3 问成"要不要动作"——措辞误导,真实问题是"动作与数据同级混排"。 +2. v2 说容器图标糊是因为 PNG——部分对;真实原因是用的是**另一张图**,加上黑墨迹在深色主题对比度低。 +3. v3 说 `mcpp why ` 有 JSON 可解释依赖来源——**错**,只有 `why toolchain` 有。 +4. v3 提议我手绘单色 SVG——你没要;后来改为从官方图机械派生,最终撤销,直接用官方原图。 +5. **v5 提的 A–I 形状标签是错的**——那是"怎么构建",官方站用的是"**怎么使用**"(`SURFACES`)。 + 你让我参考 `mcpp-index` 仓库,直接纠正了这一条。 + +--- + +## 14. round 2:一条被我自己推翻的假设(重要) + +**§13.3 第 1 条说"所有视图关闭 → 容器从 Activity Bar 消失"只是依据二手资料、没验证过。 +我在本机 VS Code 1.132 的源码里查了,结论是:不成立。** + +证据链: + +1. `paneCompositeBar.ts` 里,Activity Bar 的每一项由 + `showOrHideViewContainer(container)` 决定:`shouldBeHidden(container)` 为真才 `hideComposite`。 +2. `shouldBeHidden` 的第一段是**决定性**的: + ```ts + if (viewContainer) { + if (viewContainer.hideIfEmpty) { … } + else return false; // ← 没有这个标志就永远不隐藏 + } + ``` +3. `hideIfEmpty` 的注释是「If enabled, view container is not shown if it has no active views」, + 全仓库**只有一处**赋值:`registerGeneratedViewContainer()`(VS Code 自己给"用户自定义容器"用的), + 值为 `true`。 +4. 我在已安装的 `workbench.desktop.main.js`(1.132)里把 `hideIfEmpty` 的 **24 处**全部看了: + 每一处都是**内置容器**的注册(ports / test / voice / debug / 用户容器 …), + **没有任何一处从扩展清单读取**。 + +所以:**扩展无法把自己的容器标记为"空则隐藏"**。对第三方容器,把所有视图 `when` 设为 false 只会 +让**内容**消失,图标会留下(点开是空面板)。我原先依据的 VS Code issue #49145(closed/verified) +和那篇第三方文章,对 1.132 不再适用。 + +### 已做的修正 + +- `mcpp.views.enabled` 的标题与描述改成实话:**「隐藏 mcpp 视图内容」**,并说明图标是否移除由 + VS Code 决定、要移除可在图标上右键。中英同步,注册表与 nls 一致(`check-config` 通过)。 +- `when` 门控保留——**内容隐藏这件事是确定的**(走的是 `activeViewDescriptors.length === 0` 那条路径)。 +- 教训记在这里:**二手资料必须用一手源码或真实运行验证**。这条假设是整套里风险最高的地基, + 幸好先查了。 + +### 另外两处本轮修掉的 + +- `mcpp.internal.markIndex` 之前注册了却没人调用,`mcpp.library.updateIndex` 装好索引后 + `viewsWelcome` 不会消失。改成 `markIndexFound()`,在激活时与 `index update` 之后都重算。 + **(§15.4 更正:这条整段作废——webview 视图根本不支持 `viewsWelcome`,`markIndexFound` + 已于 round 3 连同 `mcpp.library.indexFound` 一起删除。)** +- `test/architecture.test.ts` 补上库生态的 4 个纯模块(`indexModel` / `xpkg` / `libraryHtml` / + `detailHtml`),让"纯模块不得依赖 vscode"这条覆盖到新代码。 + +## 15. round 3:你在真实实例里给的 6 条反馈 + +这一轮的每一条都是先在**一手源码**或**本机真实运行**里定位到根因,再改。下面把证据一起留下, +因为它们决定了改法——其中两条的"根因"和最初看起来的完全不是一回事。 + +### 15.1 基本信息默认折叠、常用命令默认展开(反馈 1、2 前半) + +数据层一行的事:`buildProjectTree` 里 `project.section.basic` 去掉 `expanded: true`, +`project.section.commands` 保留。测试从「两个 section 都展开」改成 +「命令展开、基本信息 `expanded === undefined`」。 +dev profile 的 `workspaceStorage` 会在重启前清掉,否则 VS Code 会用上次记住的展开状态覆盖默认。 + +### 15.2 通用命令图标带色(反馈 2 后半) + +树**支持**颜色,快捷菜单**不支持**——两者机制不同,所以这里能做的和那里能做的不是一件事。 + +树的证据(1.132 `workbench.desktop.main.js`,`CustomTreeView` 的渲染分支): + +```js +this.shouldShowThemeIcon(!!r, n.themeIcon) && (L = j.asClassName(n.themeIcon), + n.themeIcon.color ? i.icon.style.color = this.themeService.getColorTheme() + .getColor(n.themeIcon.color.id)?.toString() ?? "" : L = L + " codicon-colored") +``` + +所以 `TreeNode` 加了 `iconColor?: string`(`ThemeColor` id),只允许 `charts.*` 这类所有主题都 +会定义的 id;主题没定义时 VS Code 让颜色为空串,图标退回普通前景色,不会变成看不见。 +配色按**这一行作用于什么**分组,不按装饰:蓝=构建/添加、绿=运行/校验、紫=测试、橙=删除、 +黄=工具链、设置保持中性。 + +### 15.3 快捷菜单:图标 + 分组(反馈 3) + +`quickMenuItems` 现在每条带 `icon`,菜单按 `QUICK_MENU_GROUPS` 插 `QuickPickItemKind.Separator` +做分组;分组标题与条目共用一张表,所以不可能出现"条目的分组没有标题"。 + +**颜色做不到,这是 API 限制,不是没做。** 1.132 的 `MainThreadQuickOpen.expandIconPath`: + +```js +expandIconPath(o){ let e = o.iconPathDto; + if (e) + if (j.isThemeIcon(e)) o.iconClass = j.asClassName(e); // ← color 被丢掉 + else if (Qh(e)) { let t = P.from(e); o.iconPath = { dark: t, light: t }; } + else { … } } +``` + +`ThemeIcon` 被压成一个 codicon class,列表渲染只写 +`i.icon.className = "quick-input-list-icon " + r.iconClass`,没有任何一处读颜色。 +所以这一轮**只做了图标形状 + 分组**,没有塞一个"永远画不出来"的颜色。顺带把 5 个分组标题与 20 条 +条目全部补上中文(原来 27 个 labelKey 里 20 个没有译文,中文用户看到的是英文),并加了 +`test/commands/menu.test.ts` 把"每个 labelKey 必须有译文""分组必须连续"钉住——这两件事 +`tools/l10n-check.mjs` 看不见,因为菜单标签是 `t(item.labelKey)` 而不是字面量。 + +### 15.4 库视图与「刷新 mcpp 包索引」毫无反应(反馈 4) + +**根因不是索引定位,是 provider 从来没注册过。** 上一轮我把 `registerWebviewViewProvider` +从 `libraryView.ts` 里"交代给调用方",`extension.ts` 的注释却写着"provider 由 registerLibraryView +自己注册"——两边互相甩锅,结果**整个 `src/library/` 里一次都没有这个调用** +(`grep -rn registerWebviewViewProvider src/` 当时只有 `cachePanel.ts` 一处)。 +没有注册就没有 document,`refresh()` 永远打在 `this.view === undefined` 上: +视图是空的,刷新是无声的。修法是在 `registerLibraryView` 内部注册(与 `registerCachePanel` +同构),并加了一条**能抓住这类错误**的门禁: + +``` +test("every webview view registers the provider that fills it") + → 每个 "type": "webview" 的视图 id 必须有唯一一个 `export const *_VIEW_ID = ""` 模块, + 且该模块必须调用 registerWebviewViewProvider +``` + +索引定位按你的建议增强了,但放在**最后**,因为它不是这次的根因: + +| 顺序 | 找法 | 代价 | +| --- | --- | --- | +| 1 | `mcpp.library.indexPath`(用户显式设置) | 0 | +| 2 | `$MCPP_HOME/registry/data`、`~/.mcpp/registry/data`,**有内容的都列** | 几次 `readdir` | +| 3 | `mcpp self env --format json` 的 `mcppHome` | 每会话最多 1 个进程,且只在 2 全空时才跑 | + +第 3 步走机器协议(`schemaVersion` + `kind`,见 `src/cli/protocol.ts` 的检测规则), +在**不受信任的工作区跳过**(`mcpp.path` 是 resource 作用域,不能让工作区指定要跑的二进制)。 +本机实测(用 `Module._load` 打桩 `vscode` 后直接跑编译产物): + +| 场景 | 结果 | +| --- | --- | +| 设置指向 mcpp-index 检出 | 1 个 root / 238 包 / 146 ms,二次 0 ms(快照缓存命中) | +| 默认(不设) | 3 个 root / 543 包 / 228 ms | +| 两个 home 都空 → `mcpp self env` | 3 个 root / 543 包 / 674 ms | +| mcpp 缺失 | 空 / 5 ms,不抛 | + +(写这条时踩了自己的一个 bug:`defaultDataDirectories` 先用探针**之前**的候选列表判断 +"探针给的主目录是不是新的",而探针恰恰是把它加进列表的那一步,于是永远判定"不新"、 +永远返回旧列表。改成探针后重读候选列表。这个 bug 只有在真的构造出"两个 home 都空"的场景 +才会暴露——上面那张表就是这么写的。) + +「刷新 mcpp 包索引」另外两处修:成功后**有反馈**了(进度通知 + 「已刷新:N 个包,来自 M 个索引目录」, +之前成功时零输出,看起来就是坏的),并且补上不受信任工作区的拦截。 + +顺手删掉两处**已被证伪的死代码**:`mcpp.library.indexFound` 上下文键与 `viewsWelcome.library` +文案。1.132 里基类 `ViewPane` 的 `shouldShowWelcome(){return !1}`,只有 tree 类视图覆写它 +(`TreeViewPane` 用 `dataProvider.isTreeEmpty`),所以 `"type": "webview"` 视图**永远不会** +渲染 welcome 内容;库视图在自己的 document 里说空状态,那是唯一会被显示的地方。 + +### 15.5 全局构建缓存的分布条是黑的(反馈 5) + +根因是 CSS 选择器和渲染出来的 DOM 差了一层: + +``` +渲染:
… +样式: [data-viz="composition"] rect[data-kind="pkg"] { fill: … } ← rect 上没有 data-kind +``` + +`rect[data-kind]` 谁都不匹配 → 每个 `` 落到 SVG 的默认填充(**黑**,任何主题都是黑)。 +图例里的 `.swatch[data-kind="pkg"]` 是打在 `` 上的,所以**图例有颜色、条没有**, +正好就是你看到的样子。修法:选择器改到 ``(`fill` 是继承属性,会漏到子 ``), +并在 `test/views/cachePanelHtml.test.ts` 加了一条**文档与样式表互查**的测试: +既要求文档真的把属性打在 `` 上,也要求样式表为每个值都有 `fill` 规则,同时**禁止** +`rect[data-…]` 这种写法再出现。 + +### 15.6 活动栏的 mcpp logo 是白的 / 不显示(反馈 6) + +一手证据在 `ActivityAction.toCompositeBarActionItem`(1.132): + +```js +let c = kc(i), l = new ab; l.update(c); let u = `activity-${e.replace(/\./g,"-")}-${l.digest()}` … +Sf(p, ` + mask: ${c} no-repeat 50% 50%; + mask-size: var(--activity-bar-icon-size, ${this.options.iconSize}px); + -webkit-mask: ${c} no-repeat 50% 50%; …`) +``` + +**自定义容器图标是被当模板(mask)用的**:画出来的是图标的 **alpha 通道**,颜色来自主题 +(`.style-override` 下未选中是 `--vscode-icon-foreground`、选中是 `--vscode-foreground`, +hover/active 是 `--vscode-activityBar-foreground`)。而官方 logo 是一张 +**377×377 不透明黑色圆角方块**(实测:不透明覆盖率 90%,其中 81.5% 是纯 `#000000`、 +6.7% `#f08c00`、5.7% `#1971c2`)。拿它当模板,自然就是**一整块浅色方块**——你看到的"白色"。 + +修法:`images/activity-bar.png` 改为**派生资产**——透明底 + 只留字形的白色 alpha +(96×56,字形 88×48)。由 `tools/generate-activitybar-icon.mjs` 从 `images/logo.png` +重新生成;覆盖率用「像素 = 覆盖率 × 墨色」反解(两个平涂色的最大通道做分母), +6% 以下当作徽章边缘抗锯齿丢掉,所以不会在字外留一圈灰晕。白色 + 透明底在 alpha 模板和 +亮度模板下**都对**,不依赖 Chromium 选哪一种。`npm run check:icon` 进了 `npm run check`, +产物与来源不可能漂移。市场图标(`package.json` 的 `icon`)继续用真 logo。 + +### 15.7 这一轮的状态与仍然没做到的 + +- 646 个单元测试通过;`check:config`(68 设置 / 32 public)、`l10n-check`(382 运行串 / + 201 清单键)、`check:icon`、`check:generators` 全过;VSIX 93 文件 / 343 KiB(两道体积门禁内)。 +- dev profile 重装并重启,扩展主机日志确认激活、无错误。 +- **没做到**:快捷菜单图标没有颜色(15.3 的 API 限制);活动栏图标在真实主题下的观感只能靠眼睛, + 我没有截图能力;e2e 仍未在本地跑通(要下载固定版 VS Code,本机大文件下载会中断); + 索引定位的三条路径是靠打桩 `vscode` 后直接跑编译产物验证的,不是通过编辑器 UI 验证的。 + + +## 16. round 4:第二轮真实反馈(含一个把库视图打瘫的 bug) + +这一轮的第一条是**真 bug**:库视图一显示就抖、点不动、搜不了、CPU 飙高,折叠起来就正常。 +根因和"性能问题"完全无关。 + +### 16.1 库视图的渲染死循环(反馈 2,最高优先级) + +**根因:文档在自我介绍,宿主拿"再渲染一次"回答它。** + +`libraryHtml.ts` 的客户端脚本最后一句是 `post({ type: "ready" })`,而 `libraryView.ts` 的 +`handle()` 里恰好有: + +```ts +case "ready": + this.paint(); // ← paint() 里是 view.webview.html = … + return; +``` + +`webview.html = …` 会**重载**文档;文档重载后又发 `ready`。更致命的是每次 `paint()` 都调用 +`randomNonce()` 生成新的 CSP nonce,所以**每次渲染出来的文档都不同**——连"内容没变就别重设" +这种自然去重也永远不会命中。于是视图以毫秒级无限重载:抖动、DOM 被反复销毁(点不到、输不进)、 +CPU 打满。折叠时 webview 不渲染,所以"折叠就正常"。 + +上一轮这个 bug 之所以没暴露,恰恰因为 provider 从来没注册过——视图根本没渲染过。**修好注册, +就把它放出来了。** + +修法三条 + 一条门禁: + +1. 客户端不再发 `ready`,`decodeLibraryMessage` 不再认识它,宿主删掉 `case "ready"`: + 文档本身就是数据(每一行、每个徽章、每个计数都已经在里面),"我加载好了"这句话没有用途, + 只可能招来一次重复渲染。 +2. nonce 改成**每个视图一个**(构造时生成一次),于是"内容没变 ⇒ 文档逐字节相同"成立。 +3. `paint()` 多一道 `documentNeedsRender(this.document, document)`(纯函数,见 `libraryHtml.ts`): + 文档相同就**不重设**,滚动位置和输入到一半的搜索框都不会被丢掉。 + `resolveWebviewView` / `onDidDispose` 会把 `this.document` 清空——新解析出来的 webview 是空的, + 上一次推给旧 webview 的文档对它没有任何意义。 +4. `test/artifacts.test.ts` 新增源码级门禁:文档不得再 post `ready`、宿主不得再有 `case "ready"`、 + nonce 必须是每视图一个、比较必须存在。 + +### 16.2 快捷菜单图标配色(反馈 1) + +**能做,但只能靠图片。** 上一轮我查到 `MainThreadQuickOpen.expandIconPath` 会把 `ThemeIcon` +压成没有颜色的 codicon class;这一轮把同一函数读完了——它是**两条分支**: + +```js +expandIconPath(o){ let e = o.iconPathDto; + if (e) + if (j.isThemeIcon(e)) o.iconClass = j.asClassName(e); // 字体图标:颜色由行前景决定 + else if (Qh(e)) { let t = P.from(e); o.iconPath = { dark: t, light: t }; } // Uri:当 background-image 画 + else { … { dark, light } … } } +``` + +而列表渲染这边: + +```js +if (r.iconPath) { … i.icon.className = "quick-input-list-icon"; i.icon.style.backgroundImage = kc(f); } +``` + +`Uri` 是当**图片**画的,颜色照原样显示;`iconPath` 又恰好接受 `{ light, dark }` 对。所以路线是: +**给每一行生成一张带颜色的 SVG**。 + +- 字形来自 `@vscode/codicons`(VS Code 自己那套 codicon 字体的美术源,作为 devDependency 精确锁定 + `0.0.46-24`),颜色取对应 `charts.*` 主题令牌的**默认 light/dark 值**(从 1.132 的颜色注册表读出)。 + 于是快捷菜单和工程视图用的是同一批颜色:树把 `charts.blue` 交给 `ThemeIcon` 由主题上色, + 这里把同一个默认值烤进图里。 +- 生成器 `tools/generate-quick-menu-icons.mjs` 解析 `src/commands/menu.ts` 的表格,输出 + `media/quick-menu/----.svg`(40 个,zip 后约 34 KiB), + 支持 `--check`(多一个没人要的文件也算漂移),已进 `npm run check`。 +- 门禁在 `test/commands/menu.test.ts`:逐行走表格,两个主题的资产必须存在、非空、不再含 + `currentColor`;并且**同一个命令在菜单和工程视图里必须是同一个图标 + 同一个颜色** + (`charts.` 是两者的接缝)。 + +顺手抓出三个真问题,都是门禁逼出来的: + +| 发现 | 处理 | +| --- | --- | +| `charts.orange` 解析到 `minimap.findMatchHighlight` → `editor.findMatchHighlightBackground`,是 `#EA5C00` **33% 透明**;当字形颜色就是两头上都糊成一团 | 破坏性动作改用 `charts.red`(`editorError.foreground`,各主题都是实色)。树里的 Clean 一起改 | +| `star` 根本不是 codicon(只有 `star-full` / `star-empty` / `star-half`)——"选择全局默认工具链"那一行一直是**空图标** | 换成 `star-full` | +| "Search and add a dependency" 在树里是 `cloud`、在菜单里是 `library` | 统一成 `library`(它打开的就是 Library 视图) | + +另外菜单里那 20 条标签的中英译文上一轮已补齐,这轮没有新增文案。 + +### 16.3 cache 视图默认折叠(反馈 3) + +`contributes.views` 的条目 schema 里本来就有 `visibility: "visible" | "hidden" | "collapsed"` +(默认 `visible`),消费点是 `createView(n, { …, expanded: !collapsed })`。所以 +`mcpp.cache` 加上 `"visibility": "collapsed"` 就够了:侧边栏打开时 cache 只剩标题栏, +高度让给上面的库列表。用户手动展开/折叠之后以用户的状态为准(VS Code 把它记在 workspace state 里)。 +门禁在 `test/artifacts.test.ts`:三个视图的 `visibility` 必须恰好是 +`[undefined, undefined, "collapsed"]`。 + +### 16.4 活动栏"彩色 logo":做不到,所以不做(反馈 4) + +你想要"保留单色 / 配置里切换彩色"。**彩色这条路在活动栏是不存在的**,有两条独立的一手证据: + +1. 自定义容器图标是当**模板**画的(15.6 的 `mask: url(icon) … mask-size: 24px`), + 画出来的是图标的 **alpha 通道**,颜色来自主题变量。颜色信息在第一步就被丢掉了。 +2. 清单里 `viewsContainers.activitybar[].icon` 的 schema 是纯 `string` + (`{description:…, type:"string"}`,`required:["id","title","icon"]`), + 连 `{light, dark}` 这种写法都会被 `isValidViewsContainer` 直接判为非法; + 而且没有任何运行时 API 能改容器图标。 + +所以"配置里切换彩色/单色"会是一个**永远无效的开关**——比没有这个开关更糟。 +单色字形保留(你说它和其他图标风格匹配,这也是它本该有的样子:活动栏图标就该是主题前景色的剪影)。 + +### 16.5 状态栏:背景色可以,logo 不行(反馈 5) + +**背景色:VS Code 只允许两种,而且由扩展主机强制执行。** `StatusBarItem.backgroundColor` +的 API 文档写了"只支持 `statusBarItem.errorBackground` 与 `statusBarItem.warningBackground`", +我不满足于文档,去找了实现(`extensionHostProcess.js`): + +```js +static ALLOWED_BACKGROUND_COLORS = new Map([ + ["statusBarItem.errorBackground", new ThemeColor("statusBarItem.errorForeground")], + ["statusBarItem.warningBackground", new ThemeColor("statusBarItem.warningForeground")], +]); +set backgroundColor(t){ t && !ALLOWED_BACKGROUND_COLORS.has(t.id) && (t = void 0); this._backgroundColor = t; … } +… +this._backgroundColor && (n = ALLOWED_BACKGROUND_COLORS.get(this._backgroundColor.id)); // ← 前景色被替换 +``` + +也就是说:自建一个 `mcpp.statusBarBackground` 颜色会被**静默丢弃**;自己设 `color` 也会被覆盖。 +实现方式是新设置 `mcpp.ui.statusBar.background`(`warning` / `error` / `none`,默认 `warning`), +纯映射放在 `src/cli/statusBar.ts`(已进纯模块门禁),`applyStatusBar()` 一行接上。 +默认 `warning` = 主题的琥珀色块 + VS Code 自动配的白色前景,正好和 logo 的金橙呼应;不喜欢就设 `none`。 + +**logo 放状态栏:不行。** `StatusBarItem.text` 是字符串,里面的 `$(name)` 由 +`ThemeIcon.fromString` 解析成 **codicon 字体字形**,没有任何图片通道;`StatusBarItem` 上也没有 +`iconPath` 之类的字段。所以 `$(tools) mcpp` 保持不变——它是这套菜单(构建 / 工具链 / 缓存 / 模块) +最贴切的字形,而"换成 mcpp logo"这条路在 API 层面不存在。 + +### 16.6 这一轮的状态与仍然没做到的 + +- 653 个单元测试通过;`check:config`(69 设置 / 32 public)、`l10n-check`(382 运行串 / + 203 清单键)、`check:icon`(活动栏图 + 40 个菜单图标)、`check:generators` 全过; + VSIX 134 文件 / 377 KiB(两道体积门禁内:<200 文件、<1 MiB)。 +- dev profile 重装并重启(清掉 workspaceStorage,让 cache 的默认折叠生效),扩展主机日志确认激活、无错误。 +- **没做到 / 需要你的眼睛**:快捷菜单的彩色图标我只验证到"文件内容正确、颜色被烤进去、 + 能被栅格化成正确的颜色",**没有**在真实 quick pick 里看过——如果 `file:` URI 在快速选择里 + 加载不出来,那 16 行的槽位会是空的,那我就改走别的路子(data: URI 或退回单色)。这一条请重点看。 +- 库视图的死循环是**逻辑上**必然成立(文档 self-announce + 每次新 nonce),但没有做进程级 + 的 CPU 观测;"不抖了、能点了"需要你确认。 +- 活动栏彩色、状态栏 logo:API 层面不存在,已用一手源码说明。 +- e2e 仍未在本地跑通;索引定位的三条路径仍是打桩验证,不是 UI 验证。 + +## 17. round 5:状态栏背景回退、侧边栏配比、库标签与详情页、年龄渐变、新建工程 + +### 17.1 状态栏背景色默认关掉(反馈 1) + +`mcpp.ui.statusBar.background` 保留(它仍是唯一能做的两种背景),但默认值从 `warning` 改成 +`none`:一个常驻的琥珀色块会被读成"出问题了"。想要就设 `warning` / `error`,两个值都由 +VS Code 自己配好对比度(见 16.5 的扩展主机白名单)。 + +### 17.2 侧边栏配比:库视图默认占下面 2/3(反馈 2) + +`contributes.views[].initialSize` 不是像素,它是**视图在容器里的配比权重**(一手证据 +`computeInitialSizes()`): + +```js +let t = this.viewContainerModel.visibleViewDescriptors.reduce((i,{weight:n}) => i + (n||20), 0); +for (let i of this.viewContainerModel.visibleViewDescriptors) + e.set(i.id, this.dimension.height * (i.weight || 20) / t); // 默认每个视图 20 +``` + +所以给 `mcpp.library` 一个 `initialSize: 40`(默认 20 的两倍)就够:默认布局里项目视图拿 1/3、 +库视图拿 2/3,cache 又是折叠的(只剩标题栏,它的份额按比例回流给另外两个)。门禁在 +`test/artifacts.test.ts`:三个视图的 `initialSize` 必须恰好是 `[undefined, 40, undefined]`, +注释里带上上面那段权重公式。 + +### 17.3 库的标签去掉外框,详情页重排(反馈 3) + +**标签**(`.badge`)之前是"1px 外框 + 圆角"的小方块,一行的标签看起来像一排按钮。 +现在是不带任何框和底的**安静元数据**,相邻项之间用 `·` 分隔;只有必须被看见的两个状态 +(`Added`、`Descriptor not readable`)保留颜色并加粗——文字本身也在说同一件事,所以不靠颜色。 +**筛选 chip**(`.chip`)是交互控件,所以换成 VS Code 自己的 toggle 配色: +未选中 = 透明底、`descriptionForeground`;选中 = `inputOption.activeBackground/Foreground/Border`。 +"未选中也描一圈边"正是让它像按钮的原因。 + +**详情页**的结构问题更根本:读者来点的那颗按钮原本在**四个段落之后**(`renderActions` 在 +`
` 的最后)。现在顺序是: + +``` +标题 → 一句话描述 → 事实行(registry / surface / standard / 许可 / 徽章 / 仓库链接) +→ 主操作块([Add to mcpp.toml] [☐ dev-dependency] + 命令预览) +→ 版本矩阵 → 用法示例 → 依赖 → 其它 +``` + +同时把**版本矩阵变成选择器**:每个版本是一个 button(`data-version`),点它就把上面的命令 +指到那个版本,被选中的那枚用同一套 toggle 配色标出。原来 ``, + ` ${escapeHtml(label(CACHE_PANEL_UI.budgetUnit))}`, + ` `, + ` ${estimate}`, + ` ${button("prune", label(CACHE_PANEL_UI.prune), !sharedAvailable, sharedReason)}`, + legacyBytes > 0 + ? ` ${button("cleanLegacy", label(CACHE_PANEL_UI.cleanLegacy), false, label(CACHE_PANEL_UI.reasonLegacy))}` + : "", + ``, + ].filter((line) => line.length > 0).join("\n"); +} + +/** + * The global block. Expanded it is the composition bar, the age bar, the nested + * largest-packages list and the shared actions; collapsed it is one summary line + * (`7.20 GiB · 657 entries`). The markup carries **no `open` attribute**, which + * is what makes the first render collapsed. + */ +function renderSharedBlock(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const notes: string[] = []; + if (model.shared.root !== undefined) { + notes.push( + `

${escapeHtml(fill(label, CACHE_PANEL_UI.sharedRoot, [model.shared.root]))}

`, + ); + } + if (model.legacy !== undefined && model.legacy.path !== undefined) { + notes.push( + `

${escapeHtml(fill(label, CACHE_PANEL_UI.legacyPath, [model.legacy.path]))}

`, + ); + } + + const body = [ + renderComposition(model, label), + renderAge(model, label), + renderTop(model, label), + renderSharedActions(model, label), + ...notes, + ].join("\n"); + + if (!model.shared.available) { + // A failure must not be hidden behind a collapsed disclosure: render the + // block open, with the reason where the summary would have been. + return [ + `
`, + `

${escapeHtml(label(CACHE_PANEL_UI.sharedTitle))}

`, + `

${escapeHtml(model.shared.note ?? label(CACHE_PANEL_UI.sharedUnavailable))}

`, + renderSharedActions(model, label), + `
`, + ].join("\n"); + } + + const summary = `${formatters.bytes(finite(model.shared.totalBytes))} · ${fill( + label, + CACHE_PANEL_UI.sharedEntries, + [formatters.count(finite(model.shared.totalEntries))], + )}`; + return [ + `
`, + ` `, + ` ${escapeHtml(label(CACHE_PANEL_UI.sharedTitle))}`, + ` ${escapeHtml(summary)}`, + ` `, + `
`, + body, + `
`, + `
`, + ].join("\n"); +} + +// ───────────────────────────────────────────────────────────────── client + +/** + * The client. Dependency-free, no template literals of its own, and it only + * posts messages: the host re-renders the whole document after every action, so + * there is exactly one renderer instead of two. + * + * The open/closed state of the two `
` is the one thing the client + * remembers (`setState`), so re-rendering after an action does not re-collapse + * the block the user just opened. The document itself still ships collapsed. + */ +function clientScript(): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var saved = {}; + if (api && typeof api.getState === "function") { + try { saved = api.getState() || {}; } catch (error) { saved = {}; } + } + + function post(message) { + if (api) { api.postMessage(message); } + } + + function detailsList() { + return document.querySelectorAll("details[data-details]"); + } + + function restoreOpen() { + var open = saved && typeof saved.open === "object" && saved.open !== null ? saved.open : {}; + var all = detailsList(); + for (var index = 0; index < all.length; index += 1) { + var id = all[index].getAttribute("data-details"); + if (id && open[id] === true) { all[index].open = true; } + } + } + + function rememberOpen() { + var open = {}; + var all = detailsList(); + for (var index = 0; index < all.length; index += 1) { + var id = all[index].getAttribute("data-details"); + if (id) { open[id] = all[index].open === true; } + } + saved.open = open; + if (api && typeof api.setState === "function") { api.setState(saved); } + } + + function budgetGiB() { + var input = document.getElementById("cache-budget"); + if (!input) { return undefined; } + var text = String(input.value).trim(); + if (text.length === 0) { return undefined; } + var value = Number(text); + if (!isFinite(value) || value < 0) { return undefined; } + return value; + } + + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var button = target.closest("button"); + if (!button || button.disabled) { return; } + var entry = button.getAttribute("data-show-entry"); + if (entry) { post({ type: "showEntry", label: entry }); return; } + var action = button.getAttribute("data-action"); + if (!action) { return; } + if (action === "collect") { + var value = budgetGiB(); + if (value === undefined) { return; } + post({ type: "collect", budgetGiB: value }); + return; + } + post({ type: action }); + }); + + // A toggle event does not bubble, so the listener captures it on the way down. + document.addEventListener("toggle", function (event) { + var target = event.target; + if (target && typeof target.getAttribute === "function" && target.hasAttribute("data-details")) { + rememberOpen(); + } + }, true); + + restoreOpen(); +})();`; +} + +// ───────────────────────────────────────────────────────────── the document + +/** The whole document. */ +export function renderCachePanelHtml(model: CachePanelModel, assets: CachePanelAssets): string { + const label: UiLabel = (key) => model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const body = [ + renderWarnings(model, label), + renderProjectBlock(model, label), + renderSharedBlock(model, label), + `

${escapeHtml(label(CACHE_PANEL_UI.boundary))}

`, + ] + .filter((part) => part.length > 0) + .join("\n"); + return ` + + + + + + +${escapeHtml(label(CACHE_PANEL_UI.title))} + + +

${escapeHtml(label(CACHE_PANEL_UI.title))}

+
+${body} +
+ + + +`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not exactly one of the eight shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + * + * `collect` needs a finite, non-negative `budgetGiB`; a payload without one is + * not a request this panel can honour. + */ +export function decodeCachePanelMessage(raw: unknown): CachePanelMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "refresh": + case "cleanStale": + case "cleanProject": + case "prune": + case "verify": + case "cleanLegacy": + return { type: raw.type }; + case "collect": { + const budget = raw.budgetGiB; + if (typeof budget !== "number" || !Number.isFinite(budget) || budget < 0) { + return undefined; + } + return { type: "collect", budgetGiB: budget }; + } + case "showEntry": + return nonEmptyString(raw.label) ? { type: "showEntry", label: raw.label } : undefined; + default: + return undefined; + } +} diff --git a/src/cache/cacheState.ts b/src/cache/cacheState.ts new file mode 100644 index 0000000..f90b9e9 --- /dev/null +++ b/src/cache/cacheState.ts @@ -0,0 +1,188 @@ +/** + * The cache view's decisions, as **data**. + * + * Everything here is deliberately free of `vscode` and of the file system, so the + * settings that shape the cache view can be tested directly: + * + * - whether the pre-v1 cache node is offered (`mcpp.cache.showLegacy`, §8 G6); + * - what the panel is allowed to say about that cache; + * - the warning node for `mcpp.cache.warnAboveGiB`; + * - the extra confirmation `mcpp.cache.gc.confirmAboveGiB` adds; + * - the snapshot the environment self-check reads (`showSelfCheck`, §8 G9). + * + * `src/cache/cacheView.ts` reads the settings and hands the values in; the + * `vscode` layer therefore stays thin enough to read in one sitting. + */ + +import { formatBytes } from "../util/format"; + +/** 1 GiB, binary — the unit both cache size settings are declared in. */ +export const BYTES_PER_GIB = 1024 ** 3; + +/** + * A node built here rather than in `./models.ts`, so the tree model needs no + * edit: the shape is structurally the tree's `TreeNode`. The cache view itself is + * a sidebar `WebviewView` since §8.1 and no longer builds a tree, so this node is + * kept as the policy (§8 G6) that the warning threshold is decided in one pure + * place — the view states the same threshold in + * `src/cache/cachePanelHtml.ts`'s warning banner. + */ +export interface CacheTreeNode { + id: string; + label: { key: string; args?: readonly (string | number)[] }; + description?: { key: string; args?: readonly (string | number)[] }; + tooltip?: { key: string; args?: readonly (string | number)[] }; + icon?: string; + contextValue?: string; + command?: { command: string; title: { key: string; args?: readonly (string | number)[] } }; + children?: readonly CacheTreeNode[]; +} + +/** `mcpp.cache.showLegacy`, as the view reads it. */ +export interface LegacyGate { + enabled: boolean; + bytes?: number; + files?: number; + truncated?: boolean; +} + +export type LegacyDisplay = "shown" | "off" | "empty" | "unknown"; + +/** Why the pre-v1 node is or is not in the tree. Pure, so the policy is testable. */ +export function legacyDisplay(gate: LegacyGate): LegacyDisplay { + if (!gate.enabled) { + return "off"; + } + if (gate.bytes === undefined) { + return "unknown"; + } + return gate.bytes > 0 ? "shown" : "empty"; +} + +/** + * The figure the tree needs, or `undefined` when no node should exist. Kept next + * to {@link legacyDisplay} so "the node renders only when the setting is on and + * the directory is non-empty" is stated once. + */ +export function legacyBytesForTree(gate: LegacyGate): number | undefined { + return legacyDisplay(gate) === "shown" ? gate.bytes : undefined; +} + +/** What the panel may show: the same rule, plus the path it can offer to clean. */ +export function legacyForPanel( + gate: LegacyGate, + path: string | undefined, +): { bytes: number; path?: string } | undefined { + if (legacyDisplay(gate) !== "shown" || path === undefined) { + return undefined; + } + return { bytes: gate.bytes ?? 0, path }; +} + +/** + * The warning the tree shows when the shared cache reached `mcpp.cache.warnAboveGiB`. + * + * `0` — and a non-positive or unreadable threshold — disables the warning + * entirely, which is what the registry's description promises. `bytes` must come + * from the same read the rest of the view uses; a missing inventory is not a + * warning (nothing was measured, so nothing is known to be large). + */ +export function buildCacheWarningNode(bytes: number, thresholdGiB: number): CacheTreeNode | undefined { + if (!Number.isFinite(thresholdGiB) || thresholdGiB <= 0) { + return undefined; + } + if (!Number.isFinite(bytes) || bytes < thresholdGiB * BYTES_PER_GIB) { + return undefined; + } + const thresholdBytes = thresholdGiB * BYTES_PER_GIB; + return { + id: "cache.warning", + label: { key: "Cache warning" }, + description: { key: "{0} · at or above {1}", args: [formatBytes(bytes), formatBytes(thresholdBytes)] }, + tooltip: { + key: "The shared build cache is {0}. Raise or clear mcpp.cache.warnAboveGiB to change when this appears.", + args: [formatBytes(bytes)], + }, + icon: "warning", + contextValue: "mcppCacheWarning", + // The cache view is a sidebar `WebviewView` now, so the node points at the + // view's own focus command (VS Code registers `.focus` for every + // contributed view) rather than at the removed `mcpp.showCachePanel`. + command: { command: "mcpp.cache.focus", title: { key: "Cache statistics" } }, + }; +} + +/** `mcpp.cache.gc.confirmAboveGiB`: does this budget add a confirmation level? */ +export function gcBudgetNeedsExtraConfirm(budgetGiB: number, confirmAboveGiB: number): boolean { + if (!Number.isFinite(budgetGiB) || budgetGiB < 0) { + return false; + } + if (!Number.isFinite(confirmAboveGiB) || confirmAboveGiB < 0) { + return false; + } + // 0 means "confirm every cleanup", including the one with no budget at all. + return confirmAboveGiB === 0 || budgetGiB >= confirmAboveGiB; +} + +export interface GcBudgetDialog { + /** Substituted into the caller's `detail` key: the budget in GiB. */ + args: readonly (string | number)[]; +} + +/** + * The data the extra level's dialogue needs. + * + * It deliberately carries no sentences: the caller writes the translation + * literals (as `src/cli/clean.ts` does with its `titleKey`/`detailKey`), which is + * what makes `tools/l10n-check.mjs` see them. This module only decides *when* the + * dialogue happens and what number it names. + */ +export function gcBudgetDialog(budgetGiB: number, confirmAboveGiB: number): GcBudgetDialog { + // A budget of 0 means "no budget given": the threshold is the figure worth + // naming in that case. + const named = budgetGiB > 0 ? budgetGiB : confirmAboveGiB; + return { args: named > 0 ? [named] : [] }; +} + +/** What the environment self-check puts under `Cache` (`cli/selfCheck.ts` §8 G9). */ +export interface CacheSnapshot { + totalBytes: number; + entries: number; + incomplete: number; +} + +/** + * The snapshot from an inventory the caller already read. No query is repeated + * here; `readCacheSnapshot()` in `src/cache/cacheView.ts` is the one that runs + * them, and it reuses the cache view's own read path. + */ +export function cacheSnapshotFrom( + inventory: { totalBytes: number; totalEntries: number; incomplete: number } | undefined, +): CacheSnapshot | undefined { + if (inventory === undefined) { + return undefined; + } + return { + totalBytes: Number.isFinite(inventory.totalBytes) ? inventory.totalBytes : 0, + entries: Number.isFinite(inventory.totalEntries) ? inventory.totalEntries : 0, + incomplete: Number.isFinite(inventory.incomplete) ? inventory.incomplete : 0, + }; +} + +export interface TimerDecision { + active: boolean; + seconds: number; +} + +/** + * Whether one of the two view timers should be running. + * + * Every condition that turns a timer off is stated once, in one place: + * `0` (or anything that is not a positive finite number) disables it, and so does + * a view that is not visible. The two settings differ only in what feeds + * `viewVisible`. + */ +export function refreshTimerDecision(seconds: number, viewVisible: boolean): TimerDecision { + const usable = Number.isFinite(seconds) && seconds > 0; + return { active: usable && viewVisible, seconds: usable ? seconds : 0 }; +} diff --git a/src/cache/cacheView.ts b/src/cache/cacheView.ts new file mode 100644 index 0000000..40ef2cc --- /dev/null +++ b/src/cache/cacheView.ts @@ -0,0 +1,762 @@ +/** + * The cache view and every cleanup command. + * + * The view is a sidebar **WebviewView** (`mcpp.cache`, plan §8.1): the numbers + * and the bars live in one document whose host is `src/cache/cachePanel.ts`, and + * whose structure is the pure `src/cache/cachePanelHtml.ts`. This file owns the + * data behind it, the status item, the auto-refresh timer and the commands. + * + * The *policy* — which argv, how much confirmation, whether a preview is shown — + * lives in `src/cli/clean.ts` and is unit-tested there. This file only asks the + * user, runs the command and reports the result, so the safety rules cannot be + * edited by accident in a UI change. + * + * Nothing here removes anything by itself: every destructive path goes through + * `planClean`, a preview when the plan asks for one, and a modal. + * + * Four settings shape this file: + * + * - `mcpp.cache.showLegacy` decides whether the pre-v1 cache is measured and + * offered at all (`src/cache/cacheState.ts`, §8 G6); + * - `mcpp.cache.warnAboveGiB` adds a warning when the shared cache is large; + * - `mcpp.cache.autoRefreshSeconds` re-reads while the view is on screen; + * - `mcpp.cache.gc.confirmAboveGiB` adds one confirmation level to a large `gc`. + */ + +import * as vscode from "vscode"; + +import { estimateArtifacts, measureDirectory, type ArtifactEstimate } from "../cli/artifacts"; +import { parseCacheDir, parseCacheList, summarizeCache, type CacheInventory, type CacheEntry } from "../cli/cache"; +import { planClean, withSharedCache, type CleanPlan } from "../cli/clean"; +import { runMcpp, type ProcessResult } from "../cli/process"; +import { CACHE_COMMANDS } from "../commands/ids"; +import { read } from "../config/access"; +import { format as formatMessage, t } from "../i18n/t"; +import { PollTimer } from "../mcppls/timers"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import { formatBytes, projectGc } from "../util/format"; +import { clampOutput } from "../util/text"; +import { + cacheSnapshotFrom, + gcBudgetDialog, + gcBudgetNeedsExtraConfirm, + legacyForPanel, + refreshTimerDecision, + type CacheSnapshot, +} from "./cacheState"; +import { + CACHE_VIEW_ID, + CACHE_VIEW_FOCUS_COMMAND, + registerCachePanel, + type CachePanelData, + type CachePanelProvider, +} from "./cachePanel"; + +export { CACHE_VIEW_ID, CACHE_VIEW_FOCUS_COMMAND }; + +/** The tick granularity; `mcpp.cache.autoRefreshSeconds` is in seconds. */ +const REFRESH_TICK_MS = 100; + +/** The pre-v1 walk is a courtesy figure, so it gets a smaller budget than `target/`. */ +const LEGACY_MAX_ENTRIES = 20_000; + +/** `mcpp.runtime.timeoutSeconds`, or the built-in default when it is 0. */ +function queryTimeoutMs(): number | undefined { + const seconds = read("mcpp.runtime.timeoutSeconds"); + return seconds > 0 ? seconds * 1000 : 60_000; +} +const CLEAN_TIMEOUT_MS = 300_000; + +export interface CacheViewDeps { + output: vscode.OutputChannel; + currentProject: () => McppProjectDiscovery | undefined; + mcppExecutable: (project: McppProjectDiscovery | undefined) => string; + isTrusted: () => boolean; +} + +/** What `mcpp cache dir` reported about the pre-v1 cache, measured or not. */ +interface LegacyCache { + path?: string; + bytes?: number; + files?: number; + truncated?: boolean; +} + +interface CacheState { + entries: CacheEntry[]; + inventory?: CacheInventory; + artifacts?: ArtifactEstimate; + legacy?: LegacyCache; + error?: string; +} + +function workingDirectory(project: McppProjectDiscovery | undefined): string | undefined { + return project?.root; +} + +/** Unix seconds -> an ISO instant the panel can print; the panel does not do dates. */ +function timestamp(seconds: number | undefined): string | undefined { + return seconds === undefined || !Number.isFinite(seconds) ? undefined : new Date(seconds * 1000).toISOString(); +} + +function numberFormat(): "binary" | "decimal" { + return read("mcpp.ui.numberFormat") === "decimal" ? "decimal" : "binary"; +} + +/** + * `mcpp.cache.showLegacy`: the whole rule in one object, so the panel and the + * self-check read the same answer. + */ +function legacyGate(settings: { showLegacy: boolean }, legacy: LegacyCache | undefined): { + enabled: boolean; + bytes?: number; + files?: number; + truncated?: boolean; +} { + return { + enabled: settings.showLegacy, + bytes: legacy?.bytes, + files: legacy?.files, + truncated: legacy?.truncated, + }; +} + +function viewSettings(): { + topN: number; + ageBoundaries: string[]; + showLegacy: boolean; + warnAboveGiB: number; + autoRefreshSeconds: number; +} { + return { + topN: Math.max(1, read("mcpp.views.cache.topN")), + ageBoundaries: read("mcpp.views.cache.ageBuckets"), + showLegacy: read("mcpp.cache.showLegacy") !== false, + warnAboveGiB: read("mcpp.cache.warnAboveGiB"), + autoRefreshSeconds: read("mcpp.cache.autoRefreshSeconds"), + }; +} + +async function appendResult( + deps: CacheViewDeps, + title: string, + executable: string, + argv: readonly string[], + cwd: string | undefined, + result: ProcessResult, +): Promise { + try { + deps.output.appendLine(`\n[${new Date().toISOString()}] ${title}`); + deps.output.appendLine(`$ ${[executable, ...argv].join(" ")}`); + const stdout = clampOutput(result.stdout); + const stderr = clampOutput(result.stderr); + if (stdout.text.trim().length > 0) deps.output.appendLine(stdout.text.trimEnd()); + if (stderr.text.trim().length > 0) deps.output.appendLine(stderr.text.trimEnd()); + deps.output.appendLine(`[exit ${result.exitCode}]`); + } catch { + // The channel can already be gone during shutdown. + } +} + +async function run( + deps: CacheViewDeps, + project: McppProjectDiscovery | undefined, + argv: readonly string[], + options: { timeoutMs?: number; quiet?: boolean } = {}, +): Promise { + const executable = deps.mcppExecutable(project); + const cwd = workingDirectory(project); + const result = options.quiet === true + ? await runMcpp(deps.isTrusted(), executable, [...argv], cwd, { timeoutMs: options.timeoutMs, maxBufferMiB: read("mcpp.runtime.maxOutputMiB") }) + : await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `mcpp ${argv[0]}` }, + () => runMcpp(deps.isTrusted(), executable, [...argv], cwd, { timeoutMs: options.timeoutMs }), + ); + if (result === undefined) { + // The seam refuses in an untrusted workspace; the callers gate with their + // own message, so this is the defensive shape of the same refusal. + return { exitCode: 1, stdout: "", stderr: t("the workspace is not trusted") }; + } + if (result.exitCode !== 0 || options.quiet !== true) { + await appendResult(deps, `mcpp ${argv.join(" ")}`, executable, argv, cwd, result); + } + return result; +} + +async function preview(title: string, content: string): Promise { + const document = await vscode.workspace.openTextDocument({ + content: `# ${title}\n\n\`\`\`\n${content.trimEnd()}\n\`\`\`\n`, + language: "markdown", + }); + await vscode.window.showTextDocument(document, { preview: true, preserveFocus: true }); +} + +/** + * Ask for confirmation, honouring the plan's level. Level 3 asks twice: once as + * a modal and once with an explicit acknowledgement, because that is the one + * that reaches every project on the machine. + */ +async function confirmPlan(plan: CleanPlan, detail: string, figures: string): Promise { + const body = `${t(plan.detailKey, figures)}\n\n${detail}`; + if (plan.level === 0) { + return true; + } + const runLabel = t("Run"); + const choice = await vscode.window.showWarningMessage( + t(plan.titleKey), + { modal: true, detail: body }, + runLabel, + ); + if (choice !== runLabel) { + return false; + } + if (!plan.acknowledge) { + return true; + } + const acknowledge = t("I understand this affects every mcpp project on this machine"); + const second = await vscode.window.showWarningMessage( + t("Confirm once more"), + { modal: true, detail: body }, + acknowledge, + ); + return second === acknowledge; +} + +/** + * The extra level `mcpp.cache.gc.confirmAboveGiB` adds. + * + * It is asked *before* the plan's own modal, so a refused acknowledgement never + * reaches the dialogue that would have run the command. It never replaces a + * level: a plan that already demands an acknowledgement is not asked a third + * time, and the plan's own modal still runs afterwards. + * + * The sentences live here rather than in `cacheState.ts` so + * `tools/l10n-check.mjs` sees them as translation call literals; the decision and + * the figure come from the pure module. + */ +export function extraGcConfirmation( + plan: CleanPlan, + budgetGiB: number, + confirmAboveGiB: number, +): { title: string; detail: string; acknowledge: string; args: readonly (string | number)[] } | undefined { + if (plan.action !== "cacheGc" || plan.acknowledge) { + return undefined; + } + if (!gcBudgetNeedsExtraConfirm(budgetGiB, confirmAboveGiB)) { + return undefined; + } + const dialog = gcBudgetDialog(budgetGiB, confirmAboveGiB); + return { + title: t("Confirm a cleanup this large"), + detail: t( + "This {0} GiB budget frees entries that every mcpp project on this machine shares. The cleanup can take a while, and any dropped entry is rebuilt on next use.", + ), + acknowledge: t("I understand this removes entries other mcpp projects may use"), + args: dialog.args, + }; +} + +/** + * The cache view and its commands. + * + * Returns the sidebar provider it registered, so a caller that would rather own + * the `registerWebviewViewProvider` call can register this value itself instead + * (see `registerCachePanel`). Ignoring the return value is the normal case: the + * view is registered here, and `extension.ts` needs no extra line. + */ +export function registerCacheView(context: vscode.ExtensionContext, deps: CacheViewDeps): CachePanelProvider { + let state: CacheState = { entries: [] }; + /** Guards the interval: one refresh at a time, and none after a failure. */ + let refreshInFlight: Promise | undefined; + /** + * The sidebar view. Assigned after the timer below is declared, because the + * timer asks the provider whether the view is on screen; everything that reads + * it does so long after `registerCacheView` has returned. + */ + let provider: CachePanelProvider | undefined; + const viewVisible = (): boolean => provider?.visible === true; + + // An optional, second status item. Off by default: the C++ Modules extension + // already owns a status item, and the mcpp quick menu owns ours. + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 39); + // `mcpp.cache.focus` is registered by VS Code for the contributed view, so the + // status item can bring the sidebar view on screen without a command of ours. + status.command = CACHE_VIEW_FOCUS_COMMAND; + context.subscriptions.push(status); + + const updateStatus = (): void => { + if (!read("mcpp.cache.statusBar")) { + status.hide(); + return; + } + const total = state.inventory?.totalBytes; + status.text = total === undefined ? "$(database) mcpp" : `$(database) ${formatBytes(total, numberFormat())}`; + status.tooltip = t("Shared build cache"); + status.show(); + }; + + const applyState = (next: CacheState): void => { + state = next; + setLastCacheSnapshot( + next.inventory === undefined + ? undefined + : cacheSnapshotFrom({ + totalBytes: next.inventory.totalBytes, + totalEntries: next.inventory.totalEntries, + incomplete: next.inventory.incomplete.length, + }), + ); + }; + + /** + * Re-read everything the view shows. It deliberately does **not** redraw the + * webview: the draw path (`CachePanelDeps.read` is `read` + `panelData`) calls + * this first, and a redraw from inside here would ask itself for the same + * figures again. + */ + const refreshNow = async (): Promise => { + const project = deps.currentProject(); + const settings = viewSettings(); + const next: CacheState = { entries: [] }; + + if (!deps.isTrusted()) { + next.error = t("the workspace is not trusted"); + applyState(next); + return; + } + + const listed = await run(deps, project, ["cache", "list", "--format", "json"], { + timeoutMs: queryTimeoutMs(), + quiet: true, + }); + if (listed.exitCode === 0) { + const parsed = parseCacheList(listed.stdout); + if (parsed === undefined) { + next.error = t("mcpp cache list did not return the documented document"); + } else { + next.entries = parsed.entries; + next.inventory = summarizeCache(parsed.root, parsed.entries, { + topN: settings.topN, + ageBoundaries: settings.ageBoundaries, + }); + } + } else { + next.error = t("mcpp cache list failed (exit {0})", listed.exitCode); + } + + const dir = await run(deps, project, ["cache", "dir"], { timeoutMs: queryTimeoutMs(), quiet: true }); + if (dir.exitCode === 0) { + const parsed = parseCacheDir(dir.stdout); + // The pre-v1 directory is measured only when the setting offers it: a + // user who turned `mcpp.cache.showLegacy` off pays for no walk at all. + const measured = + settings.showLegacy && parsed.legacyPath !== undefined + ? measureDirectory(parsed.legacyPath, { maxEntries: LEGACY_MAX_ENTRIES }) + : undefined; + next.legacy = { + path: parsed.legacyPath, + bytes: measured?.totalBytes, + files: measured?.files, + truncated: measured?.truncated !== undefined, + }; + } + + if (project !== undefined && read("mcpp.cache.estimateProjectBytes")) { + next.artifacts = estimateArtifacts(project.root); + } + + applyState(next); + updateStatus(); + }; + + /** Single-flight: an interval tick during a read must not start a second one. */ + const refresh = (): Promise => { + if (refreshInFlight !== undefined) { + return refreshInFlight; + } + refreshInFlight = refreshNow().finally(() => { + refreshInFlight = undefined; + }); + return refreshInFlight; + }; + + /** The figures a confirmation dialogue needs, without running anything new. */ + const figures = (): string => { + const total = state.inventory?.totalBytes; + return total === undefined ? t("(size unknown)") : t("{0} in the shared cache", formatBytes(total)); + }; + + const runPlan = async (project: McppProjectDiscovery | undefined, plan: CleanPlan, extra = ""): Promise => { + const detail = `${t("Command")}: mcpp ${plan.argv.join(" ")}${extra}`; + if (!(await confirmPlan(plan, detail, figures()))) { + return; + } + const result = await run(deps, project, plan.argv, { timeoutMs: CLEAN_TIMEOUT_MS }); + if (result.exitCode === 0) { + await refresh(); + return; + } + void vscode.window.showErrorMessage(t("mcpp {0} failed with exit code {1}", plan.argv.join(" "), result.exitCode)); + }; + + /** `runPlan` plus the one extra level `mcpp.cache.gc.confirmAboveGiB` adds. */ + const runGcPlan = async ( + project: McppProjectDiscovery | undefined, + budgetGiB: number, + extra: string, + ): Promise => { + const plan = planClean("cacheGc", { budgetGiB }); + const dialog = extraGcConfirmation(plan, budgetGiB, read("mcpp.cache.gc.confirmAboveGiB")); + // The extra level is asked *before* the plan's own modal, so a refused + // acknowledgement never reaches the one that would have run the command. + // `extraGcConfirmation` already resolved the strings through `t()`. + if (dialog !== undefined) { + const choice = await vscode.window.showWarningMessage( + dialog.title, + { modal: true, detail: formatMessage(dialog.detail, dialog.args) }, + dialog.acknowledge, + ); + if (choice !== dialog.acknowledge) { + return; + } + } + await runPlan(project, plan, extra); + }; + + const requireTrusted = (): boolean => { + if (deps.isTrusted()) { + return true; + } + void vscode.window.showWarningMessage( + t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."), + ); + return false; + }; + + const register = (id: string, handler: (...args: unknown[]) => Promise): void => { + context.subscriptions.push( + vscode.commands.registerCommand(id, async (...args: unknown[]) => { + try { + await handler(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.output.appendLine(`mcpp: ${message}`); + void vscode.window.showErrorMessage(t("mcpp: {0}", message)); + } + }), + ); + }; + + /** What the view shows, from the same state the commands use. Runs nothing. */ + const panelData = (): CachePanelData => { + const inventory = state.inventory; + const artifacts = state.artifacts; + return { + project: { + available: artifacts !== undefined && artifacts.exists, + note: + artifacts === undefined + ? t("Project artifacts could not be measured.") + : artifacts.exists + ? undefined + : t("No target/ directory"), + totalBytes: artifacts?.totalBytes ?? 0, + files: artifacts?.files ?? 0, + groups: artifacts?.byTopLevel.length ?? 0, + truncated: artifacts?.truncated, + }, + shared: { + available: inventory !== undefined, + note: inventory === undefined ? state.error ?? t("The shared build cache could not be read.") : undefined, + root: inventory?.root, + totalBytes: inventory?.totalBytes ?? 0, + totalEntries: inventory?.totalEntries ?? 0, + byKind: inventory?.byKind ?? [], + buckets: inventory?.ageBuckets ?? [], + top: (inventory?.topLabels ?? []).map((entry) => ({ + label: entry.label, + entries: entry.entries, + bytes: entry.bytes, + oldestAccessed: entry.oldestAccessed, + })), + incomplete: inventory?.incomplete.length ?? 0, + oldestAccessed: timestamp(inventory?.oldestAccessed), + newestAccessed: timestamp(inventory?.newestAccessed), + }, + legacy: legacyForPanel(legacyGate(viewSettings(), state.legacy), state.legacy?.path), + }; + }; + + // `mcpp.cache.autoRefreshSeconds`: re-read while the view is on screen, never + // in an untrusted workspace, and never on top of a read that is still running. + // The tick only redraws: the provider's own read path performs the refresh, so + // one tick costs one round of queries. + const timer = new PollTimer({ + periodMs: 0, + tick: (): void => { + if (viewVisible() && deps.isTrusted()) { + provider?.refresh(); + } + }, + }); + context.subscriptions.push(timer); + const applyTimer = (): void => { + const seconds = viewSettings().autoRefreshSeconds; + const usable = deps.isTrusted() && Number.isFinite(seconds) && seconds > 0; + const decision = refreshTimerDecision(usable ? seconds : 0, viewVisible()); + timer.start(decision.active ? decision.seconds * 1000 : 0); + }; + applyTimer(); + + // The sidebar webview view: `mcpp.cache` is a `"type": "webview"` view, so + // this provider (not a tree) is what draws it. Every destructive action still + // goes through `runPlan` above. + provider = registerCachePanel(context, { + refresh, + read: panelData, + run: async (message) => { + const project = deps.currentProject(); + switch (message.type) { + case "cleanStale": + await runPlan(project, planClean("stale", { staleDays: read("mcpp.cache.staleDays") })); + return; + case "cleanProject": + await runPlan(project, planClean("project")); + return; + case "collect": + await runGcPlan(project, message.budgetGiB, ""); + return; + case "prune": + await runPlan(project, planClean("cachePrune", { pruneAgeDays: read("mcpp.cache.pruneAgeDays") })); + return; + case "cleanLegacy": + await runPlan(project, planClean("cacheLegacy")); + return; + default: + await run(deps, project, ["cache", "verify"], { timeoutMs: CLEAN_TIMEOUT_MS }); + return; + } + }, + showEntry: async (label) => { + await vscode.commands.executeCommand(CACHE_COMMANDS.showEntry, label); + }, + onVisibilityChanged: () => applyTimer(), + }); + + register(CACHE_COMMANDS.refreshStats, async () => { + if (!requireTrusted()) return; + await refresh(); + provider?.refresh(); + }); + + register(CACHE_COMMANDS.showEntry, async (label) => { + if (!requireTrusted()) return; + if (typeof label !== "string" || label.length === 0) { + return; + } + const project = deps.currentProject(); + const result = await run(deps, project, ["cache", "info", label], { timeoutMs: queryTimeoutMs(), quiet: true }); + await preview(t("Cache entry {0}", label), result.stdout.length > 0 ? result.stdout : result.stderr); + }); + + register(CACHE_COMMANDS.cleanStale, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); + return; + } + const plan = planClean("stale", { staleDays: read("mcpp.cache.staleDays") }); + const dry = await run(deps, project, ["clean", "--dry-run"], { timeoutMs: CLEAN_TIMEOUT_MS, quiet: true }); + await preview(t("mcpp clean --dry-run"), dry.stdout.length > 0 ? dry.stdout : dry.stderr); + await runPlan(project, plan); + }); + + register(CACHE_COMMANDS.cleanProject, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); + return; + } + const base = planClean("project"); + const alsoCache = t("Also empty the shared build cache"); + // `mcpp.task.confirmClean` answers the same question here as it does on the + // task path. Turning it off skips only *this* modal: choosing to also empty + // the shared cache still escalates through the plan's own level 3. + const choice = read("mcpp.task.confirmClean") + ? await vscode.window.showWarningMessage( + t(base.titleKey), + { modal: true, detail: t(base.detailKey) }, + t("Run"), + alsoCache, + ) + : undefined; + if (read("mcpp.task.confirmClean") && choice === undefined) { + return; + } + const plan = choice === alsoCache ? withSharedCache(base) : base; + if (plan.acknowledge && choice === alsoCache) { + const acknowledge = t("I understand this affects every mcpp project on this machine"); + const second = await vscode.window.showWarningMessage( + t("Confirm once more"), + { modal: true, detail: t(plan.detailKey) }, + acknowledge, + ); + if (second !== acknowledge) { + return; + } + } + const result = await run(deps, project, plan.argv, { timeoutMs: CLEAN_TIMEOUT_MS }); + if (result.exitCode === 0) { + await refresh(); + } else { + void vscode.window.showErrorMessage(t("mcpp {0} failed with exit code {1}", plan.argv.join(" "), result.exitCode)); + } + }); + + register(CACHE_COMMANDS.collect, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + await refresh(); + const total = state.inventory?.totalBytes ?? 0; + const suggested = read("mcpp.cache.gc.defaultBudgetGiB") || Math.max(1, Math.round(total / 1024 ** 3 / 2)); + const input = await vscode.window.showInputBox({ + title: t("Keep the shared build cache under how many GiB?"), + value: String(suggested), + validateInput: (value) => (/^\d+$/.test(value.trim()) ? undefined : t("Enter a whole number of GiB")), + }); + if (input === undefined) { + return; + } + const budgetGiB = Number.parseInt(input.trim(), 10); + // Local LRU projection: an estimate, offered so the budget is chosen with + // numbers rather than guessed. mcpp's own policy decides in the end. + const projection = projectGc(state.entries, budgetGiB * 1024 ** 3); + const estimate = + projection.removed.length === 0 + ? t("Already within {0} GiB.", budgetGiB) + : t("About {0} would be freed ({1} entries).", formatBytes(projection.freedBytes), projection.removed.length); + await runGcPlan(project, budgetGiB, `\n${estimate}`); + }); + + register(CACHE_COMMANDS.prune, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + await runPlan(project, planClean("cachePrune", { pruneAgeDays: read("mcpp.cache.pruneAgeDays") })); + }); + + register(CACHE_COMMANDS.verify, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + const result = await run(deps, project, ["cache", "verify"], { timeoutMs: CLEAN_TIMEOUT_MS }); + if (result.exitCode === 0) { + const text = `${result.stdout}\n${result.stderr}`.trim(); + await preview(t("Cache verification"), text.length === 0 ? t("Every entry matches its manifest.") : text); + } else { + void vscode.window.showErrorMessage(t("mcpp cache verify failed with exit code {0}", result.exitCode)); + } + }); + + register(CACHE_COMMANDS.cleanLegacy, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + await runPlan(project, planClean("cacheLegacy")); + }); + + // First paint: show what the settings allow without running anything heavy. + if (deps.currentProject() !== undefined && read("mcpp.cache.estimateProjectBytes")) { + const project = deps.currentProject(); + if (project !== undefined) { + state.artifacts = estimateArtifacts(project.root); + } + } + provider.refresh(); + updateStatus(); + + // Keep the view, the timer and the status item in step with the settings. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.views.cache") || event.affectsConfiguration("mcpp.cache") || event.affectsConfiguration("mcpp.ui.numberFormat")) { + provider?.refresh(); + updateStatus(); + applyTimer(); + } + }), + ); + + return provider; +} + +// ── the self-check's cache snapshot (§8 G9) ────────────────────────────────── + +/** A reading older than this is re-taken rather than reported as current. */ +const SNAPSHOT_FRESH_MS = 60_000; + +let lastSnapshot: { snapshot: CacheSnapshot; at: number } | undefined; + +function setLastCacheSnapshot(snapshot: CacheSnapshot | undefined): void { + lastSnapshot = snapshot === undefined ? undefined : { snapshot, at: Date.now() }; +} + +/** The configured `mcpp.path`, or `mcpp` — the same rule the CLI controller uses. */ +function snapshotExecutable(): string { + const configured = vscode.workspace.getConfiguration("mcpp").get("path", ""); + return configured.trim().length === 0 ? "mcpp" : configured.trim(); +} + +/** + * `showSelfCheck` calls this to fill `buildSelfCheckText`'s `cache` field. + * + * It runs the same read-only `mcpp cache list --format json` the cache view runs + * and reuses that view's own aggregation, so the self-check and the view can + * never disagree. A reading the view took less than a minute ago is reused, so + * opening the self-check does not re-run a query for a number the user just saw. + * + * An untrusted workspace reuses the last reading and never runs `mcpp`; a + * missing `mcpp`, a failed query or an unreadable document all return + * `undefined`, which `buildSelfCheckText` renders as "not read". + * + * The one-line caller change is in the report: + * `cache: await readCacheSnapshot(),` inside the `buildSelfCheckText` call. + */ +export async function readCacheSnapshot(): Promise { + const cached = lastSnapshot; + if (cached !== undefined && Date.now() - cached.at < SNAPSHOT_FRESH_MS) { + return cached.snapshot; + } + if (!vscode.workspace.isTrusted) { + return cached?.snapshot; + } + return readCacheSnapshotNow(); +} + +/** + * Read regardless of what the view last showed: this is the query itself, kept + * separate so a caller with an explicit reason to re-measure can ask for one. + * Read-only, bounded, and never throws. + */ +export async function readCacheSnapshotNow(): Promise { + try { + const result = await runMcpp(vscode.workspace.isTrusted, snapshotExecutable(), ["cache", "list", "--format", "json"], undefined, { + timeoutMs: queryTimeoutMs(), + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }); + if (result === undefined || result.exitCode !== 0) { + return undefined; + } + const parsed = parseCacheList(result.stdout); + if (parsed === undefined) { + return undefined; + } + const inventory = summarizeCache(parsed.root, parsed.entries, { topN: 0, ageBoundaries: [] }); + return cacheSnapshotFrom({ + totalBytes: inventory.totalBytes, + totalEntries: inventory.totalEntries, + incomplete: inventory.incomplete.length, + }); + } catch { + return undefined; + } +} + diff --git a/src/cli/artifacts.ts b/src/cli/artifacts.ts new file mode 100644 index 0000000..eef7073 --- /dev/null +++ b/src/cli/artifacts.ts @@ -0,0 +1,197 @@ +/** + * Size estimate for a project's `target/` directory (plan §3.4.1). + * + * mcpp states that the contents of `target/` are **not an interface**, so this is + * an estimate for the cache view only: it never parses mcpp's fingerprints and + * never guesses which artifacts are stale. The authoritative "what would be + * removed" answer is `mcpp clean --dry-run`. + * + * Pure `node:fs`, never throws, and bounded: a hostile or huge `target/` cannot + * hang the extension host. Symlinks are counted as zero-byte entries and are + * never followed (a link back to an ancestor would otherwise loop forever). + */ + +import { lstatSync, readdirSync } from "node:fs"; +import path from "node:path"; + +import { formatBytes } from "../util/format"; + +export interface ArtifactGroup { + name: string; + bytes: number; + files: number; +} + +export interface ArtifactEstimate { + /** Absolute path of `target/`, whether or not it exists. */ + path: string; + exists: boolean; + totalBytes: number; + files: number; + /** One entry per directory directly under `/target`. */ + byTopLevel: ArtifactGroup[]; + /** Set when the walk stopped early; the numbers are then a floor. */ + truncated?: "entries" | "depth"; +} + +const DEFAULT_MAX_ENTRIES = 200_000; +const DEFAULT_MAX_DEPTH = 6; + +function budget(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value) || value < 0) { + return fallback; + } + return Math.floor(value); +} + +/** + * Walk `/target` with a bounded budget. Never throws. + * + * Returns `exists: false` with zeros when `target/` is missing, is not a + * directory, or is itself a symlink. + */ +export function estimateArtifacts( + projectRoot: string, + options: { maxEntries?: number; maxDepth?: number } = {}, +): ArtifactEstimate { + return measureDirectory(path.join(projectRoot, "target"), options); +} + +/** + * The same bounded walk, for a directory that is already known by path. + * + * The cache view needs it for the pre-v1 cache path `mcpp cache dir` reports + * (plan §3.4 / §8 G6): that directory lives outside the workspace, so it cannot + * be reached through {@link estimateArtifacts}. The same rules apply — bounded, + * never followed through symlinks, and it reports a floor instead of throwing + * when the walk stops early. + */ +export function measureDirectory( + root: string, + options: { maxEntries?: number; maxDepth?: number } = {}, +): ArtifactEstimate { + const maxEntries = budget(options.maxEntries, DEFAULT_MAX_ENTRIES); + const maxDepth = budget(options.maxDepth, DEFAULT_MAX_DEPTH); + + const missing: ArtifactEstimate = { + path: root, + exists: false, + totalBytes: 0, + files: 0, + byTopLevel: [], + }; + let targetStat; + try { + targetStat = lstatSync(root); + } catch { + return missing; + } + // A symlinked `target/` is not followed either, so it reports as absent. + if (!targetStat.isDirectory()) { + return missing; + } + + const byTopLevel: ArtifactGroup[] = []; + let totalBytes = 0; + let files = 0; + let visited = 0; + let truncated: "entries" | "depth" | undefined; + + const add = (group: ArtifactGroup | undefined, bytes: number, count: number): void => { + totalBytes += bytes; + files += count; + if (group !== undefined) { + group.bytes += bytes; + group.files += count; + } + }; + + const hasEntries = (directory: string): boolean => { + try { + return readdirSync(directory, { withFileTypes: true }).length > 0; + } catch { + return false; + } + }; + + const walk = (directory: string, depth: number, group: ArtifactGroup | undefined): void => { + if (truncated === "entries") { + return; + } + if (depth > maxDepth) { + // Only report depth truncation when something was actually left behind. + if (hasEntries(directory)) { + truncated = truncated ?? "depth"; + } + return; + } + let dirents; + try { + dirents = readdirSync(directory, { withFileTypes: true }); + } catch { + // Unreadable directory: tolerate it, the estimate is a floor anyway. + return; + } + for (const dirent of dirents) { + if (visited >= maxEntries) { + truncated = "entries"; + return; + } + visited += 1; + + const full = path.join(directory, dirent.name); + let stat; + try { + stat = lstatSync(full); + } catch { + // Raced with a concurrent `mcpp clean`, or a permission error: skip it. + continue; + } + if (stat.isSymbolicLink()) { + // Never followed; counted as an entry of zero bytes. + add(group, 0, 1); + continue; + } + if (stat.isDirectory()) { + let child = group; + if (depth === 0) { + child = { name: dirent.name, bytes: 0, files: 0 }; + byTopLevel.push(child); + } + walk(full, depth + 1, child); + continue; + } + // Regular files carry a size; fifos/sockets/devices are counted at 0. + add(group, stat.isFile() ? stat.size : 0, 1); + } + }; + + walk(root, 0, undefined); + + byTopLevel.sort( + (left, right) => + right.bytes - left.bytes || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0), + ); + + const estimate: ArtifactEstimate = { + path: root, + exists: true, + totalBytes, + files, + byTopLevel, + }; + if (truncated !== undefined) { + estimate.truncated = truncated; + } + return estimate; +} + +/** One human line for the cache view / status bar, e.g. `"1.43 GiB in 3 groups"`. */ +export function formatArtifactEstimate(estimate: ArtifactEstimate): string { + if (!estimate.exists) { + return "no target/ directory"; + } + const groups = estimate.byTopLevel.length; + const base = `${formatBytes(estimate.totalBytes)} in ${groups} group${groups === 1 ? "" : "s"}`; + return estimate.truncated === undefined ? base : `${base} (truncated: ${estimate.truncated})`; +} diff --git a/src/cli/cache.ts b/src/cli/cache.ts new file mode 100644 index 0000000..addae80 --- /dev/null +++ b/src/cli/cache.ts @@ -0,0 +1,293 @@ +/** + * `mcpp cache …` reading and aggregation (plan §3.4). + * + * Pure functions over captured stdout plus the view model the cache TreeView and + * the statistics webview render. No `vscode` and no process spawning: the caller + * runs `mcpp` (`src/cli/process.ts`) and hands the text in. + * + * Two documents are involved: + * - `mcpp cache list --format json` — a real envelope (`kind: "mcpp.cache"`), + * parsed strictly; a foreign or unknown document is rejected outright; + * - `mcpp cache dir` — human text, so it is parsed leniently and partially. + * + * Everything is defensive: a broken entry is dropped, never thrown. A cache view + * that fails to render is worse than one that renders fewer rows. + */ + +import { ageInDays, bucketIndex, fromUnixSeconds, parseAgeBuckets } from "../util/format"; + +/** Envelope `kind` of `mcpp cache list --format json`. */ +const CACHE_LIST_KIND = "mcpp.cache"; + +/** Fallback `kind` for an entry whose own `kind` is missing or not a string. */ +const UNKNOWN_KIND = "unknown"; + +/** Default number of labels reported by {@link summarizeCache}. */ +const DEFAULT_TOP_N = 5; + +export interface CacheEntry { + accessed?: number; + bytes: number; + complete: boolean; + dir: string; + files?: number; + key: string; + kind: string; + label: string; +} + +export interface CacheInventory { + root: string; + entries: CacheEntry[]; + totalBytes: number; + totalEntries: number; + /** Per-kind totals, largest first. */ + byKind: Array<{ kind: string; entries: number; bytes: number }>; + /** Per-label totals, largest first, capped by `topN`. */ + topLabels: Array<{ label: string; entries: number; bytes: number; oldestAccessed?: number }>; + /** Entries whose `complete` is false. */ + incomplete: CacheEntry[]; + oldestAccessed?: number; + newestAccessed?: number; + /** Age histogram: index i is `[boundaries[i-1], boundaries[i])` days, last bucket is the overflow. */ + ageBuckets: Array<{ fromDays: number; toDays?: number; entries: number; bytes: number }>; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Deterministic, locale-independent string ordering. */ +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function parseCacheEntry(raw: unknown): CacheEntry | undefined { + if (!isRecord(raw)) { + return undefined; + } + const dir = nonEmptyString(raw.dir); + const key = nonEmptyString(raw.key); + const label = nonEmptyString(raw.label); + const bytes = finiteNumber(raw.bytes); + if (dir === undefined || key === undefined || label === undefined || bytes === undefined) { + return undefined; + } + const entry: CacheEntry = { + bytes: Math.max(0, bytes), + // A missing `complete` counts as incomplete: mcpp always emits it, so an + // absent field means a truncated record, which is exactly what + // "incomplete" should surface to the user (`mcpp cache verify`). + complete: raw.complete === true, + dir, + key, + kind: typeof raw.kind === "string" ? raw.kind : UNKNOWN_KIND, + label, + }; + const accessed = finiteNumber(raw.accessed); + if (accessed !== undefined) { + entry.accessed = accessed; + } + const files = finiteNumber(raw.files); + if (files !== undefined) { + entry.files = files; + } + return entry; +} + +/** + * Parse `mcpp cache list --format json` stdout. + * `undefined` when it is not that document (non-JSON, foreign `kind`, bad shape). + */ +export function parseCacheList(stdout: string): { root: string; entries: CacheEntry[] } | undefined { + let document: unknown; + try { + document = JSON.parse(stdout); + } catch { + return undefined; + } + if (!isRecord(document) || document.kind !== CACHE_LIST_KIND) { + return undefined; + } + const data = document.data; + if (!isRecord(data) || !Array.isArray(data.entries)) { + return undefined; + } + const entries: CacheEntry[] = []; + for (const raw of data.entries) { + const entry = parseCacheEntry(raw); + if (entry !== undefined) { + entries.push(entry); + } + } + return { root: typeof data.root === "string" ? data.root : "", entries }; +} + +/** + * Parse `mcpp cache dir` stdout: + * + * ``` + * /home/u/.mcpp/build-cache/v1 + * legacy (unused, removable with `mcpp cache clean --legacy`): /home/u/.mcpp/bmi + * ``` + * + * The legacy line is absent when there is no pre-v1 cache. The legacy path is + * whatever follows the **last** `": "` on that line — the backticked command name + * in the parenthetical must never be mistaken for a path. + */ +export function parseCacheDir(stdout: string): { root?: string; legacyPath?: string } { + let root: string | undefined; + let legacyPath: string | undefined; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + if (/^legacy\b/i.test(trimmed)) { + const separator = trimmed.lastIndexOf(": "); + const candidate = separator >= 0 ? trimmed.slice(separator + 2).trim() : ""; + if (candidate.length > 0) { + legacyPath = candidate; + } + continue; + } + if (root === undefined) { + root = trimmed; + } + } + const result: { root?: string; legacyPath?: string } = {}; + if (root !== undefined) { + result.root = root; + } + if (legacyPath !== undefined) { + result.legacyPath = legacyPath; + } + return result; +} + +/** + * Aggregate a cache listing into what the views need. + * + * `ageBoundaries` come from `mcpp.views.cache.ageBuckets` as strings (`"1d"`, + * `"7d"`, `"30d"`); see `parseAgeBuckets`. Entries without a usable `accessed` + * timestamp are excluded from the histogram and from oldest/newest, because + * their age is unknown — guessing would promise reclaimable space mcpp may keep. + */ +export function summarizeCache( + root: string, + entries: readonly CacheEntry[], + options: { topN?: number; ageBoundaries?: readonly string[] } = {}, +): CacheInventory { + const topN = options.topN === undefined ? DEFAULT_TOP_N : Math.max(0, Math.floor(options.topN)); + const boundaries = parseAgeBuckets(options.ageBoundaries); + const now = new Date(); + + const byKindTotals = new Map(); + const labelTotals = new Map< + string, + { entries: number; bytes: number; oldestAccessed?: number } + >(); + const incomplete: CacheEntry[] = []; + + const ageBuckets: CacheInventory["ageBuckets"] = []; + for (let index = 0; index <= boundaries.length; index += 1) { + const bucket: CacheInventory["ageBuckets"][number] = { + fromDays: index === 0 ? 0 : boundaries[index - 1], + entries: 0, + bytes: 0, + }; + if (index < boundaries.length) { + bucket.toDays = boundaries[index]; + } + ageBuckets.push(bucket); + } + + let totalBytes = 0; + let oldestAccessed: number | undefined; + let newestAccessed: number | undefined; + + for (const entry of entries) { + const bytes = Number.isFinite(entry.bytes) ? Math.max(0, entry.bytes) : 0; + totalBytes += bytes; + + const kindTotals = byKindTotals.get(entry.kind) ?? { entries: 0, bytes: 0 }; + kindTotals.entries += 1; + kindTotals.bytes += bytes; + byKindTotals.set(entry.kind, kindTotals); + + const labelEntry = labelTotals.get(entry.label) ?? { entries: 0, bytes: 0 }; + labelEntry.entries += 1; + labelEntry.bytes += bytes; + if (entry.accessed !== undefined && Number.isFinite(entry.accessed)) { + if (labelEntry.oldestAccessed === undefined || entry.accessed < labelEntry.oldestAccessed) { + labelEntry.oldestAccessed = entry.accessed; + } + if (oldestAccessed === undefined || entry.accessed < oldestAccessed) { + oldestAccessed = entry.accessed; + } + if (newestAccessed === undefined || entry.accessed > newestAccessed) { + newestAccessed = entry.accessed; + } + const at = fromUnixSeconds(entry.accessed); + if (at !== undefined) { + const bucket = ageBuckets[bucketIndex(ageInDays(at, now), boundaries)]; + bucket.entries += 1; + bucket.bytes += bytes; + } + } + labelTotals.set(entry.label, labelEntry); + + if (!entry.complete) { + incomplete.push(entry); + } + } + + const byKind = [...byKindTotals.entries()] + .map(([kind, totals]) => ({ kind, entries: totals.entries, bytes: totals.bytes })) + .sort( + (left, right) => + right.bytes - left.bytes || + right.entries - left.entries || + compareStrings(left.kind, right.kind), + ); + + const rankedLabels = [...labelTotals.entries()] + .map(([label, totals]) => ({ + label, + entries: totals.entries, + bytes: totals.bytes, + ...(totals.oldestAccessed === undefined ? {} : { oldestAccessed: totals.oldestAccessed }), + })) + .sort( + (left, right) => + right.bytes - left.bytes || + right.entries - left.entries || + compareStrings(left.label, right.label), + ); + + const inventory: CacheInventory = { + root, + entries: [...entries], + totalBytes, + totalEntries: entries.length, + byKind, + topLabels: rankedLabels.slice(0, topN), + incomplete, + ageBuckets, + }; + if (oldestAccessed !== undefined) { + inventory.oldestAccessed = oldestAccessed; + } + if (newestAccessed !== undefined) { + inventory.newestAccessed = newestAccessed; + } + return inventory; +} diff --git a/src/cli/clean.ts b/src/cli/clean.ts new file mode 100644 index 0000000..f2be9a6 --- /dev/null +++ b/src/cli/clean.ts @@ -0,0 +1,242 @@ +/** + * Cleanup, as plans rather than as calls. + * + * Every destructive action this extension can run is described here: the exact + * argv, how much confirmation it needs, and whether a preview must be shown + * first. Keeping it in one pure table means the UI cannot invent a sixth level + * of danger, and the tests can assert the policy instead of the wording. + * + * The policy (from the plan, §3.4.3): + * + * | level | what | confirmation | + * |---|---|---| + * | 0 | read-only (`cache list`/`dir`/`info`/`verify`) | none | + * | 1 | project body (`mcpp clean`) | modal, with the measured size | + * | 2 | budget/targeted (`cache gc`, `cache prune`, `cache clean --deps/--std`) | preview, then modal | + * | 3 | everything (`cache clean --all`) | preview, modal, then a second acknowledgement | + * + * The user asked for `cache clean --all` to be reachable — so it is — but it is + * the only level-3 action, and its text names every project on the machine. + */ + +export type CleanAction = + | "project" + | "stale" + | "cacheGc" + | "cachePrune" + | "cacheDeps" + | "cacheStd" + | "cacheAll" + | "cacheLegacy" + | "cacheVerify" + | "cacheList"; + +export interface CleanOptions { + /** `mcpp clean --stale --older-than d`; 0 means "keep none". */ + staleDays?: number; + /** `mcpp cache gc --max-size GiB`; 0 means "the user types a budget". */ + budgetGiB?: number; + /** `mcpp cache prune --older-than d`. */ + pruneAgeDays?: number; +} + +export interface CleanPlan { + action: CleanAction; + /** The mcpp subcommand, for logging: `clean` or `cache`. */ + group: "clean" | "cache"; + argv: readonly string[]; + /** 0 read-only, 1 modal, 2 preview+modal, 3 preview+modal+acknowledge. */ + level: 0 | 1 | 2 | 3; + /** A `--dry-run`-style pass the UI must show before asking. */ + preview: boolean; + /** A second, explicit acknowledgement (a checkbox) is required. */ + acknowledge: boolean; + /** English key for the dialogue title. */ + titleKey: string; + /** English key for the body; the caller adds the measured figures. */ + detailKey: string; + /** This action is refused when the workspace is untrusted. */ + requiresTrust: boolean; +} + +function staleDaysOf(options: CleanOptions): number { + const days = options.staleDays ?? 3; + return Number.isFinite(days) ? Math.max(0, Math.round(days)) : 3; +} + +export function planClean(action: CleanAction, options: CleanOptions = {}): CleanPlan { + switch (action) { + case "project": + return { + action, + group: "clean", + argv: ["clean"], + level: 1, + preview: false, + acknowledge: false, + titleKey: "Remove the whole target/ directory?", + detailKey: "This deletes every build directory of this project. The next build starts from scratch. The shared build cache is not touched.", + requiresTrust: true, + }; + case "stale": + return { + action, + group: "clean", + argv: ["clean", "--stale", "--older-than", `${staleDaysOf(options)}d`], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Remove build directories mcpp no longer considers current?", + detailKey: "Directories written in the last {0} day(s) are kept. mcpp decides which ones are stale; the preview above is its own list.", + requiresTrust: true, + }; + case "cacheGc": { + const budget = options.budgetGiB ?? 0; + const argv = budget > 0 ? ["cache", "gc", "--max-size", `${budget}GiB`] : ["cache", "gc"]; + return { + action, + group: "cache", + argv, + level: 2, + preview: true, + acknowledge: false, + titleKey: "Collect the shared build cache to a budget?", + detailKey: "mcpp drops least-recently-used entries until the cache fits. Projects that used a dropped entry rebuild it.", + requiresTrust: true, + }; + } + case "cachePrune": { + const days = options.pruneAgeDays ?? 30; + return { + action, + group: "cache", + argv: ["cache", "prune", "--older-than", `${Number.isFinite(days) ? Math.max(1, Math.round(days)) : 30}d`], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Drop cache entries that have not been used for a while?", + detailKey: "Entries unused for longer than {0} day(s) are removed. Projects that used one rebuild it.", + requiresTrust: true, + }; + } + case "cacheDeps": + return { + action, + group: "cache", + argv: ["cache", "clean", "--deps"], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Drop every package entry from the shared cache?", + detailKey: "Every mcpp project on this machine rebuilds its dependencies afterwards. Standard library module entries are kept.", + requiresTrust: true, + }; + case "cacheStd": + return { + action, + group: "cache", + argv: ["cache", "clean", "--std"], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Drop every standard library module entry from the shared cache?", + detailKey: "Every mcpp project on this machine re-prepares the standard library module afterwards. Package entries are kept.", + requiresTrust: true, + }; + case "cacheAll": + return { + action, + group: "cache", + argv: ["cache", "clean", "--all"], + level: 3, + preview: true, + acknowledge: true, + titleKey: "Drop the entire shared build cache?", + detailKey: "Package and standard library entries go. Every mcpp project on this machine rebuilds from scratch afterwards.", + requiresTrust: true, + }; + case "cacheLegacy": + return { + action, + group: "cache", + argv: ["cache", "clean", "--legacy"], + level: 2, + preview: false, + acknowledge: false, + titleKey: "Remove the unused pre-v1 cache?", + detailKey: "mcpp no longer reads $MCPP_HOME/bmi. Nothing is rebuilt because nothing uses it.", + requiresTrust: true, + }; + case "cacheVerify": + return { + action, + group: "cache", + argv: ["cache", "verify"], + level: 0, + preview: false, + acknowledge: false, + titleKey: "Verify the shared build cache", + detailKey: "Every entry's manifest is checked against the files on disk.", + requiresTrust: true, + }; + default: + return { + action: "cacheList", + group: "cache", + argv: ["cache", "list", "--format", "json"], + level: 0, + preview: false, + acknowledge: false, + titleKey: "Read the shared build cache", + detailKey: "Nothing is removed.", + requiresTrust: true, + }; + } +} + +/** True when `mcpp clean`'s global cache also goes — the option the UI keeps un-ticked. */ +export function withSharedCache(plan: CleanPlan): CleanPlan { + if (plan.action !== "project") { + return plan; + } + return { + ...plan, + argv: [...plan.argv, "--bmi-cache"], + level: 3, + acknowledge: true, + detailKey: "This also empties the shared build cache, so every mcpp project on this machine rebuilds from scratch.", + }; +} + +/** The single destructive action that may be shortened to one confirmation. */ +export function planProblems(): string[] { + const problems: string[] = []; + const actions: CleanAction[] = [ + "project", + "stale", + "cacheGc", + "cachePrune", + "cacheDeps", + "cacheStd", + "cacheAll", + "cacheLegacy", + "cacheVerify", + "cacheList", + ]; + for (const action of actions) { + const plan = planClean(action); + if (plan.requiresTrust === false) { + problems.push(`${action} does not require a trusted workspace`); + } + if (plan.level >= 2 && !plan.preview && plan.action !== "cacheLegacy") { + problems.push(`${action} must be previewed before it runs`); + } + if (plan.level === 3 && !plan.acknowledge) { + problems.push(`${action} must require a second acknowledgement`); + } + if (plan.argv.some((part) => part.includes(" "))) { + problems.push(`${action} passes an argument containing a space`); + } + } + return problems; +} diff --git a/src/cli/controller.ts b/src/cli/controller.ts new file mode 100644 index 0000000..087116c --- /dev/null +++ b/src/cli/controller.ts @@ -0,0 +1,1160 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +import * as vscode from "vscode"; + +import { + hostDefaultToolchains, + mcppCommandArguments, + normalizeToolchainSpec, + parseToolchainList, + toolchainInstallKind, + toolchainSpecTargetHint, + type ToolchainInventory, + type ToolchainItem, +} from "./toolchain"; +import { + discoveryBoundaryOf, + type DiscoveryBoundary, + type McppProjectDiscovery, +} from "../projects/discovery"; +import { updateEditorTitleButtonsContext } from "../projects/context"; +import { createLogger, type Logger } from "../util/log"; +import { runProcess } from "./process"; +import { + McppOperationRegistry, + classifyTaskExit, + projectTaskPlan, + TASK_ARGUMENT_SETTINGS, + shouldRefreshLanguageServerAfterTask, + type ProjectTaskKind, + type TaskCompletion, +} from "./tasks"; +import { classifyExit, explainHint, type McppFailureKind, type McppOutcome } from "./errors"; +import { statusBarBackgroundColour } from "./statusBar"; +import { CLI_COMMANDS } from "../commands/ids"; +import { QUICK_MENU_GROUPS, quickMenuIconAsset, quickMenuItems, quickMenuStatusText } from "../commands/menu"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import { controllerLabels } from "./labels"; +import { runNewProjectFlow, validateNewProjectName } from "./newProject"; + +export interface McppCliControllerOptions { + output: vscode.OutputChannel; + /** + * The extension's own directory, for the assets that cannot be a `ThemeIcon` — + * today only the coloured quick menu icons, which VS Code paints as an image + * because it drops a `ThemeIcon`'s colour in a quick pick. + */ + extensionUri: vscode.Uri; + currentProject: () => McppProjectDiscovery | undefined; + afterProjectTask: ( + project: McppProjectDiscovery, + kind: ProjectTaskKind, + completion: TaskCompletion, + ) => Promise; + isTrusted: () => boolean; + /** + * `mcpp.ui.statusBar.showLanguageServer`: one short line describing the C++ + * Modules state, or `undefined` when there is nothing to show. A callback + * instead of an import so this controller never depends on `src/mcppls/**`; + * the extension layer owns the bridge and passes it in. + */ + languageServerSummary?: () => string | undefined; +} + +interface ToolchainPickItem extends vscode.QuickPickItem { + spec?: string; + toolchain?: ToolchainItem; + customInput?: boolean; +} + +type OperationToken = object; + +/** Where the generated quick menu icons live, under the extension root. */ +const QUICK_MENU_ICON_DIRECTORY = "media/quick-menu"; + +export interface ProjectTaskRunOptions { + /** Set to false when the caller performs its own post-task action. */ + notify?: boolean; +} + +function taskScope(root: string): vscode.WorkspaceFolder | vscode.TaskScope { + return vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)) + ?? vscode.TaskScope.Workspace; +} + +function workingDirectory(project: McppProjectDiscovery | undefined): string { + if (project !== undefined) { + return project.root; + } + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); +} + +function commandLine(executable: string, args: string[]): string { + return [executable, ...args].join(" "); +} + +// ─── pure policy ──────────────────────────────────────────────────────────── +// +// Every decision a setting makes is computed by a pure function in this block, +// so `test/cli/controller.*.test.ts` can assert the decision without an editor. +// The class below only executes what these functions decide. + +export type TaskRevealSetting = "always" | "onFailure" | "never"; + +export interface TaskTerminalUi { + /** `mcpp.task.revealTerminal`. */ + reveal: TaskRevealSetting; + /** `mcpp.task.focusTerminal`: hand keyboard focus to the task terminal. */ + focus: boolean; + /** `mcpp.task.clearTerminal`: clear the panel before the command runs. */ + clearBeforeRun: boolean; +} + +export function taskTerminalUi( + revealTerminal: unknown, + focusTerminal: unknown, + clearTerminal: unknown, +): TaskTerminalUi { + return { + reveal: + revealTerminal === "never" || revealTerminal === "onFailure" ? revealTerminal : "always", + focus: focusTerminal === true, + clearBeforeRun: clearTerminal !== false, + }; +} + +/** `workbench.action.terminal.*` commands to run once the task has finished. */ +export function terminalCommandsAfterTask(ui: TaskTerminalUi, failed: boolean): readonly string[] { + // VS Code has no "reveal without focusing" command, so the one command below + // is both the reveal and the focus for the failure-only setting. + return failed && ui.reveal === "onFailure" ? ["workbench.action.terminal.focus"] : []; +} + +/** The problem matcher `package.json` must contribute under `contributes.problemMatchers`. */ +export const PROBLEM_MATCHER_NAME = "$mcpp"; + +export function problemMatchersFor(enabled: unknown): readonly string[] { + return enabled === false ? [] : [PROBLEM_MATCHER_NAME]; +} + +/** + * `mcpp.task.confirmClean`: this decides only the controller's own prompt for + * `mcpp.clean`. The cleanup plan (`src/cli/clean.ts`) keeps its own level-2/3 + * confirmations, which this setting can never remove. + */ +export function cleanConfirmationRequired(confirmClean: unknown): boolean { + return confirmClean !== false; +} + +export type ConfirmationStrength = "notice" | "modal"; + +export interface ConfirmationPolicy { + installToolchain: ConfirmationStrength; + globalDefault: ConfirmationStrength; +} + +/** + * `mcpp.ui.confirmDestructiveOnly` (default true) reserves a modal for actions + * that cannot be undone. Turning it off escalates these two undoable prompts + * from a dismissible notification to a modal — the prompt, its text and its + * buttons are identical in both modes, so the setting can only ask *more* + * insistently, never less often. + */ +export function confirmationPolicy(confirmDestructiveOnly: unknown): ConfirmationPolicy { + const strength: ConfirmationStrength = confirmDestructiveOnly === false ? "modal" : "notice"; + return { installToolchain: strength, globalDefault: strength }; +} + +/** `mcpp.ui.statusBar.showLanguageServer`: the suffix the status item gains. */ +export function languageServerStatusSuffix(show: unknown, summary: string | undefined): string | undefined { + if (show !== true) { + return undefined; + } + const text = summary?.trim(); + return text === undefined || text.length === 0 ? undefined : text; +} + +export type SuccessReport = "silent" | "statusBar" | "toast"; + +/** `mcpp.ui.notifications.success`; only a *successful* result consults this. */ +export function successReportOf(value: unknown): SuccessReport { + return value === "silent" || value === "toast" ? value : "statusBar"; +} + +/** + * `mcpp.ui.notifications.dedupeMinutes`: true when the same message may be + * shown again. `0` (or an unusable value) shows every notification. + */ +export function dedupeAllows(lastAt: number | undefined, now: number, dedupeMinutes: unknown): boolean { + if (typeof dedupeMinutes !== "number" || !Number.isFinite(dedupeMinutes) || dedupeMinutes <= 0) { + return true; + } + if (typeof lastAt !== "number" || !Number.isFinite(lastAt)) { + return true; + } + return now - lastAt >= dedupeMinutes * 60_000; +} + +export const STATUS_BAR_SUCCESS_MAX = 60; + +/** A status bar item is a label, not a paragraph. */ +export function statusBarSuccessText(message: string): string { + const single = message.replace(/\s+/g, " ").trim(); + return single.length <= STATUS_BAR_SUCCESS_MAX + ? single + : `${single.slice(0, STATUS_BAR_SUCCESS_MAX - 1)}…`; +} + +export interface FailureAdvice { + kind: McppFailureKind; + exitCode: number; + /** `mcpp self explain `, when the output or code names a diagnostic. */ + hint?: string; +} + +/** + * `src/cli/errors.ts` (SPEC-003) applied to a failed run: the failure kind that + * picks the guidance, plus the `mcpp self explain` follow-up when there is a + * diagnostic code to explain. + */ +export function failureAdvice(args: readonly string[], exitCode: number, output: string): FailureAdvice { + const outcome: McppOutcome = { ...classifyExit(exitCode, args), detail: output }; + return { kind: outcome.kind, exitCode: outcome.exitCode, hint: explainHint(outcome) }; +} + +/** The exit-code-specific next step, or `undefined` to keep the caller's message. */ +function failureGuidanceText(kind: McppFailureKind): string | undefined { + switch (kind) { + case "usage": + return t("mcpp rejected the command line; check the arguments and run it from the project root."); + case "environment": + return t("mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing."); + case "internal": + return t("mcpp reported an internal error; re-run with the mcpp output channel open and report it."); + case "unknown-command": + return t("This mcpp build does not recognise that command; update mcpp or check the spelling."); + case "build-failed": + return t("The program did not build; see the task terminal for the compiler output."); + default: + return undefined; + } +} + +/** + * The `vscode`-side read of `mcpp.project.discoveryBoundary`. `extension.ts` + * passes the result into `findNearestMcppProject`, whose walk stays pure. The + * resource is the workspace folder, because the setting is resource-scoped. + */ +export function discoveryBoundaryFromSettings(resource?: vscode.Uri): DiscoveryBoundary { + return discoveryBoundaryOf(read("mcpp.project.discoveryBoundary", resource)); +} + +const SUCCESS_STATUS_MS = 6000; + +export class McppCliController { + private readonly status: vscode.StatusBarItem; + + private readonly operations = new McppOperationRegistry(); + + /** `mcpp.log.level` decides what reaches the `mcpp` channel; see `src/util/log.ts`. */ + private readonly logger: Logger; + + /** `mcpp.ui.notifications.dedupeMinutes`: message signature -> last shown (ms). */ + private readonly lastNotified = new Map(); + + /** Reverts the transient success text in the status item. */ + private statusRevert: ReturnType | undefined; + + public constructor(private readonly options: McppCliControllerOptions) { + this.status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 40); + this.status.command = CLI_COMMANDS.showMenu; + this.logger = createLogger(this.options.output, () => read("mcpp.log.level")); + this.applyStatusBar(); + } + + public register(): vscode.Disposable[] { + this.applyStatusBar(); + const applyEditorTitleButtons = (): void => { + void updateEditorTitleButtonsContext({ + enabled: () => read("mcpp.task.editorTitleButtons"), + setContextValue: (key, value) => vscode.commands.executeCommand("setContext", key, value), + }).catch(() => undefined); + }; + applyEditorTitleButtons(); + const disposables: vscode.Disposable[] = [ + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.ui.statusBar")) { + this.applyStatusBar(); + } + if (event.affectsConfiguration("mcpp.task.editorTitleButtons")) { + applyEditorTitleButtons(); + } + }), + this.status, + vscode.commands.registerCommand(CLI_COMMANDS.showMenu, this.guarded(() => this.showMenu())), + vscode.commands.registerCommand(CLI_COMMANDS.newProject, this.guarded(() => this.newProject())), + vscode.commands.registerCommand(CLI_COMMANDS.build, this.guarded(() => this.runProjectTask("build"))), + vscode.commands.registerCommand(CLI_COMMANDS.run, this.guarded(() => this.runProjectTask("run"))), + vscode.commands.registerCommand(CLI_COMMANDS.test, this.guarded(() => this.runProjectTask("test"))), + vscode.commands.registerCommand(CLI_COMMANDS.clean, this.guarded(() => this.runProjectTask("clean"))), + vscode.commands.registerCommand(CLI_COMMANDS.showToolchains, this.guarded(() => this.showToolchains())), + vscode.commands.registerCommand(CLI_COMMANDS.installToolchain, this.guarded(() => this.installToolchain())), + vscode.commands.registerCommand(CLI_COMMANDS.selectDefaultToolchain, this.guarded(() => this.selectDefaultToolchain())), + vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()), + vscode.workspace.onDidChangeWorkspaceFolders(() => this.refreshStatus()), + ]; + this.refreshStatus(); + return disposables; + } + + public refreshStatus(): void { + if (this.options.currentProject() === undefined) { + this.status.hide(); + return; + } + this.applyStatusBar(); + } + + public isBusy(): boolean { + return this.operations.hasActive(); + } + + public async runProjectTask( + kind: ProjectTaskKind, + options: ProjectTaskRunOptions = {}, + ): Promise { + const project = this.requireProject(); + if (project === undefined || !this.requireTrusted()) { + return undefined; + } + + // `mcpp.task.confirmClean`: one prompt for this entry point; the cleanup + // plan's own level-2/3 confirmations are untouched by it. + if (kind === "clean" && cleanConfirmationRequired(read("mcpp.task.confirmClean", vscode.Uri.file(project.root)))) { + const labels = controllerLabels(); + const choice = await vscode.window.showWarningMessage( + t("Delete the target/ directory of this project: {0}/target", project.root), + { modal: true, detail: t("This does not clean the global BMI cache.") }, + labels.confirmClean, + ); + if (choice !== labels.confirmClean) { + return undefined; + } + } + + // `mcpp.runtime.concurrency` picks the mutual-exclusion scope: one task at a + // time per project (the default), or one task at a time for the whole window. + const globalScope = read("mcpp.runtime.concurrency") === "global"; + const token: OperationToken = {}; + const active = globalScope + ? this.operations.beginGlobal(token) + : this.operations.beginProject(project.root, token); + if (active !== undefined) { + const labels = controllerLabels(); + const choice = await vscode.window.showWarningMessage( + t("An mcpp operation is already running; not starting {0} now.", kind), + labels.showTasks, + ); + if (choice === labels.showTasks) { + await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); + } + return undefined; + } + + let completion: TaskCompletion | undefined; + try { + const plan = projectTaskPlan(kind, read(TASK_ARGUMENT_SETTINGS[kind])); + completion = await this.executeTask( + project.root, + this.mcppExecutable(project), + plan.title, + plan.args, + ); + this.appendTaskCompletion(project.root, plan.title, plan.args, completion); + } finally { + if (globalScope) { + this.operations.finishGlobal(token); + } else { + this.operations.finishProject(project.root, token); + } + } + + if (options.notify !== false + && completion !== undefined + && shouldRefreshLanguageServerAfterTask(kind, completion)) { + await this.options.afterProjectTask(project, kind, completion); + } + return completion; + } + + public async showToolchains(): Promise { + if (!this.requireTrusted()) { + return; + } + const project = this.options.currentProject(); + const inventory = await this.readToolchainInventory(project); + if (inventory === undefined) { + return; + } + + const items = this.inventoryItems(inventory, false, project); + if (items.length === 0) { + await vscode.window.showInformationMessage( + t("mcpp listed no usable toolchain; see the raw result in the mcpp output channel."), + ); + return; + } + await vscode.window.showQuickPick(items, { + title: t("mcpp toolchains and targets"), + placeHolder: t("Read-only view of what mcpp currently resolves"), + matchOnDescription: true, + matchOnDetail: true, + }); + } + + public async installToolchain(): Promise { + if (!this.requireTrusted()) { + return; + } + const project = this.options.currentProject(); + const inventory = await this.readToolchainInventory(project); + const spec = await this.pickInstallSpec(inventory); + if (spec === undefined) { + return; + } + const installKind = toolchainInstallKind(spec); + const targetHint = toolchainSpecTargetHint(spec); + const labels = controllerLabels(); + const confirmLabel = installKind === "system-detect" ? labels.confirmDetect : labels.confirmInstall; + const confirmation = installKind === "system-detect" + ? t("mcpp will detect the system MSVC ({0}).", spec) + : targetHint === "target" + ? t("The compatible spec {0}, which may carry target semantics, is handed to mcpp to install, and mcpp validates it in the end.", spec) + : installKind === "managed-target" + ? t("The compatible spec {0} carrying target semantics is handed to mcpp to install.", spec) + : t("mcpp toolchain {0} is installed (no target specified, the host target is used).", spec); + const detail = installKind === "system-detect" + ? t("mcpp does not download or install MSVC; it detects Visual Studio and points to the official installation guide when it is missing.") + : targetHint === "target" + ? t("mcpp decides whether the compiler prefix is a valid triple; when it is, it may download a larger toolchain package for that target.") + : installKind === "managed-target" + ? t("mcpp normalises the compatible spelling and may download a larger toolchain package for that target.") + : t("The installation may download a large toolchain package and modifies the mcpp global cache."); + + const policy = confirmationPolicy(read("mcpp.ui.confirmDestructiveOnly")); + const choice = await vscode.window.showWarningMessage( + confirmation, + this.warningOptions(policy.installToolchain, detail), + confirmLabel, + ); + if (choice !== confirmLabel) { + return; + } + + const token: OperationToken = {}; + const active = this.operations.beginGlobal(token); + if (active !== undefined) { + const duplicateChoice = await vscode.window.showWarningMessage( + t("An mcpp operation is already running."), + labels.showTasks, + ); + if (duplicateChoice === labels.showTasks) { + await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); + } + return; + } + + const taskTitle = installKind === "system-detect" + ? t("mcpp: detect system MSVC ({0})", spec) + : t("mcpp: install toolchain {0}", spec); + try { + const installArgs = mcppCommandArguments("toolchain", "install", spec); + const completion = await this.executeTask( + workingDirectory(project), + this.mcppExecutable(project), + taskTitle, + installArgs, + ); + this.appendTaskCompletion( + workingDirectory(project), + taskTitle, + installArgs, + completion, + ); + if (completion.state !== "succeeded") { + return; + } + } finally { + this.operations.finishGlobal(token); + } + + if (installKind === "managed-target") { + this.reportSuccess(t("mcpp finished {0}. This first release does not change the target default; to set one, use the mcpp CLI with --target.", spec)); + return; + } + + const refreshed = await this.readToolchainInventory(project); + const defaultChoice = await vscode.window.showInformationMessage( + installKind === "system-detect" + ? t("MSVC detection finished. Choose a global default from the latest list?") + : t("Toolchain {0} installed. Choose a global default from the latest list?", spec), + labels.chooseGlobalDefault, + ); + if (defaultChoice === labels.chooseGlobalDefault && refreshed !== undefined) { + await this.selectDefaultToolchainFromInventory(project, refreshed); + } + } + + public async selectDefaultToolchain(): Promise { + if (!this.requireTrusted()) { + return; + } + const project = this.options.currentProject(); + const inventory = await this.readToolchainInventory(project); + if (inventory !== undefined) { + await this.selectDefaultToolchainFromInventory(project, inventory); + } + } + + private async selectDefaultToolchainFromInventory( + project: McppProjectDiscovery | undefined, + inventory: ToolchainInventory, + ): Promise { + const installed = hostDefaultToolchains(inventory); + if (installed.length === 0) { + await vscode.window.showWarningMessage( + t("No installed toolchain is usable for the host target, so a global default cannot be set here. For target-specific toolchains use the mcpp CLI; on Windows install Visual Studio first."), + ); + return; + } + + const items: ToolchainPickItem[] = installed.map((toolchain) => ({ + label: `${toolchain.effective ? "$(check) " : ""}${toolchain.spec}`, + description: toolchain.source === "system" ? t("System toolchain (mcpp only detects it)") : t("Toolchain managed by mcpp"), + detail: toolchain.effective ? t("Effective toolchain of this project") : undefined, + spec: toolchain.spec, + toolchain, + })); + const picked = await vscode.window.showQuickPick(items, { + title: t("Choose the mcpp global default toolchain"), + placeHolder: this.isNestedWorkspaceProject(project) + ? t("The project root sits below the VS Code folder; the real build resolution is whatever mcpp reports") + : inventory.projectOverridesGlobal + ? t("The project currently overrides the global default; this only changes the current mcpp global configuration") + : t("Choosing here only changes the mcpp global configuration; it does not build the project"), + matchOnDescription: true, + matchOnDetail: true, + }); + if (picked?.spec === undefined) { + return; + } + + const defaultPolicy = confirmationPolicy(read("mcpp.ui.confirmDestructiveOnly")); + const warningLabels = controllerLabels(); + const choice = await vscode.window.showWarningMessage( + t("The global default pair is set to {0} + host target. The mcpp.toml/target configuration of the current project can still override it.", picked.spec), + this.warningOptions( + defaultPolicy.globalDefault, + t("mcpp also clears the global default_target; the configuration file location is decided by the current mcpp installation and MCPP_HOME."), + ), + warningLabels.confirmDefault, + ); + if (choice !== warningLabels.confirmDefault) { + return; + } + + const token: OperationToken = {}; + const active = this.operations.beginGlobal(token); + if (active !== undefined) { + await vscode.window.showWarningMessage(t("An mcpp operation is already running."), warningLabels.showTasks); + return; + } + + try { + const args = mcppCommandArguments("toolchain", "default", picked.spec); + const result = await runProcess( + this.mcppExecutable(project), + args, + workingDirectory(project), + ); + this.appendShortCommand(t("Set the global default toolchain"), this.mcppExecutable(project), args, result); + if (result.exitCode !== 0) { + this.reportCommandFailure( + args, + result.exitCode, + `${result.stdout}\n${result.stderr}`, + t("Setting the global default toolchain failed (exit code {0}). See the mcpp output channel.", result.exitCode), + ); + return; + } + + } finally { + this.operations.finishGlobal(token); + } + + const buildChoice = await vscode.window.showInformationMessage( + t("The mcpp global default is now {0} + host target. Cleaning the old toolchain artifacts and rebuilding is recommended.", picked.spec), + ...(project === undefined ? [] : [warningLabels.cleanAndBuild]), + ); + if (buildChoice === warningLabels.cleanAndBuild && project !== undefined) { + const cleanArgs = mcppCommandArguments("clean"); + const cleanResult = await runProcess(this.mcppExecutable(project), cleanArgs, project.root); + this.appendShortCommand(t("Clean old artifacts"), this.mcppExecutable(project), cleanArgs, cleanResult); + await this.runProjectTask("build"); + } + } + + private async pickInstallSpec(inventory: ToolchainInventory | undefined): Promise { + const labels = controllerLabels(); + const items: ToolchainPickItem[] = []; + for (const toolchain of inventory?.available ?? []) { + items.push({ + label: toolchain.spec, + description: t("Versions mcpp aggregates by family; this operation installs for the host target"), + spec: toolchain.spec, + }); + } + items.push({ + label: labels.installCustom, + detail: t("Accepts family, family@version, namespace, partial versions and mcpp's compatible legacy spellings"), + customInput: true, + }); + + const picked = await vscode.window.showQuickPick(items, { + title: t("Install an mcpp toolchain"), + placeHolder: t("No target is selected; for a target-specific install use the mcpp CLI"), + matchOnDescription: true, + matchOnDetail: true, + }); + if (picked === undefined) { + return undefined; + } + if (!picked.customInput) { + return picked.spec; + } + + const input = await vscode.window.showInputBox({ + title: t("Enter a toolchain spec"), + prompt: t("For example gcc, llvm@20.1.7, xim:gcc@16, msvc, mingw; mcpp normalises compatible spellings"), + placeHolder: "gcc@16", + validateInput: (value) => { + const normalized = normalizeToolchainSpec(value); + if (normalized === undefined) { + return t("Enter a family, family@version, family version, namespace or an mcpp-compatible spec"); + } + return undefined; + }, + }); + return input === undefined ? undefined : normalizeToolchainSpec(input); + } + + private inventoryItems( + inventory: ToolchainInventory, + includeActions: boolean, + project: McppProjectDiscovery | undefined, + ): ToolchainPickItem[] { + const items: ToolchainPickItem[] = []; + const nestedView = this.isNestedWorkspaceProject(project); + if (inventory.effective !== undefined) { + items.push({ + label: `$(check) ${nestedView ? t("mcpp list toolchain for this directory") : t("Current effective toolchain")}: ${inventory.effective.spec}`, + description: nestedView + ? t("This is the current-directory view; the value mcpp build resolves is authoritative") + : inventory.projectOverridesGlobal ? t("From this project's mcpp.toml, overriding the global default") : t("From the global default"), + detail: inventory.effectiveTarget === undefined + ? t("Effective target: host (mcpp shows no explicit target)") + : t("Effective target: {0}", inventory.effectiveTarget), + }); + } + if (inventory.globalDefaultSpec !== undefined) { + items.push({ + label: t("Global default: {0}", inventory.globalDefaultSpec), + description: nestedView + ? t("This is the current-directory view; overrides from the project or a parent configuration are decided by the actual build") + : inventory.projectOverridesGlobal ? t("This project may not be using this value") : t("mcpp global configuration"), + }); + } else if (inventory.recognized) { + items.push({ + label: t("Global default: "), + description: t("mcpp has no global default toolchain yet"), + }); + } + for (const toolchain of inventory.installed) { + items.push({ + label: `${toolchain.effective ? "$(check) " : ""}${toolchain.spec}`, + description: toolchain.source === "system" ? t("System: system toolchain, detection only") : t("Installed"), + detail: toolchain.effective + ? nestedView ? t("mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace") : t("Currently effective item") + : undefined, + spec: toolchain.spec, + toolchain, + }); + } + for (const target of inventory.targets) { + items.push({ + label: `${target.effective ? "$(check) " : ""}target ${target.target}`, + description: `${target.status}${target.toolchainSpec === undefined ? "" : ` · ${t("convention {0}", target.toolchainSpec)}`}`, + detail: target.note.length === 0 ? t("The target axis is shown read-only; to select a target use the mcpp CLI") : target.note, + }); + } + for (const toolchain of inventory.available) { + items.push({ + label: t("Installable: {0}", toolchain.spec), + description: t("Index versions mcpp aggregates by family; no host payload is promised"), + spec: toolchain.spec, + }); + } + if (includeActions) { + items.push({ + label: t("$(cloud-download) Install a toolchain…"), + detail: t("Back to the toolchain installation flow"), + customInput: true, + }); + } + return items; + } + + /** + * The status bar menu: one section per group, one row per command. + * + * The section titles come from `QUICK_MENU_GROUPS`, so an entry can never be + * filed under a heading that does not exist, and the headers are only emitted + * where the entries actually change group — a group whose commands are all + * filtered out (C++ Modules, when `mcpp.languageService.menuItems` is off) + * takes its heading with it. + * + * The icons are the generated ones under `media/quick-menu/`, handed over as a + * `{ light, dark }` pair so the row is legible in both themes. They cannot be + * `ThemeIcon`s: VS Code drops a `ThemeIcon`'s colour in a quick pick and paints + * a `Uri` as an image, so a coloured row needs a coloured file. See the note at + * the top of `src/commands/menu.ts`. + * + * Typing filters by command name only. The group names are separator rows, and + * VS Code hides separators as soon as a query is present, so they are not + * matchable — the headings are for reading the list, not for searching it. + */ + private async showMenu(): Promise { + const groupLabel = new Map(QUICK_MENU_GROUPS.map((group) => [group.id, t(group.labelKey)])); + const showLanguageServer = read("mcpp.languageService.menuItems"); + const items: Array = []; + let section: string | undefined; + for (const item of quickMenuItems) { + if (!showLanguageServer && item.group === "languageServer") { + continue; + } + if (item.group !== section) { + section = item.group; + items.push({ + label: groupLabel.get(item.group) ?? item.group, + kind: vscode.QuickPickItemKind.Separator, + }); + } + items.push({ + label: t(item.labelKey), + iconPath: { + light: vscode.Uri.joinPath(this.options.extensionUri, QUICK_MENU_ICON_DIRECTORY, quickMenuIconAsset(item, "light")), + dark: vscode.Uri.joinPath(this.options.extensionUri, QUICK_MENU_ICON_DIRECTORY, quickMenuIconAsset(item, "dark")), + }, + command: item.command, + }); + } + const picked = await vscode.window.showQuickPick(items, { + title: t("mcpp: quick menu"), + placeHolder: t("Choose a project, toolchain, cache or C++ Modules action"), + }); + if (picked?.command !== undefined) { + await vscode.commands.executeCommand(picked.command); + } + } + + public async newProject(): Promise { + if (!this.requireTrusted()) { + return; + } + + const input = await vscode.window.showInputBox({ + title: t("New mcpp project (1/2)"), + prompt: t("Enter a project name; a folder of that name is created at the location you pick"), + placeHolder: "hello-mcpp", + validateInput: validateNewProjectName, + }); + if (input === undefined) { + return; + } + const projectName = input.trim(); + + const picked = await vscode.window.showOpenDialog({ + title: t("Choose the project location (2/2)"), + // Start where the reader already is: `mcpp new` creates a folder *inside* + // the chosen one, so the workspace folder is the useful default — the + // project itself cannot be created in it, because `mcpp new` refuses a + // destination that already exists. + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: t("Create the project here"), + }); + const location = picked?.[0]; + if (location === undefined) { + return; + } + + const projectRoot = join(location.fsPath, projectName); + // Shown *and* compared: one definition keeps the two in step. + const confirmCreate = t("Create and open"); + await runNewProjectFlow(projectName, location.fsPath, projectRoot, { + exists: existsSync, + confirm: async (message) => + (await vscode.window.showWarningMessage(message, { modal: true }, confirmCreate)) + === confirmCreate, + run: async (name, cwd) => { + const executable = this.mcppExecutable(undefined); + const args = mcppCommandArguments("new", name); + const result = await runProcess(executable, args, cwd); + this.appendShortCommand(t("New project"), executable, args, result); + return result.exitCode; + }, + openFolder: async (path) => { + await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(path)); + }, + showError: async (message) => { + await vscode.window.showErrorMessage(message); + }, + }); + } + + private guarded(operation: () => Promise): () => Promise { + return async () => { + try { + await operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(t("mcpp CLI operation failed: {0}", message)); + await vscode.window.showErrorMessage(t("mcpp: {0}", message)); + } + }; + } + + private requireProject(): McppProjectDiscovery | undefined { + const project = this.options.currentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("No mcpp.toml was found in this workspace. Run this command inside an mcpp project.")); + } + return project; + } + + private requireTrusted(): boolean { + if (this.options.isTrusted()) { + return true; + } + void vscode.window.showWarningMessage( + t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first."), + ); + return false; + } + + public mcppExecutable(project: McppProjectDiscovery | undefined): string { + const uri = project === undefined + ? vscode.workspace.workspaceFolders?.[0]?.uri + : vscode.Uri.file(project.root); + const configured = vscode.workspace.getConfiguration("mcpp", uri).get("path", "").trim(); + return configured.length === 0 ? "mcpp" : configured; + } + + private isNestedWorkspaceProject(project: McppProjectDiscovery | undefined): boolean { + if (project === undefined) { + return false; + } + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(project.root)); + return folder !== undefined && folder.uri.fsPath !== project.root; + } + + public async readToolchainInventory( + project: McppProjectDiscovery | undefined = this.options.currentProject(), + ): Promise { + const executable = this.mcppExecutable(project); + const args = mcppCommandArguments("toolchain", "list"); + const result = await runProcess(executable, args, workingDirectory(project)); + this.appendShortCommand(t("Inspect the toolchain"), executable, args, result); + if (result.exitCode !== 0) { + this.reportCommandFailure( + args, + result.exitCode, + `${result.stdout}\n${result.stderr}`, + t("mcpp toolchain list failed (exit code {0}). See the mcpp output channel.", result.exitCode), + ); + return undefined; + } + + const inventory = parseToolchainList(`${result.stdout}${result.stderr.length > 0 ? `\n${result.stderr}` : ""}`); + if (!inventory.recognized) { + await vscode.window.showErrorMessage( + t("The current mcpp toolchain list output is not recognised; the raw output is kept in the mcpp output channel, so check the mcpp version."), + ); + return undefined; + } + return inventory; + } + + private async executeTask( + root: string, + executable: string, + title: string, + args: string[], + ): Promise { + // `mcpp.task.revealTerminal` / `focusTerminal` / `clearTerminal` and + // `mcpp.task.problemMatcher` are read here, where the task object is + // assembled; the completion path re-reads them for the failure-only reveal. + const ui = this.terminalUi(root); + const task = new vscode.Task( + { type: "mcpp", command: args[0] ?? "mcpp", projectRoot: root }, + taskScope(root), + title, + "mcpp", + new vscode.ProcessExecution(executable, args, { cwd: root }), + [...problemMatchersFor(read("mcpp.task.problemMatcher", vscode.Uri.file(root)))], + ); + task.presentationOptions = { + reveal: ui.reveal === "always" + ? vscode.TaskRevealKind.Always + : ui.reveal === "never" + ? vscode.TaskRevealKind.Never + : vscode.TaskRevealKind.Silent, + panel: vscode.TaskPanelKind.Dedicated, + focus: ui.focus, + clear: ui.clearBeforeRun, + showReuseMessage: false, + }; + + let execution: vscode.TaskExecution | undefined; + let earlyCompletion: TaskCompletion | undefined; + let settled = false; + let processEndSubscription: vscode.Disposable | undefined; + let taskEndSubscription: vscode.Disposable | undefined; + const disposeListeners = (): void => { + processEndSubscription?.dispose(); + taskEndSubscription?.dispose(); + }; + const finish = (completion: TaskCompletion): void => { + if (settled) { + return; + } + settled = true; + disposeListeners(); + resolveCompletion?.(completion); + }; + let resolveCompletion: ((completion: TaskCompletion) => void) | undefined; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + processEndSubscription = vscode.tasks.onDidEndTaskProcess((event) => { + if (event.execution.task !== task) { + return; + } + const classified = classifyTaskExit(event.exitCode); + if (execution === undefined) { + earlyCompletion ??= classified; + return; + } + finish(classified); + }); + taskEndSubscription = vscode.tasks.onDidEndTask((event) => { + if (event.execution.task !== task) { + return; + } + const classified = classifyTaskExit(undefined); + if (execution === undefined) { + earlyCompletion ??= classified; + return; + } + finish(classified); + }); + }); + + try { + execution = await vscode.tasks.executeTask(task); + } catch (error) { + disposeListeners(); + throw error; + } + if (earlyCompletion !== undefined) { + finish(earlyCompletion); + } + return completion; + } + + private appendTaskCompletion( + root: string, + title: string, + args: string[], + completion: TaskCompletion, + ): void { + const suffix = completion.state === "succeeded" + ? t("Exit code {0}", completion.exitCode ?? 0) + : completion.state === "cancelled" + ? t("Cancelled") + : t("Failed with exit code {0}", completion.exitCode ?? t("unknown")); + // `mcpp.log.level` gates the verbose lines; the failed result is an error + // and is therefore never suppressed. + this.logger.info(`\n[${new Date().toISOString()}] ${title}`); + this.logger.debug(t("Working directory: {0}", root)); + this.logger.debug(t("Task arguments: {0}", args.join(" "))); + const resultLine = t("Result: {0}", suffix); + if (completion.state === "failed") { + this.logger.error(resultLine); + } else if (completion.state === "cancelled") { + this.logger.warn(resultLine); + } else { + this.logger.info(resultLine); + } + + for (const command of terminalCommandsAfterTask(this.terminalUi(root), completion.state === "failed")) { + void vscode.commands.executeCommand(command); + } + + if (completion.state === "failed") { + this.reportCommandFailure( + args, + completion.exitCode ?? 1, + "", + t("{0} failed (exit code {1}). See the task terminal.", title, completion.exitCode ?? t("unknown")), + ); + } else if (completion.state === "cancelled") { + void vscode.window.showWarningMessage(t("{0} was cancelled.", title)); + } else { + this.reportSuccess(t("{0} finished.", title)); + } + } + + /** The task-terminal settings, read together so every site agrees. */ + private terminalUi(root: string): TaskTerminalUi { + const resource = vscode.Uri.file(root); + return taskTerminalUi( + read("mcpp.task.revealTerminal", resource), + read("mcpp.task.focusTerminal", resource), + read("mcpp.task.clearTerminal", resource), + ); + } + + /** + * A *successful* result only; failures and cancellations never consult + * `mcpp.ui.notifications.success`. `mcpp.ui.notifications.dedupeMinutes` + * suppresses a repeated identical message inside its window. + */ + private reportSuccess(message: string): void { + const mode = successReportOf(read("mcpp.ui.notifications.success")); + if (mode === "silent") { + return; + } + const now = Date.now(); + if (!dedupeAllows(this.lastNotified.get(message), now, read("mcpp.ui.notifications.dedupeMinutes"))) { + return; + } + this.lastNotified.set(message, now); + if (mode === "toast") { + void vscode.window.showInformationMessage(message); + return; + } + this.showStatusSuccess(message); + } + + /** `mcpp.ui.notifications.success = statusBar`: a transient, non-blocking label. */ + private showStatusSuccess(message: string): void { + if (this.options.currentProject() === undefined || !read("mcpp.ui.statusBar.show")) { + return; + } + this.status.text = `$(check) ${statusBarSuccessText(message)}`; + this.status.show(); + if (this.statusRevert !== undefined) { + clearTimeout(this.statusRevert); + } + this.statusRevert = setTimeout(() => { + this.statusRevert = undefined; + try { + this.applyStatusBar(); + } catch { + // The status item can already be disposed during a window reload. + } + }, SUCCESS_STATUS_MS); + } + + /** + * A failed `mcpp` run: the exit-code-specific next step from + * `src/cli/errors.ts`, the caller's own message as the fallback, and the + * `mcpp self explain` hint whenever the output names a diagnostic code. + */ + private reportCommandFailure( + args: readonly string[], + exitCode: number, + output: string, + fallback: string, + ): void { + const advice = failureAdvice(args, exitCode, output); + const parts: string[] = [failureGuidanceText(advice.kind) ?? fallback]; + if (advice.hint !== undefined) { + parts.push(t("Explain this code with: {0}", advice.hint)); + } + void vscode.window.showErrorMessage(parts.join(" ")); + } + + private warningOptions(strength: ConfirmationStrength, detail: string): vscode.MessageOptions { + return strength === "modal" ? { modal: true, detail } : { detail }; + } + + /** + * `mcpp.ui.statusBar.show` plus `mcpp.ui.statusBar.showLanguageServer`; the + * C++ Modules line comes from the host callback, so this controller never + * imports the mcppls bridge. + */ + private applyStatusBar(): void { + if (this.statusRevert !== undefined) { + clearTimeout(this.statusRevert); + this.statusRevert = undefined; + } + const tooltip = t("Open the mcpp project and toolchain quick menu"); + const languageServer = languageServerStatusSuffix( + read("mcpp.ui.statusBar.showLanguageServer"), + this.options.languageServerSummary?.(), + ); + this.status.text = languageServer === undefined + ? quickMenuStatusText + : `${quickMenuStatusText} · ${t("C++ Modules: {0}", languageServer)}`; + this.status.tooltip = languageServer === undefined + ? tooltip + : `${tooltip}\n${t("C++ Modules (provided by the mcpp language server extension): {0}", languageServer)}`; + // `mcpp.ui.statusBar.background`: VS Code allows a status bar entry exactly + // two backgrounds and picks the matching foreground itself; see + // `src/cli/statusBar.ts` for the whitelist this reads. + const background = statusBarBackgroundColour(read("mcpp.ui.statusBar.background")); + this.status.backgroundColor = background === undefined ? undefined : new vscode.ThemeColor(background); + if (read("mcpp.ui.statusBar.show")) { + this.status.show(); + } else { + this.status.hide(); + } + } + + private appendShortCommand( + title: string, + executable: string, + args: string[], + result: { exitCode: number; stdout: string; stderr: string }, + ): void { + // The command echo and the output of a successful command are the verbose + // lines `mcpp.log.level` gates. A failed command's raw stdout/stderr is + // written at `error`, which no configured level may suppress. + const failed = result.exitCode !== 0; + this.logger.info(`\n[${new Date().toISOString()}] ${title}`); + this.logger.debug(`$ ${commandLine(executable, args)}`); + if (result.stdout.length > 0) { + (failed ? this.logger.error : this.logger.info)(result.stdout.trimEnd()); + } + if (result.stderr.length > 0) { + (failed ? this.logger.error : this.logger.info)(result.stderr.trimEnd()); + } + (failed ? this.logger.error : this.logger.info)(`[exit ${result.exitCode}]`); + } +} diff --git a/src/cli/errors.ts b/src/cli/errors.ts new file mode 100644 index 0000000..a24fc35 --- /dev/null +++ b/src/cli/errors.ts @@ -0,0 +1,156 @@ +/** + * mcpp's exit-code contract (SPEC-003) turned into something a UI can branch on. + * + * | code | meaning | + * |------|--------------------------------------------| + * | 0 | success | + * | 1 | ran and failed | + * | 2 | usage error | + * | 4 | environment not ready | + * | 70 | internal error | + * | 101 | build failure — **only for `mcpp run`** | + * | 127 | unknown command | + * + * Two documented quirks are respected by the callers of this module: + * `mcpp build --configure-only` returns **2** for a *planning* failure, and + * `mcpp emit build-database` returns **1** together with a usable envelope — + * see {@link hasUsableOutput}. Nothing here parses narration for meaning; the + * only text it understands is the `MCPP_*` diagnostic code that + * `mcpp self explain` accepts. + * + * Pure string/number functions: no child process, no `vscode`. + */ + +export type McppFailureKind = + | "none" + | "failed" + | "usage" + | "environment" + | "internal" + | "build-failed" + | "unknown-command" + | "cancelled"; + +export interface McppOutcome { + kind: McppFailureKind; + /** Raw exit code, or -1 when the process could not be started at all. */ + exitCode: number; + /** The single stderr line that best explains it, when there is one. */ + detail?: string; + /** A `MCPP_*` diagnostic code found in the output, when there is one. */ + diagnosticCode?: string; +} + +const USAGE = 2; +const ENVIRONMENT = 4; +const INTERNAL = 70; +const BUILD_FAILED = 101; +const UNKNOWN_COMMAND = 127; + +/** `MCPP_OFFLINE_DOWNLOAD_REQUIRED`, never a lowercase word like `mcpp_offline`. */ +const DIAGNOSTIC_CODE = /\bMCPP_[A-Z][A-Z0-9_]*/; + +/** + * Map an exit code (plus the argv that produced it, which is the only way to + * tell `mcpp run`'s build failure apart) to a failure kind. + * + * `undefined` means "the process never produced an exit code" — a spawn failure + * or a cancellation — and yields `{ kind: "cancelled", exitCode: -1 }`. Signal + * terminations reported as a negative code are treated the same way. + * + * Codes that are not in the contract (for example a program's own exit code + * relayed by `mcpp run`) fall back to `"failed"`. + */ +export function classifyExit(exitCode: number | undefined, command?: readonly string[]): McppOutcome { + if (typeof exitCode !== "number" || !Number.isFinite(exitCode) || exitCode < 0) { + return { kind: "cancelled", exitCode: -1 }; + } + if (exitCode === 0) { + return { kind: "none", exitCode }; + } + if (exitCode === UNKNOWN_COMMAND) { + return { kind: "unknown-command", exitCode }; + } + if (exitCode === BUILD_FAILED) { + // 101 is `mcpp run`'s "your program failed to build"; for anything else it + // is an ordinary runtime failure. + return { kind: command?.[0] === "run" ? "build-failed" : "failed", exitCode }; + } + if (exitCode === USAGE) { + return { kind: "usage", exitCode }; + } + if (exitCode === ENVIRONMENT) { + return { kind: "environment", exitCode }; + } + if (exitCode === INTERNAL) { + return { kind: "internal", exitCode }; + } + return { kind: "failed", exitCode }; +} + +/** The first `MCPP_*` diagnostic code in `output`, when there is one. */ +export function diagnosticCodeIn(output: string): string | undefined { + if (typeof output !== "string") { + return undefined; + } + return DIAGNOSTIC_CODE.exec(output)?.[0]; +} + +/** Minimal JSON extraction for salvage checks: the trimmed text, then its outermost `{…}` / `[…]`. */ +function jsonDocumentIn(stdout: string): unknown { + if (typeof stdout !== "string") { + return undefined; + } + const text = stdout.trim(); + if (text.length === 0) { + return undefined; + } + const attempts = [text]; + const objectStart = text.indexOf("{"); + const objectEnd = text.lastIndexOf("}"); + if (objectStart >= 0 && objectEnd > objectStart) { + attempts.push(text.slice(objectStart, objectEnd + 1)); + } + const arrayStart = text.indexOf("["); + const arrayEnd = text.lastIndexOf("]"); + if (arrayStart >= 0 && arrayEnd > arrayStart) { + attempts.push(text.slice(arrayStart, arrayEnd + 1)); + } + for (const attempt of attempts) { + try { + const parsed: unknown = JSON.parse(attempt); + if (typeof parsed === "object" && parsed !== null) { + return parsed; + } + } catch { + // Not this candidate; try the next one. + } + } + return undefined; +} + +/** + * True when stdout carried something usable **even though the exit code was + * non-zero** — the `mcpp emit build-database` partial-failure case, where the + * envelope and its `diagnostics` are still worth reading. + * + * False when the command succeeded (there is nothing to salvage) and false when + * stdout has no parseable JSON document. A bare `--json` document counts just + * like an envelope. + */ +export function hasUsableOutput(outcome: McppOutcome, stdout: string): boolean { + if (outcome.exitCode === 0) { + return false; + } + return jsonDocumentIn(stdout) !== undefined; +} + +/** + * The follow-up command that explains a failure, e.g. + * `mcpp self explain MCPP_OFFLINE_DOWNLOAD_REQUIRED`. `undefined` when there is + * no diagnostic code to explain. + */ +export function explainHint(outcome: McppOutcome): string | undefined { + const code = outcome.diagnosticCode ?? diagnosticCodeIn(outcome.detail ?? ""); + return code === undefined ? undefined : `mcpp self explain ${code}`; +} diff --git a/src/cli/labels.ts b/src/cli/labels.ts new file mode 100644 index 0000000..96a4757 --- /dev/null +++ b/src/cli/labels.ts @@ -0,0 +1,43 @@ +/** + * The controller's own buttons. + * + * `showWarningMessage` / `showInformationMessage` reply with the label the user + * pressed, so a button that is both **displayed** and **compared** must be + * defined exactly once: two literals would silently desynchronise, and the + * comparison would never match a translated label. + * + * Kept out of `controller.ts` so it stays importable without an editor (`t()` + * loads the editor API lazily) and `test/cli/labels.test.ts` can hold the + * identity rule. + */ + +import { t } from "../i18n/t"; + +export interface ControllerLabels { + installCustom: string; + confirmInstall: string; + confirmDetect: string; + confirmDefault: string; + confirmClean: string; + chooseGlobalDefault: string; + cleanAndBuild: string; + showTasks: string; +} + +/** + * Resolved on every call, so `mcpp.ui.language` applies without a reload, and + * read once per dialogue into a local `labels` value that serves both the + * prompt and the `===` comparison. + */ +export function controllerLabels(): ControllerLabels { + return { + installCustom: t("$(edit) Enter another compatible toolchain spec…"), + confirmInstall: t("Install"), + confirmDetect: t("Detect"), + confirmDefault: t("Set as global default"), + confirmClean: t("Clean target"), + chooseGlobalDefault: t("Choose a global default"), + cleanAndBuild: t("Clean and build"), + showTasks: t("Show running tasks"), + }; +} diff --git a/src/cli/moduleGraph.ts b/src/cli/moduleGraph.ts new file mode 100644 index 0000000..9f43c03 --- /dev/null +++ b/src/cli/moduleGraph.ts @@ -0,0 +1,10 @@ +import { runProcess, type ProcessRunner } from "./process"; +import { parseModuleGraph, type ModuleGraphJson } from "../projects/moduleGraph"; + +/** Use mcppls's resolved graph; never launch a second C++ parser in this extension. */ +export async function queryModuleGraph(trusted: boolean, executable: string, root: string, runner: ProcessRunner = runProcess): Promise { + if (!trusted) { throw new Error("Trust this workspace before reading its module graph."); } + const result = await runner(executable, ["query", "module", "--graph", "--root", root, "--timeout", "20", "--format", "json"], root, { timeoutMs: 30_000, maxBufferMiB: 8 }); + if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || "mcppls module graph query failed"); } + return parseModuleGraph(result.stdout); +} diff --git a/src/newProject.ts b/src/cli/newProject.ts similarity index 64% rename from src/newProject.ts rename to src/cli/newProject.ts index 07cdead..90547e6 100644 --- a/src/newProject.ts +++ b/src/cli/newProject.ts @@ -1,3 +1,5 @@ +import { t } from "../i18n/t"; + export interface NewProjectActions { exists(path: string): boolean; confirm(message: string): Promise; @@ -10,8 +12,8 @@ export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened"; /** * 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程—— - * 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程), - * 避免与缺少 CDB 时的 configure 重复执行。 + * 打开后的完整构建交给用户手动触发;新窗口按自身信任状态与 CDB 状态决定是否运行 + * `mcpp build --configure-only`,避免创建流程跨窗口重复启动配置。 */ export async function runNewProjectFlow( projectName: string, @@ -20,11 +22,11 @@ export async function runNewProjectFlow( actions: NewProjectActions, ): Promise { if (actions.exists(projectRoot)) { - await actions.showError(`目标路径已存在:${projectRoot}。请更换项目名或位置。`); + await actions.showError(t("The destination already exists: {0}. Choose another project name or location.", projectRoot)); return "exists"; } const confirmed = await actions.confirm( - `将在 ${location} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`, + t("Run “mcpp new {0}” in {1}, create the project folder {2} and open it.", projectName, location, projectRoot), ); if (!confirmed) { return "declined"; @@ -32,7 +34,7 @@ export async function runNewProjectFlow( const exitCode = await actions.run(projectName, location); if (exitCode !== 0) { await actions.showError( - `mcpp new ${projectName} 失败(退出码 ${exitCode})。请查看 mcpp 输出频道。`, + t("mcpp new {0} failed (exit code {1}). See the mcpp output channel.", projectName, exitCode), ); return "failed"; } @@ -59,32 +61,32 @@ const MCPP_BUILTIN_TEMPLATE_MARKER = "PROJECT"; export function validateNewProjectName(input: string): string | undefined { const name = input.trim(); if (name.length === 0) { - return "项目名不能为空"; + return t("The project name must not be empty."); } if (/[\\/]/.test(name)) { - return "项目名不能包含路径分隔符"; + return t("The project name must not contain a path separator."); } if (name.startsWith("-")) { - return "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项"; + return t("The project name must not start with -, or mcpp would read it as a command-line option."); } if (name === "." || name === "..") { - return "项目名不能是 . 或 .."; + return t("The project name must not be . or .."); } // mcpp#380:当前内置模板会重复扫描替换结果,名称包含该标记时不会终止。 if (name.includes(MCPP_BUILTIN_TEMPLATE_MARKER)) { - return "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷"; + return t("The project name must not contain PROJECT: it triggers a defect in the current mcpp template substitution."); } if (CONTROL_CHARS.test(name)) { - return "项目名不能包含控制字符"; + return t("The project name must not contain control characters."); } if (WINDOWS_RESERVED_CHARS.test(name)) { - return '项目名不能包含 <>:"|?* 等保留字符'; + return t('The project name must not contain reserved characters such as <>:"|?*.'); } if (name.endsWith(".")) { - return "项目名不能以 . 结尾(Windows 不支持)"; + return t("The project name must not end with . (unsupported on Windows)."); } if (WINDOWS_DEVICE_NAMES.test(name)) { - return "项目名不能是 Windows 保留设备名"; + return t("The project name must not be a Windows reserved device name."); } return undefined; } diff --git a/src/cli/process.ts b/src/cli/process.ts new file mode 100644 index 0000000..a0c58a8 --- /dev/null +++ b/src/cli/process.ts @@ -0,0 +1,120 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ProcessResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface ProcessRunOptions { + timeoutMs?: number; + /** `mcpp.runtime.maxOutputMiB`; the default matches what the extension shipped before it was configurable. */ + maxBufferMiB?: number; +} + +export type ProcessRunner = ( + executable: string, + args: string[], + cwd?: string, + options?: ProcessRunOptions, +) => Promise; + +/** + * Windows cannot `execFile` a `.cmd`/`.bat` shim directly — Node ≥ 20.12 rejects + * it with EINVAL outright, and older hosts still mis-handle shebang scripts. A + * `mcpp.path` that points at such a wrapper (npm-style shim, the e2e fake mcpp) + * therefore has to go through the shell. Everywhere else the direct spawn is + * both safer and faster, so only these two suffixes opt in. + */ +export function spawnNeedsShell(executable: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32" && /\.(cmd|bat)$/i.test(executable); +} + +/** + * One argument, quoted for the shell path (external review P1-2, 2026-10-03). + * + * Node joins `file` and `args` with plain spaces when `shell: true`, without + * quoting anything, so a path with a space (`C:\Users\John Doe\…`, exactly what + * `xpkg parse` receives) splits in two and a search term like `foo & calc` + * becomes two commands. The rule is the one `CommandLineToArgvW` applies when + * the target re-parses the line: double backslashes that precede a quote or end + * the argument, escape the quotes themselves, and wrap in double quotes — + * inside which cmd treats `& | < > ^` as literal. `%VAR%` expansion inside + * quotes is a cmd limitation every shell-spawner shares and stays documented + * here rather than half-fixed. + */ +export function quoteWindowsArgument(argument: string): string { + let quoted = argument.replace(/(\\*)"/g, '$1$1\\"'); + quoted = quoted.replace(/(\\*)$/, '$1$1'); + return `"${quoted}"`; +} + +export async function runProcess( + executable: string, + args: string[], + cwd?: string, + options: ProcessRunOptions = {}, +): Promise { + try { + const needsShell = spawnNeedsShell(executable); + const result = await execFileAsync( + needsShell ? quoteWindowsArgument(executable) : executable, + needsShell ? args.map(quoteWindowsArgument) : args, + { + cwd, + encoding: "utf8", + maxBuffer: Math.max(1, options.maxBufferMiB ?? 16) * 1024 * 1024, + timeout: options.timeoutMs, + ...(needsShell ? { shell: true } : {}), + }, + ); + return { + exitCode: 0, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (error) { + const processError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: number | string; + }; + return { + exitCode: typeof processError.code === "number" ? processError.code : 1, + stdout: processError.stdout ?? "", + stderr: processError.stderr ?? (typeof processError.message === "string" ? processError.message : ""), + }; + } +} + +/** + * The one way the extension body runs mcpp: `runProcess` plus the + * workspace-trust gate (external review P0, 2026-10-03). + * + * `mcpp.path` is a `resource`-scoped setting, so an untrusted workspace can + * name any program there; every caller outside `src/cli/` therefore goes + * through this seam and receives `undefined` — a refusal, not an error — when + * the workspace is not trusted. The callers degrade (the detail page falls + * back to the descriptor's own text, the cross-registry search keeps its + * local results, the self-check reports the probe as unknown) instead of + * running the command. + * + * `src/cli/controller.ts` wraps its own `requireTrusted()` prompts around + * whole commands, which is why `src/cli/` may still call `runProcess` + * directly; the architecture test holds everyone else to this seam. + */ +export async function runMcpp( + trusted: boolean, + executable: string, + args: readonly string[], + cwd?: string, + options: ProcessRunOptions = {}, +): Promise { + if (!trusted) { + return undefined; + } + return runProcess(executable, [...args], cwd, options); +} diff --git a/src/cli/protocol.ts b/src/cli/protocol.ts new file mode 100644 index 0000000..a84841b --- /dev/null +++ b/src/cli/protocol.ts @@ -0,0 +1,259 @@ +/** + * mcpp's machine-output protocol (`docs/50-machine-output.md`). + * + * **Detection rule**: parse stdout and require `schemaVersion` **and** `kind`. + * The exit code is never the signal — an older mcpp answers + * `mcpp --protocol-version` with human text on stdout and exit 1, and a + * *failing* command can still emit a perfectly good envelope + * (`mcpp emit build-database` on partial failure). Everything here is a pure + * string function: no child process, no `vscode`. + * + * `mcpp --protocol-version` prints an envelope with a `mcpp.protocol` kind: + * + * ```jsonc + * { "schemaVersion": 1, "kind": "mcpp.protocol", + * "envelope": { "min": 1, "max": 1 }, + * "kinds": { "mcpp.env": 1, "mcpp.toolchain.list": 1 }, + * "commands": { "toolchain list": { "effects": ["read"] } }, + * "mcpp": { "version": "2026.9.30.2", "protocol": { "min": 1, "max": 1 } } } + * ``` + * + * `kinds` is the answer to "which `--format json` commands are safe to use" — + * see {@link supportsKind}. + */ + +export interface ProtocolEnvelope { + schemaVersion: number; + kind: string; + kindVersion?: number; + data?: T; + diagnostics?: ProtocolDiagnostic[]; + effects?: string[]; + mcpp?: { version?: string; protocol?: { min?: number; max?: number } }; +} + +export interface ProtocolDiagnostic { + code: string; + severity: "error" | "warning" | "note"; + source?: string; + message: string; + path?: string; + range?: unknown; +} + +export interface ProtocolInfo { + mcppVersion?: string; + envelopeMax?: number; + kinds: Record; + /** command name -> the effects it may have, from `mcpp --protocol-version`. */ + effects: Record; +} + +const PROTOCOL_KIND = "mcpp.protocol"; + +/** + * Upper bound on the number of substrings we are willing to try when a document + * is not pure JSON. Only reached for malformed input, and it keeps the scan + * linear-ish on a 16 MiB capture. + */ +const MAX_JSON_CANDIDATES = 64; + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Candidate object substrings for a text that is **not** pure JSON: every `{…}` + * span between the brace positions. Narration before or after the document (a + * `warning:` line, a blank line, an old-mcpp banner) therefore cannot hide it, + * and a stray `}` in trailing narration does not either. + */ +function jsonCandidates(text: string): string[] { + const candidates: string[] = []; + + const starts: number[] = []; + for (let index = text.indexOf("{"); index >= 0; index = text.indexOf("{", index + 1)) { + starts.push(index); + if (starts.length >= MAX_JSON_CANDIDATES) { + break; + } + } + const ends: number[] = []; + for (let index = text.lastIndexOf("}"); index >= 0; index = text.lastIndexOf("}", index - 1)) { + ends.push(index); + if (ends.length >= MAX_JSON_CANDIDATES) { + break; + } + } + + for (const start of starts) { + // `ends` is descending: the first entry is the outermost closing brace. + for (const end of ends) { + if (end <= start) { + break; + } + candidates.push(text.slice(start, end + 1)); + if (candidates.length >= MAX_JSON_CANDIDATES) { + return candidates; + } + } + } + return candidates; +} + +/** The first JSON object found in `stdout`; `undefined` for anything else. */ +function parseJsonObject(stdout: string): Record | undefined { + const text = stdout.trim(); + if (text.length === 0) { + return undefined; + } + try { + // The trimmed text is one complete JSON value, so it *is* the document. + // An array or a scalar is not an envelope, and we must not dig an object + // out of it. + return asRecord(JSON.parse(text) as unknown); + } catch { + // Narration around the document: fall through to the outermost object. + } + for (const candidate of jsonCandidates(text)) { + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + const record = asRecord(parsed); + if (record !== undefined) { + return record; + } + } + return undefined; +} + +/** Parse stdout as one enveloped document; `undefined` when it is not one. */ +export function parseEnvelope(stdout: string): ProtocolEnvelope | undefined { + if (typeof stdout !== "string") { + return undefined; + } + const record = parseJsonObject(stdout); + if (record === undefined) { + return undefined; + } + // Presence *and* type: a string `schemaVersion` is not a document we can trust. + if (typeof record.schemaVersion !== "number") { + return undefined; + } + if (typeof record.kind !== "string" || record.kind.length === 0) { + return undefined; + } + return record as unknown as ProtocolEnvelope; +} + +function kindVersionOf(value: unknown): number | undefined { + const direct = readNumber(value); + if (direct !== undefined) { + return direct; + } + const record = asRecord(value); + if (record === undefined) { + return undefined; + } + return readNumber(record.version) ?? readNumber(record.max); +} + +function readKindVersions(source: Record | undefined): Record { + const versions: Record = {}; + if (source === undefined) { + return versions; + } + for (const [kind, value] of Object.entries(source)) { + const version = kindVersionOf(value); + if (version !== undefined) { + versions[kind] = version; + } + } + return versions; +} + +function readCommandEffects(source: Record | undefined): Record { + const effects: Record = {}; + if (source === undefined) { + return effects; + } + for (const [command, value] of Object.entries(source)) { + const entry = asRecord(value); + if (entry === undefined || !Array.isArray(entry.effects)) { + continue; + } + effects[command] = entry.effects.filter((item): item is string => typeof item === "string"); + } + return effects; +} + +/** + * Parse `mcpp --protocol-version`. Returns `undefined` for older mcpp (human + * text on stdout, exit 1) and for any document that is not the protocol + * envelope, so the caller can fall back to the legacy text path. + * + * The fields sit at the envelope's top level in the real output; `data` is + * accepted as well so a future nesting cannot silently disable the probe. + */ +export function parseProtocolInfo(stdout: string): ProtocolInfo | undefined { + const envelope = parseEnvelope(stdout) as + | (ProtocolEnvelope & Record) + | undefined; + if (envelope === undefined || envelope.kind !== PROTOCOL_KIND) { + return undefined; + } + + const data = asRecord(envelope.data); + const mcpp = asRecord(envelope.mcpp) ?? asRecord(data?.mcpp); + const envelopeRange = asRecord(envelope.envelope) ?? asRecord(data?.envelope); + const protocolRange = asRecord(mcpp?.protocol); + + const info: ProtocolInfo = { + kinds: readKindVersions(asRecord(envelope.kinds) ?? asRecord(data?.kinds)), + effects: readCommandEffects(asRecord(envelope.commands) ?? asRecord(data?.commands)), + }; + const mcppVersion = readString(mcpp?.version); + if (mcppVersion !== undefined) { + info.mcppVersion = mcppVersion; + } + const envelopeMax = readNumber(envelopeRange?.max) ?? readNumber(protocolRange?.max); + if (envelopeMax !== undefined) { + info.envelopeMax = envelopeMax; + } + return info; +} + +/** The kinds mcpp advertised, i.e. which `--format json` commands are safe to use. */ +export function supportsKind(info: ProtocolInfo | undefined, kind: string): boolean { + if (info === undefined || typeof kind !== "string") { + return false; + } + const kinds = info.kinds; + if (typeof kinds !== "object" || kinds === null) { + return false; + } + return Object.prototype.hasOwnProperty.call(kinds, kind); +} + +/** `parseEnvelope` and `kind` check in one step, returning `data` or `undefined`. */ +export function readData(stdout: string, kind: string): T | undefined { + const envelope = parseEnvelope(stdout); + if (envelope === undefined || envelope.kind !== kind) { + return undefined; + } + return envelope.data; +} diff --git a/src/cli/search.ts b/src/cli/search.ts new file mode 100644 index 0000000..8cf21ef --- /dev/null +++ b/src/cli/search.ts @@ -0,0 +1,175 @@ +// `mcpp search` 的适配层(依赖版本补全,方案默认关)。 +// +// **这是全项目唯一解析「人类输出」的地方。** `mcpp search` 没有机读格式 +// (`--all-versions` 也只改人类列表的详细程度,不是 JSON),所以这里只能按 +// 实测行形状解析,并且必须防御性地失败:看不懂的行一律跳过,绝不抛异常、 +// 绝不半猜。mcpp 一旦提供机读格式就替换本模块(方案 §3.3.2 已登记)。 +// +// 纯字符串函数:不启动进程、不读盘、不依赖 vscode。执行 `mcpp`、超时、联网 +// 判定与会话缓存都由调用方负责(`mcpp.toml.indexCompletion*`)。 +// +// 实测形状(mcpp 2026.9.30.2): +// 兼容索引行 ` compat:zlib A compression library (1.3.2)` +// --all-versions 多版本 ` mcpplibs:aarch64-virt-rt … (0.2.1, 0.2.0, 0.1.1, ...)` +// 没有版本的行(如纯 xim 工具)没有尾括号,直接跳过。 + +export interface PackageVersion { + version: string; + summary?: string; +} + +/** semver 近似:数字点分,可带 `-`/`+` 后缀(`2026.07.09` 这类日期版本也算)。 */ +const VERSION = /^\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?$/; +/** 行尾的括号组:实测版本都写在这里。 */ +const TRAILING_PARENS = /\(([^()]*)\)\s*$/; +/** 有些终端会给输出上色;颜色码不属于内容。 */ +const ANSI = /\u001b\[[0-9;]*m/g; + +function isVersionToken(text: string): boolean { + return VERSION.test(text); +} + +/** 去掉包标识后的描述文本;没有描述时返回 undefined。 */ +function summaryOf(head: string): string | undefined { + const match = /^\S+\s+(.*)$/.exec(head.trim()); + const summary = match?.[1]?.trim() ?? ""; + return summary === "" ? undefined : summary; +} + +/** 简单 semver 比较:数字段按数值比,缺段视为更小,后缀按字典序。 */ +function compareVersions(a: string, b: string): number { + const left = a.split(/[.+-]/); + const right = b.split(/[.+-]/); + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const l = left[index]; + const r = right[index]; + if (l === undefined) { + return -1; + } + if (r === undefined) { + return 1; + } + const ln = /^\d+$/.test(l) ? Number(l) : undefined; + const rn = /^\d+$/.test(r) ? Number(r) : undefined; + if (ln !== undefined && rn !== undefined) { + if (ln !== rn) { + return ln < rn ? -1 : 1; + } + continue; + } + const order = l.localeCompare(r); + if (order !== 0) { + return order < 0 ? -1 : 1; + } + } + return 0; +} + +/** + * 从 `mcpp search --all-versions` 的 stdout 提取候选版本, + * 去重后按新→旧排序。空输入、只有旁白、或形状不认识时返回空数组。 + */ +export function parseSearchOutput(stdout: string): PackageVersion[] { + if (typeof stdout !== "string" || stdout === "") { + return []; + } + const byVersion = new Map(); + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.replace(ANSI, "").trim(); + if (line === "") { + continue; + } + const match = TRAILING_PARENS.exec(line); + if (match === null) { + continue; + } + const versions = match[1] + .split(",") + .map((part) => part.trim()) + .filter(isVersionToken); + if (versions.length === 0) { + continue; + } + const summary = summaryOf(line.slice(0, match.index)); + for (const version of versions) { + if (byVersion.has(version)) { + continue; + } + const entry: PackageVersion = { version }; + if (summary !== undefined) { + entry.summary = summary; + } + byVersion.set(version, entry); + } + } + return [...byVersion.values()].sort((a, b) => compareVersions(b.version, a.version)); +} + +/** `mcpp` 的 argv(不含可执行文件名)。 */ +export function searchArguments(name: string): string[] { + return ["search", name, "--all-versions"]; +} + +/** + * 是否执行索引查询:显式开启、工作区受信任、且未离线。 + * `name` 留给将来的 deny-list;当前判定只是三个开关的合取。 + */ +export function shouldSearch( + name: string, + options: { enabled: boolean; offline: boolean; trusted: boolean }, +): boolean { + return options.enabled && options.trusted && !options.offline; +} + +/** `mcpp.toml.indexCompletionTimeoutSeconds` 的注册表默认值。 */ +const DEFAULT_INDEX_TIMEOUT_SECONDS = 20; + +/** 一次索引查询的完整描述:argv 与硬超时。 */ +export interface IndexSearchRequest { + name: string; + args: string[]; + timeoutMs: number; +} + +/** + * 把设置解析成"要跑什么",或者 `undefined` 表示不跑。 + * + * 纯函数:进程、会话缓存、vscode 判定都在调用方(`src/toml/providers.ts`)。 + * 超时从 `mcpp.toml.indexCompletionTimeoutSeconds` 来,至少 1 秒;非有限值 + * 回落到注册表默认的 20 秒。这样"绝不超过设置的超时"有一个可单测的落点。 + */ +export function indexCompletionRequest( + name: string, + options: { enabled: boolean; trusted: boolean; offline: boolean; timeoutSeconds: number }, +): IndexSearchRequest | undefined { + if (!shouldSearch(name, options)) { + return undefined; + } + const seconds = Number.isFinite(options.timeoutSeconds) + ? Math.max(1, Math.floor(options.timeoutSeconds)) + : DEFAULT_INDEX_TIMEOUT_SECONDS; + return { name, args: searchArguments(name), timeoutMs: seconds * 1000 }; +} + +/** + * 等 `work`,但最多等 `timeoutMs`;超时得到 `undefined`。 + * + * 调用方自己的进程超时(`execFile` 的 `timeout`)才是第一道闸;这是第二道: + * 即便进程卡住不退出,补全也必须在超时后返回,绝不把编辑器挂住。 + */ +export async function withDeadline(work: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + work, + new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), timeoutMs); + }), + ]); + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} diff --git a/src/cli/selfCheck.ts b/src/cli/selfCheck.ts new file mode 100644 index 0000000..4f5f517 --- /dev/null +++ b/src/cli/selfCheck.ts @@ -0,0 +1,98 @@ +/** + * `mcpp: Environment Self-check` — one copyable snapshot. + * + * The point is to answer the questions a support thread always asks, in one + * place: which versions, which protocol, what mcppls can do, what the state is, + * which settings were changed. Pure, so the layout is testable and the extension + * host only has to gather the input. + */ + +import { formatBytes, formatCount } from "../util/format"; + +export interface SelfCheckInput { + extensionVersion: string; + vscodeVersion: string; + platform: string; + languagePreference: string; + trusted: boolean; + workspaceRoots: readonly string[]; + projectRoot?: string; + mcppPath: string; + mcppProbe?: { + version?: string; + envelopeMax?: number; + kinds: readonly string[]; + effects?: Record; + }; + mcppls: { + installed: boolean; + version?: string; + enabled: boolean; + state: string; + capabilities: ReadonlyArray<{ key: string; state: string; command?: string }>; + }; + cache?: { totalBytes: number; entries: number; incomplete?: number }; + changedSettings: ReadonlyArray<{ key: string; value: unknown; source: string }>; + lastRefresh?: { at: string; state: string; command?: string }; +} + +function line(label: string, value: string): string { + return ` ${label.padEnd(14)}${value}`; +} + +/** + * The snapshot. It says "unknown" where something could not be read — an empty + * field reads as "nothing is wrong", which is the opposite of the truth. + */ +export function buildSelfCheckText(input: SelfCheckInput): string { + const lines: string[] = []; + lines.push(`mcpp-vscode ${input.extensionVersion} · VS Code ${input.vscodeVersion} · ${input.platform}`); + lines.push(line("Language", input.languagePreference)); + lines.push(line("Workspace", `${input.trusted ? "trusted" : "NOT trusted"} · ${input.workspaceRoots.length} root(s)`)); + for (const root of input.workspaceRoots) { + lines.push(line(" folder", root)); + } + lines.push(line("Project", input.projectRoot ?? "no mcpp.toml found")); + + lines.push(""); + lines.push("mcpp"); + lines.push(line("path", input.mcppPath)); + const probe = input.mcppProbe; + if (probe === undefined) { + lines.push(line("version", "unknown (mcpp --protocol-version did not answer)")); + } else { + lines.push(line("version", probe.version ?? "unknown")); + lines.push(line("envelope", probe.envelopeMax === undefined ? "unknown" : String(probe.envelopeMax))); + lines.push(line("kinds", probe.kinds.length === 0 ? "none advertised" : probe.kinds.join(", "))); + } + + lines.push(""); + lines.push("C++ Modules (mcppls)"); + lines.push(line("installed", input.mcppls.installed ? "yes" : "no")); + lines.push(line("version", input.mcppls.version ?? "unknown")); + lines.push(line("enabled", input.mcppls.enabled ? "yes" : "disabled in this workspace")); + lines.push(line("state", input.mcppls.state)); + for (const capability of input.mcppls.capabilities) { + lines.push(line(` ${capability.key}`, `${capability.state}${capability.command === undefined ? "" : ` → ${capability.command}`}`)); + } + lines.push(line("last refresh", input.lastRefresh === undefined ? "never" : `${input.lastRefresh.state} at ${input.lastRefresh.at}${input.lastRefresh.command === undefined ? "" : ` (${input.lastRefresh.command})`}`)); + + lines.push(""); + lines.push("Cache"); + if (input.cache === undefined) { + lines.push(line("shared", "not read")); + } else { + lines.push(line("shared", `${formatBytes(input.cache.totalBytes)} · ${formatCount(input.cache.entries)} entries${input.cache.incomplete === undefined ? "" : ` · ${input.cache.incomplete} incomplete`}`)); + } + + lines.push(""); + lines.push(`Settings changed from their default (${input.changedSettings.length})`); + if (input.changedSettings.length === 0) { + lines.push(line("", "none")); + } else { + for (const entry of input.changedSettings) { + lines.push(line(` ${entry.source}`, `${entry.key} = ${JSON.stringify(entry.value)}`)); + } + } + return lines.join("\n"); +} diff --git a/src/cli/statusBar.ts b/src/cli/statusBar.ts new file mode 100644 index 0000000..e7fc67c --- /dev/null +++ b/src/cli/statusBar.ts @@ -0,0 +1,45 @@ +/** + * The one thing the status bar item can be painted with. + * + * `StatusBarItem.backgroundColor` looks like it takes any `ThemeColor`, and the + * API reference says so — but the extension host enforces a whitelist, and it is + * the *host* that also swaps the foreground so the text stays readable: + * + * ```js + * // extensionHostProcess.js, ExtHostStatusBarEntry + * static ALLOWED_BACKGROUND_COLORS = new Map([ + * ["statusBarItem.errorBackground", new ThemeColor("statusBarItem.errorForeground")], + * ["statusBarItem.warningBackground", new ThemeColor("statusBarItem.warningForeground")], + * ]); + * set backgroundColor(t) { t && !ALLOWED_BACKGROUND_COLORS.has(t.id) && (t = void 0); … } + * ``` + * + * So a contributed `mcpp.statusBarBackground` would be silently dropped, and a + * `color` set alongside one of the two allowed backgrounds would be overridden. + * This module is the whole answer to "can the mcpp item have a background?" and + * it is pure, so the answer is testable without an editor host. + */ + +/** The value of `mcpp.ui.statusBar.background`. */ +export type StatusBarBackground = "none" | "warning" | "error"; + +/** Every value `mcpp.ui.statusBar.background` accepts, in menu order. */ +export const STATUS_BAR_BACKGROUNDS: readonly StatusBarBackground[] = ["warning", "error", "none"]; + +/** + * The `ThemeColor` id for a setting value, or `undefined` for "no background". + * + * An unknown value is treated as `none` rather than as an error: a settings file + * with a stale value should leave the status bar plain, not throw during + * activation. + */ +export function statusBarBackgroundColour(value: unknown): string | undefined { + switch (value) { + case "warning": + return "statusBarItem.warningBackground"; + case "error": + return "statusBarItem.errorBackground"; + default: + return undefined; + } +} diff --git a/src/tasks.ts b/src/cli/tasks.ts similarity index 62% rename from src/tasks.ts rename to src/cli/tasks.ts index ffa148c..a480b31 100644 --- a/src/tasks.ts +++ b/src/cli/tasks.ts @@ -1,3 +1,5 @@ +import { t } from "../i18n/t"; + export type ProjectTaskKind = "build" | "run" | "test" | "clean"; export type TaskState = "succeeded" | "failed" | "cancelled"; @@ -12,26 +14,44 @@ export interface TaskCompletion { exitCode?: number; } -const TASK_TITLES: Record = { - build: "mcpp: 构建", - run: "mcpp: 运行", - test: "mcpp: 测试", - clean: "mcpp: 清理", +/** + * The task's display name. Resolved per call, not at import time, so a change to + * `mcpp.ui.language` applies without reloading the window. + */ +function taskTitle(kind: ProjectTaskKind): string { + switch (kind) { + case "build": + return t("mcpp: Build"); + case "run": + return t("mcpp: Run"); + case "test": + return t("mcpp: Test"); + case "clean": + return t("mcpp: Clean"); + } +} + +/** `mcpp.task.Args`, so a user can pass `-j 4` or `--quiet` without a wrapper. */ +export const TASK_ARGUMENT_SETTINGS: Readonly> = { + build: "mcpp.task.buildArgs", + run: "mcpp.task.runArgs", + test: "mcpp.task.testArgs", + clean: "mcpp.task.cleanArgs", }; -export function projectTaskPlan(kind: ProjectTaskKind): ProjectTaskPlan { +export function projectTaskPlan(kind: ProjectTaskKind, extraArgs: readonly string[] = []): ProjectTaskPlan { return { kind, - title: TASK_TITLES[kind], - args: [kind], + title: taskTitle(kind), + args: [kind, ...extraArgs], }; } -export function shouldReconcileAfterTask( - _kind: ProjectTaskKind, +export function shouldRefreshLanguageServerAfterTask( + kind: ProjectTaskKind, completion?: TaskCompletion, ): boolean { - return completion?.state !== "cancelled"; + return kind === "build" && completion !== undefined && completion.state !== "cancelled"; } export function classifyTaskExit(exitCode: number | undefined): TaskCompletion { diff --git a/src/cli.ts b/src/cli/toolchain.ts similarity index 100% rename from src/cli.ts rename to src/cli/toolchain.ts diff --git a/src/cliController.ts b/src/cliController.ts deleted file mode 100644 index ef77e6a..0000000 --- a/src/cliController.ts +++ /dev/null @@ -1,840 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import process from "node:process"; - -import * as vscode from "vscode"; - -import { - hostDefaultToolchains, - mcppCommandArguments, - normalizeToolchainSpec, - parseToolchainList, - toolchainInstallKind, - toolchainSpecTargetHint, - type ToolchainInventory, - type ToolchainItem, -} from "./cli"; -import type { McppProjectDiscovery } from "./discovery"; -import { runProcess } from "./process"; -import { - McppOperationRegistry, - classifyTaskExit, - projectTaskPlan, - shouldReconcileAfterTask, - type ProjectTaskKind, - type TaskCompletion, -} from "./tasks"; -import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; -import { runNewProjectFlow, validateNewProjectName } from "./newProject"; -import { - mcppModuleSetupCommands, - type ModuleSetupCommand, - type ModuleSetupDecision, - type ModuleSetupStepResult, -} from "./moduleSetup"; - -export interface McppCliControllerOptions { - output: vscode.OutputChannel; - currentProject: () => McppProjectDiscovery | undefined; - afterProjectTask: ( - project: McppProjectDiscovery, - kind: ProjectTaskKind, - completion: TaskCompletion, - ) => Promise; - isTrusted: () => boolean; -} - -interface ToolchainPickItem extends vscode.QuickPickItem { - spec?: string; - toolchain?: ToolchainItem; - customInput?: boolean; -} - -type OperationToken = object; - -const INSTALL_CUSTOM_LABEL = "$(edit) 输入其他兼容工具链 spec…"; -const CONFIRM_INSTALL = "安装"; -const CONFIRM_DETECT = "检测"; -const CONFIRM_DEFAULT = "设为全局默认"; -const CONFIRM_CLEAN = "清理 target"; -const SHOW_TASKS = "显示正在运行的任务"; - -function taskScope(root: string): vscode.WorkspaceFolder | vscode.TaskScope { - return vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)) - ?? vscode.TaskScope.Workspace; -} - -function workingDirectory(project: McppProjectDiscovery | undefined): string { - if (project !== undefined) { - return project.root; - } - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); -} - -function commandLine(executable: string, args: string[]): string { - return [executable, ...args].join(" "); -} - -export class McppCliController { - private readonly status: vscode.StatusBarItem; - - private readonly operations = new McppOperationRegistry(); - - public constructor(private readonly options: McppCliControllerOptions) { - this.status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 40); - this.status.command = CLI_COMMANDS.showMenu; - this.status.text = quickMenuStatusText; - this.status.tooltip = "打开 mcpp 项目和工具链快捷菜单"; - } - - public register(): vscode.Disposable[] { - const disposables: vscode.Disposable[] = [ - this.status, - vscode.commands.registerCommand(CLI_COMMANDS.showMenu, this.guarded(() => this.showMenu())), - vscode.commands.registerCommand(CLI_COMMANDS.newProject, this.guarded(() => this.newProject())), - vscode.commands.registerCommand(CLI_COMMANDS.build, this.guarded(() => this.runProjectTask("build"))), - vscode.commands.registerCommand(CLI_COMMANDS.run, this.guarded(() => this.runProjectTask("run"))), - vscode.commands.registerCommand(CLI_COMMANDS.test, this.guarded(() => this.runProjectTask("test"))), - vscode.commands.registerCommand(CLI_COMMANDS.clean, this.guarded(() => this.runProjectTask("clean"))), - vscode.commands.registerCommand(CLI_COMMANDS.showToolchains, this.guarded(() => this.showToolchains())), - vscode.commands.registerCommand(CLI_COMMANDS.installToolchain, this.guarded(() => this.installToolchain())), - vscode.commands.registerCommand(CLI_COMMANDS.selectDefaultToolchain, this.guarded(() => this.selectDefaultToolchain())), - vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()), - vscode.workspace.onDidChangeWorkspaceFolders(() => this.refreshStatus()), - ]; - this.refreshStatus(); - return disposables; - } - - public refreshStatus(): void { - if (this.options.currentProject() === undefined) { - this.status.hide(); - return; - } - this.status.show(); - } - - public isBusy(): boolean { - return this.operations.hasActive(); - } - - public async runAutomaticModuleSetup( - plan: Extract, - ): Promise { - const project = this.requireProject(); - const commands = mcppModuleSetupCommands(plan); - const firstCommand = commands[0]; - if (project === undefined || !this.requireTrusted() || firstCommand === undefined) { - return { - stage: firstCommand?.stage ?? "build", - state: "failed", - detail: "当前工作区无法执行自动模块配置。", - }; - } - - const token: OperationToken = {}; - if (this.operations.beginGlobal(token) !== undefined) { - return { - stage: firstCommand.stage, - state: "failed", - detail: "已有 mcpp 操作正在运行。", - }; - } - - const executable = this.mcppExecutable(project); - try { - for (const command of commands) { - const result = await this.executeAutomaticModuleSetupCommand( - project, - executable, - command, - ); - if (result.state !== "succeeded") { - return result; - } - } - return { stage: "build", state: "succeeded" }; - } finally { - // 整个 install/default/build 事务共用一个 token,异常时也必须释放。 - this.operations.finishGlobal(token); - } - } - - public async runProjectTask(kind: ProjectTaskKind): Promise { - const project = this.requireProject(); - if (project === undefined || !this.requireTrusted()) { - return; - } - - if (kind === "clean") { - const choice = await vscode.window.showWarningMessage( - `将删除当前工程的 target 目录:${project.root}/target`, - { modal: true, detail: "此操作不会清理全局 BMI 缓存。" }, - CONFIRM_CLEAN, - ); - if (choice !== CONFIRM_CLEAN) { - return; - } - } - - const token: OperationToken = {}; - const active = this.operations.beginProject(project.root, token); - if (active !== undefined) { - const choice = await vscode.window.showWarningMessage( - `已有 mcpp 操作正在运行,暂不启动 ${kind}。`, - SHOW_TASKS, - ); - if (choice === SHOW_TASKS) { - await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); - } - return; - } - - let completion: TaskCompletion | undefined; - try { - const plan = projectTaskPlan(kind); - completion = await this.executeTask( - project.root, - this.mcppExecutable(project), - plan.title, - plan.args, - ); - this.appendTaskCompletion(project.root, plan.title, plan.args, completion); - } finally { - this.operations.finishProject(project.root, token); - } - - if (completion !== undefined && shouldReconcileAfterTask(kind, completion)) { - await this.options.afterProjectTask(project, kind, completion); - } - } - - public async showToolchains(): Promise { - if (!this.requireTrusted()) { - return; - } - const project = this.options.currentProject(); - const inventory = await this.readToolchainInventory(project); - if (inventory === undefined) { - return; - } - - const items = this.inventoryItems(inventory, false, project); - if (items.length === 0) { - await vscode.window.showInformationMessage( - "mcpp 没有列出可用工具链;请查看 mcpp 输出频道中的原始结果。", - ); - return; - } - await vscode.window.showQuickPick(items, { - title: "mcpp 工具链与 target", - placeHolder: "只读查看 mcpp 当前解析结果", - matchOnDescription: true, - matchOnDetail: true, - }); - } - - public async installToolchain(): Promise { - if (!this.requireTrusted()) { - return; - } - const project = this.options.currentProject(); - const inventory = await this.readToolchainInventory(project); - const spec = await this.pickInstallSpec(inventory); - if (spec === undefined) { - return; - } - const installKind = toolchainInstallKind(spec); - const targetHint = toolchainSpecTargetHint(spec); - const confirmLabel = installKind === "system-detect" ? CONFIRM_DETECT : CONFIRM_INSTALL; - const confirmation = installKind === "system-detect" - ? `将调用 mcpp 检测系统 MSVC(${spec})。` - : targetHint === "target" - ? `将把可能携带 target 语义的兼容 spec ${spec} 交给 mcpp 安装,最终由 mcpp 校验。` - : installKind === "managed-target" - ? `将把携带 target 语义的兼容 spec ${spec} 交给 mcpp 安装。` - : `将安装 mcpp 工具链 ${spec}(不指定 target,使用 host target)。`; - const detail = installKind === "system-detect" - ? "mcpp 不会下载或安装 MSVC;它会检测 Visual Studio,并在缺失时给出官方安装指引。" - : targetHint === "target" - ? "mcpp 会判断编译器前缀是否为有效 triple;有效时可能下载对应 target 的较大工具链包。" - : installKind === "managed-target" - ? "mcpp 会规范化兼容写法,并可能下载对应 target 的较大工具链包。" - : "安装可能下载较大的工具链包,并修改 mcpp 全局缓存。"; - - const choice = await vscode.window.showWarningMessage( - confirmation, - { modal: true, detail }, - confirmLabel, - ); - if (choice !== confirmLabel) { - return; - } - - const token: OperationToken = {}; - const active = this.operations.beginGlobal(token); - if (active !== undefined) { - const duplicateChoice = await vscode.window.showWarningMessage( - "已有 mcpp 操作正在运行。", - SHOW_TASKS, - ); - if (duplicateChoice === SHOW_TASKS) { - await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); - } - return; - } - - const taskTitle = installKind === "system-detect" - ? `mcpp: 检测系统 MSVC(${spec})` - : `mcpp: 安装工具链 ${spec}`; - try { - const installArgs = mcppCommandArguments("toolchain", "install", spec); - const completion = await this.executeTask( - workingDirectory(project), - this.mcppExecutable(project), - taskTitle, - installArgs, - ); - this.appendTaskCompletion( - workingDirectory(project), - taskTitle, - installArgs, - completion, - ); - if (completion.state !== "succeeded") { - return; - } - } finally { - this.operations.finishGlobal(token); - } - - if (installKind === "managed-target") { - await vscode.window.showInformationMessage( - `mcpp 已完成 ${spec}。首版插件不修改 target 默认;如需设为默认,请使用带 --target 的 mcpp CLI。`, - ); - return; - } - - const refreshed = await this.readToolchainInventory(project); - const defaultChoice = await vscode.window.showInformationMessage( - installKind === "system-detect" - ? "MSVC 检测完成。是否从最新列表中选择全局默认?" - : `工具链 ${spec} 安装完成。是否从最新列表中选择全局默认?`, - "选择全局默认", - ); - if (defaultChoice === "选择全局默认" && refreshed !== undefined) { - await this.selectDefaultToolchainFromInventory(project, refreshed); - } - } - - public async selectDefaultToolchain(): Promise { - if (!this.requireTrusted()) { - return; - } - const project = this.options.currentProject(); - const inventory = await this.readToolchainInventory(project); - if (inventory !== undefined) { - await this.selectDefaultToolchainFromInventory(project, inventory); - } - } - - private async selectDefaultToolchainFromInventory( - project: McppProjectDiscovery | undefined, - inventory: ToolchainInventory, - ): Promise { - const installed = hostDefaultToolchains(inventory); - if (installed.length === 0) { - await vscode.window.showWarningMessage( - "没有可用于 host target 的已安装工具链,不能在此处设置全局默认。target 专用工具链请使用 mcpp CLI;Windows MSVC 请先安装 Visual Studio。", - ); - return; - } - - const items: ToolchainPickItem[] = installed.map((toolchain) => ({ - label: `${toolchain.effective ? "$(check) " : ""}${toolchain.spec}`, - description: toolchain.source === "system" ? "系统工具链(mcpp 只检测)" : "mcpp 管理的工具链", - detail: toolchain.effective ? "当前工程有效工具链" : undefined, - spec: toolchain.spec, - toolchain, - })); - const picked = await vscode.window.showQuickPick(items, { - title: "选择 mcpp 全局默认工具链", - placeHolder: this.isNestedWorkspaceProject(project) - ? "当前工程根位于 VS Code 文件夹子目录;实际构建解析以 mcpp 为准" - : inventory.projectOverridesGlobal - ? "项目当前覆盖全局默认;这里只修改 mcpp 当前全局配置" - : "选择后只修改 mcpp 全局配置,不会自动构建工程", - matchOnDescription: true, - matchOnDetail: true, - }); - if (picked?.spec === undefined) { - return; - } - - const choice = await vscode.window.showWarningMessage( - `将把全局默认对设为 ${picked.spec} + host target。当前项目的 mcpp.toml/target 配置仍可能覆盖它。`, - { modal: true, detail: "mcpp 会同时清空全局 default_target;配置文件位置由当前 mcpp 安装及 MCPP_HOME 决定。" }, - CONFIRM_DEFAULT, - ); - if (choice !== CONFIRM_DEFAULT) { - return; - } - - const token: OperationToken = {}; - const active = this.operations.beginGlobal(token); - if (active !== undefined) { - await vscode.window.showWarningMessage("已有 mcpp 操作正在运行。", SHOW_TASKS); - return; - } - - try { - const args = mcppCommandArguments("toolchain", "default", picked.spec); - const result = await runProcess( - this.mcppExecutable(project), - args, - workingDirectory(project), - ); - this.appendShortCommand("设置全局默认工具链", this.mcppExecutable(project), args, result); - if (result.exitCode !== 0) { - await vscode.window.showErrorMessage( - `设置全局默认工具链失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, - ); - return; - } - - } finally { - this.operations.finishGlobal(token); - } - - const buildChoice = await vscode.window.showInformationMessage( - `mcpp 全局默认已更新为 ${picked.spec} + host target。建议清理旧工具链产物后重新构建。`, - ...(project === undefined ? [] : ["清理并构建"]), - ); - if (buildChoice === "清理并构建" && project !== undefined) { - const cleanArgs = mcppCommandArguments("clean"); - const cleanResult = await runProcess(this.mcppExecutable(project), cleanArgs, project.root); - this.appendShortCommand("清理旧产物", this.mcppExecutable(project), cleanArgs, cleanResult); - await this.runProjectTask("build"); - } - } - - private async pickInstallSpec(inventory: ToolchainInventory | undefined): Promise { - const items: ToolchainPickItem[] = []; - for (const toolchain of inventory?.available ?? []) { - items.push({ - label: toolchain.spec, - description: "mcpp 按 family 聚合的可用版本;本操作按 host target 安装", - spec: toolchain.spec, - }); - } - items.push({ - label: INSTALL_CUSTOM_LABEL, - detail: "支持 family、family@version、namespace、部分版本和 mcpp 兼容旧拼写", - customInput: true, - }); - - const picked = await vscode.window.showQuickPick(items, { - title: "安装 mcpp 工具链", - placeHolder: "不选择 target;target 专用安装请使用 mcpp CLI", - matchOnDescription: true, - matchOnDetail: true, - }); - if (picked === undefined) { - return undefined; - } - if (!picked.customInput) { - return picked.spec; - } - - const input = await vscode.window.showInputBox({ - title: "输入工具链 spec", - prompt: "例如 gcc、llvm@20.1.7、xim:gcc@16、msvc、mingw;兼容写法由 mcpp 规范化", - placeHolder: "gcc@16", - validateInput: (value) => { - const normalized = normalizeToolchainSpec(value); - if (normalized === undefined) { - return "请输入 family、family@version、family version、namespace 或 mcpp 兼容 spec"; - } - return undefined; - }, - }); - return input === undefined ? undefined : normalizeToolchainSpec(input); - } - - private inventoryItems( - inventory: ToolchainInventory, - includeActions: boolean, - project: McppProjectDiscovery | undefined, - ): ToolchainPickItem[] { - const items: ToolchainPickItem[] = []; - const nestedView = this.isNestedWorkspaceProject(project); - if (inventory.effective !== undefined) { - items.push({ - label: `$(check) ${nestedView ? "mcpp list 当前目录工具链" : "当前有效工具链"}:${inventory.effective.spec}`, - description: nestedView - ? "当前目录视图;实际生效值以 mcpp build 解析为准" - : inventory.projectOverridesGlobal ? "来自当前项目 mcpp.toml,覆盖全局默认" : "来自全局默认", - detail: inventory.effectiveTarget === undefined - ? "有效 target:host(mcpp 未显示显式 target)" - : `有效 target:${inventory.effectiveTarget}`, - }); - } - if (inventory.globalDefaultSpec !== undefined) { - items.push({ - label: `全局默认:${inventory.globalDefaultSpec}`, - description: nestedView - ? "当前目录视图;项目或父级配置的覆盖以实际构建为准" - : inventory.projectOverridesGlobal ? "当前项目可能没有使用此值" : "mcpp 全局配置", - }); - } else if (inventory.recognized) { - items.push({ - label: "全局默认:", - description: "mcpp 尚未设置全局默认工具链", - }); - } - for (const toolchain of inventory.installed) { - items.push({ - label: `${toolchain.effective ? "$(check) " : ""}${toolchain.spec}`, - description: toolchain.source === "system" ? "System:系统工具链,仅检测" : "已安装", - detail: toolchain.effective - ? nestedView ? "mcpp list 当前目录有效项;实际构建解析可能受父级 mcpp 工作区影响" : "当前有效项" - : undefined, - spec: toolchain.spec, - toolchain, - }); - } - for (const target of inventory.targets) { - items.push({ - label: `${target.effective ? "$(check) " : ""}target ${target.target}`, - description: `${target.status}${target.toolchainSpec === undefined ? "" : ` · 约定 ${target.toolchainSpec}`}`, - detail: target.note.length === 0 ? "target 轴只读展示;选择 target 请使用 mcpp CLI" : target.note, - }); - } - for (const toolchain of inventory.available) { - items.push({ - label: `可安装:${toolchain.spec}`, - description: "mcpp 按 family 聚合的索引版本;未承诺 host payload", - spec: toolchain.spec, - }); - } - if (includeActions) { - items.push({ - label: "$(cloud-download) 安装工具链…", - detail: "回到工具链安装流程", - customInput: true, - }); - } - return items; - } - - private async showMenu(): Promise { - const items = quickMenuItems.map((item) => ({ - label: item.label, - description: item.group === "project" - ? "当前 mcpp 工程" - : item.group === "toolchain" ? "mcpp 工具链管理" : "clangd 与编译数据库", - command: item.command, - })); - const picked = await vscode.window.showQuickPick(items, { - title: "mcpp 快捷菜单", - placeHolder: "选择项目、工具链或 IDE 操作", - matchOnDescription: true, - }); - if (picked !== undefined) { - await vscode.commands.executeCommand(picked.command); - } - } - - public async newProject(): Promise { - if (!this.requireTrusted()) { - return; - } - - const input = await vscode.window.showInputBox({ - title: "新建 mcpp 工程(1/2)", - prompt: "输入项目名,将在所选位置创建同名项目文件夹", - placeHolder: "hello-mcpp", - validateInput: validateNewProjectName, - }); - if (input === undefined) { - return; - } - const projectName = input.trim(); - - const picked = await vscode.window.showOpenDialog({ - title: "选择项目位置(2/2)", - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: "在此创建项目", - }); - const location = picked?.[0]; - if (location === undefined) { - return; - } - - const projectRoot = join(location.fsPath, projectName); - const confirmCreate = "创建并打开"; - await runNewProjectFlow(projectName, location.fsPath, projectRoot, { - exists: existsSync, - confirm: async (message) => - (await vscode.window.showWarningMessage(message, { modal: true }, confirmCreate)) - === confirmCreate, - run: async (name, cwd) => { - const executable = this.mcppExecutable(undefined); - const args = mcppCommandArguments("new", name); - const result = await runProcess(executable, args, cwd); - this.appendShortCommand("新建工程", executable, args, result); - return result.exitCode; - }, - openFolder: async (path) => { - await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(path)); - }, - showError: async (message) => { - await vscode.window.showErrorMessage(message); - }, - }); - } - - private guarded(operation: () => Promise): () => Promise { - return async () => { - try { - await operation(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - try { - this.options.output.appendLine(`mcpp CLI 操作失败:${message}`); - } catch { - // 输出频道可能已经在窗口重载时释放。 - } - await vscode.window.showErrorMessage(`mcpp:${message}`); - } - }; - } - - private requireProject(): McppProjectDiscovery | undefined { - const project = this.options.currentProject(); - if (project === undefined) { - void vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。请在 mcpp 工程中执行此命令。"); - } - return project; - } - - private requireTrusted(): boolean { - if (this.options.isTrusted()) { - return true; - } - void vscode.window.showWarningMessage( - "当前工作区未受信任。mcpp 命令可能执行工作区设置指定的外部程序,请先信任工作区。", - ); - return false; - } - - private mcppExecutable(project: McppProjectDiscovery | undefined): string { - const uri = project === undefined - ? vscode.workspace.workspaceFolders?.[0]?.uri - : vscode.Uri.file(project.root); - const configured = vscode.workspace.getConfiguration("mcpp", uri).get("path", "").trim(); - return configured.length === 0 ? "mcpp" : configured; - } - - private isNestedWorkspaceProject(project: McppProjectDiscovery | undefined): boolean { - if (project === undefined) { - return false; - } - const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(project.root)); - return folder !== undefined && folder.uri.fsPath !== project.root; - } - - public async readToolchainInventory( - project: McppProjectDiscovery | undefined = this.options.currentProject(), - ): Promise { - const executable = this.mcppExecutable(project); - const args = mcppCommandArguments("toolchain", "list"); - const result = await runProcess(executable, args, workingDirectory(project)); - this.appendShortCommand("查看工具链", executable, args, result); - if (result.exitCode !== 0) { - await vscode.window.showErrorMessage( - `mcpp toolchain list 失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, - ); - return undefined; - } - - const inventory = parseToolchainList(`${result.stdout}${result.stderr.length > 0 ? `\n${result.stderr}` : ""}`); - if (!inventory.recognized) { - await vscode.window.showErrorMessage( - "无法识别当前 mcpp toolchain list 输出;原始输出已保留在 mcpp 输出频道,请检查 mcpp 版本。", - ); - return undefined; - } - return inventory; - } - - private async executeAutomaticModuleSetupCommand( - project: McppProjectDiscovery, - executable: string, - command: ModuleSetupCommand, - ): Promise { - if (command.mode === "process") { - const result = await runProcess(executable, command.args, project.root); - this.appendShortCommand(`自动模块配置:${command.stage}`, executable, command.args, result); - return { - stage: command.stage, - state: result.exitCode === 0 ? "succeeded" : "failed", - exitCode: result.exitCode, - }; - } - - // build 直接复用任务执行器,避免在 global token 内再次申请 project token。 - let completion: TaskCompletion; - try { - completion = await this.executeTask( - project.root, - executable, - "mcpp: 自动模块配置构建", - command.args, - ); - } catch (error) { - return { - stage: command.stage, - state: "failed", - detail: error instanceof Error ? error.message : String(error), - }; - } - this.appendTaskCompletion( - project.root, - "mcpp: 自动模块配置构建", - command.args, - completion, - ); - return { - stage: command.stage, - state: completion.state, - exitCode: completion.exitCode, - }; - } - - private async executeTask( - root: string, - executable: string, - title: string, - args: string[], - ): Promise { - const task = new vscode.Task( - { type: "mcpp", command: args[0] ?? "mcpp", projectRoot: root }, - taskScope(root), - title, - "mcpp", - new vscode.ProcessExecution(executable, args, { cwd: root }), - ); - task.presentationOptions = { - reveal: vscode.TaskRevealKind.Always, - panel: vscode.TaskPanelKind.Dedicated, - focus: true, - clear: true, - showReuseMessage: false, - }; - - let execution: vscode.TaskExecution | undefined; - let earlyCompletion: TaskCompletion | undefined; - let settled = false; - let processEndSubscription: vscode.Disposable | undefined; - let taskEndSubscription: vscode.Disposable | undefined; - const disposeListeners = (): void => { - processEndSubscription?.dispose(); - taskEndSubscription?.dispose(); - }; - const finish = (completion: TaskCompletion): void => { - if (settled) { - return; - } - settled = true; - disposeListeners(); - resolveCompletion?.(completion); - }; - let resolveCompletion: ((completion: TaskCompletion) => void) | undefined; - const completion = new Promise((resolve) => { - resolveCompletion = resolve; - processEndSubscription = vscode.tasks.onDidEndTaskProcess((event) => { - if (event.execution.task !== task) { - return; - } - const classified = classifyTaskExit(event.exitCode); - if (execution === undefined) { - earlyCompletion ??= classified; - return; - } - finish(classified); - }); - taskEndSubscription = vscode.tasks.onDidEndTask((event) => { - if (event.execution.task !== task) { - return; - } - const classified = classifyTaskExit(undefined); - if (execution === undefined) { - earlyCompletion ??= classified; - return; - } - finish(classified); - }); - }); - - try { - execution = await vscode.tasks.executeTask(task); - } catch (error) { - disposeListeners(); - throw error; - } - if (earlyCompletion !== undefined) { - finish(earlyCompletion); - } - return completion; - } - - private appendTaskCompletion( - root: string, - title: string, - args: string[], - completion: TaskCompletion, - ): void { - const suffix = completion.state === "succeeded" - ? `退出码 ${completion.exitCode ?? 0}` - : completion.state === "cancelled" - ? "已取消" - : `失败,退出码 ${completion.exitCode ?? "未知"}`; - try { - this.options.output.appendLine(`\n[${new Date().toISOString()}] ${title}`); - this.options.output.appendLine(`工作目录:${root}`); - this.options.output.appendLine(`任务参数:${args.join(" ")}`); - this.options.output.appendLine(`结果:${suffix}`); - } catch { - // 窗口重载时输出频道可能早于任务事件被释放。 - } - if (completion.state === "failed") { - void vscode.window.showErrorMessage(`${title}失败(退出码 ${completion.exitCode ?? "未知"})。请查看任务终端。`); - } else if (completion.state === "cancelled") { - void vscode.window.showWarningMessage(`${title}已取消。`); - } - } - - private appendShortCommand( - title: string, - executable: string, - args: string[], - result: { exitCode: number; stdout: string; stderr: string }, - ): void { - try { - this.options.output.appendLine(`\n[${new Date().toISOString()}] ${title}`); - this.options.output.appendLine(`$ ${commandLine(executable, args)}`); - if (result.stdout.length > 0) { - this.options.output.appendLine(result.stdout.trimEnd()); - } - if (result.stderr.length > 0) { - this.options.output.appendLine(result.stderr.trimEnd()); - } - this.options.output.appendLine(`[exit ${result.exitCode}]`); - } catch { - // 窗口重载时输出频道可能早于短命令结束被释放。 - } - } -} diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index a28796c..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,34 +0,0 @@ -export const CLI_COMMANDS = { - showMenu: "mcpp.showMenu", - newProject: "mcpp.newProject", - build: "mcpp.build", - run: "mcpp.run", - test: "mcpp.test", - clean: "mcpp.clean", - showToolchains: "mcpp.showToolchains", - installToolchain: "mcpp.installToolchain", - selectDefaultToolchain: "mcpp.selectDefaultToolchain", - autoConfigureModules: "mcpp.autoConfigureModules", -} as const; - -export const quickMenuStatusText = "$(tools) mcpp: 快捷菜单"; - -export interface QuickMenuItem { - label: string; - command: string; - group: "project" | "toolchain" | "ide"; -} - -export const quickMenuItems: readonly QuickMenuItem[] = [ - { label: "$(gear) 构建", command: CLI_COMMANDS.build, group: "project" }, - { label: "$(play) 运行", command: CLI_COMMANDS.run, group: "project" }, - { label: "$(beaker) 测试", command: CLI_COMMANDS.test, group: "project" }, - { label: "$(trash) 清理 target", command: CLI_COMMANDS.clean, group: "project" }, - { label: "$(list-unordered) 查看工具链", command: CLI_COMMANDS.showToolchains, group: "toolchain" }, - { label: "$(cloud-download) 安装工具链", command: CLI_COMMANDS.installToolchain, group: "toolchain" }, - { label: "$(settings-gear) 选择全局默认工具链", command: CLI_COMMANDS.selectDefaultToolchain, group: "toolchain" }, - { label: "$(symbol-interface) 配置 clangd", command: "mcpp.configureClangd", group: "ide" }, - { label: "$(database) 刷新编译数据库", command: "mcpp.refreshCompilationDatabase", group: "ide" }, - { label: "$(check) 检查模块支持", command: "mcpp.checkModuleSupport", group: "ide" }, - { label: "$(rocket) 一键配置模块代码提示", command: CLI_COMMANDS.autoConfigureModules, group: "ide" }, -]; diff --git a/src/commands/ids.ts b/src/commands/ids.ts new file mode 100644 index 0000000..811bd74 --- /dev/null +++ b/src/commands/ids.ts @@ -0,0 +1,118 @@ +/** + * Every command id this extension contributes, in one place. + * + * Two rules live here: + * + * 1. **`mcpp.` only.** Our ids must never collide with a command the C++ Modules + * server advertises (`mcppls.*`): `vscode-languageclient` registers those + * itself, and a duplicate id stops the language client from starting + * (mcppls's S3-5.6-3). `test/mcppls/contract.test.ts` asserts this. + * 2. **Ids do not change once shipped.** A rename keeps the old id and points it + * at the new implementation, so a user's keybinding keeps working — see + * `DEPRECATED_COMMANDS` below. + */ + +/** mcpp CLI commands that existed in 0.4.x. */ +export const CLI_COMMANDS = { + showMenu: "mcpp.showMenu", + newProject: "mcpp.newProject", + build: "mcpp.build", + run: "mcpp.run", + test: "mcpp.test", + clean: "mcpp.clean", + showToolchains: "mcpp.showToolchains", + installToolchain: "mcpp.installToolchain", + selectDefaultToolchain: "mcpp.selectDefaultToolchain", + autoConfigureModules: "mcpp.autoConfigureModules", +} as const; + +/** The language-service entries that existed in 0.4.x (kept as aliases, see below). */ +export const LEGACY_LANGUAGE_SERVER_COMMANDS = { + configureLanguageServer: "mcpp.configureLanguageServer", + refreshCompilationDatabase: "mcpp.refreshCompilationDatabase", + checkModuleSupport: "mcpp.checkModuleSupport", + showModuleGraph: "mcpp.showModuleGraph", + showLanguageServerLogs: "mcpp.showLanguageServerLogs", +} as const; + +/** The C++ Modules view and everything it forwards. */ +export const LANGUAGE_SERVER_COMMANDS = { + refreshState: "mcpp.languageServer.refreshState", + restart: "mcpp.languageServer.restart", + restartEngine: "mcpp.languageServer.restartEngine", + resetWorkspaceCache: "mcpp.languageServer.resetWorkspaceCache", + selectContext: "mcpp.languageServer.selectContext", + showModuleGraph: "mcpp.languageServer.showModuleGraph", + showLogs: "mcpp.languageServer.showLogs", + collectReport: "mcpp.languageServer.collectReport", + exportDiagnosticBundle: "mcpp.languageServer.exportDiagnosticBundle", + revealBundle: "mcpp.languageServer.revealBundle", + openLogFolder: "mcpp.languageServer.openLogFolder", + runBuildToolInTerminal: "mcpp.languageServer.runBuildToolInTerminal", + /** Turns the other C++ language services off (the id is from 0.6.0). */ + manageConflicts: "mcpp.languageServer.manageConflicts", + restoreConflicts: "mcpp.languageServer.restoreConflicts", + toggleInWorkspace: "mcpp.languageServer.toggleInWorkspace", + installTools: "mcpp.languageServer.installTools", + reviewChanges: "mcpp.languageServer.reviewChanges", +} as const; + +/** Cache statistics and the cleanup table. */ +export const CACHE_COMMANDS = { + refreshStats: "mcpp.refreshCacheStats", + showEntry: "mcpp.showCacheEntry", + cleanStale: "mcpp.cleanStaleArtifacts", + cleanProject: "mcpp.cleanProjectArtifacts", + collect: "mcpp.gcGlobalCache", + prune: "mcpp.pruneGlobalCache", + verify: "mcpp.verifyGlobalCache", + cleanLegacy: "mcpp.cleanLegacyCache", +} as const; + +/** + * The library-ecosystem view. The list and the detail page talk to their + * webviews over messages, so these are the only ids a menu can name. + */ +export const LIBRARY_COMMANDS = { + search: "mcpp.library.search", + openDetail: "mcpp.library.openDetail", + updateIndex: "mcpp.library.updateIndex", +} as const; + +/** Settings, diagnostics and the mcpp CLI entry points added in 0.5.0. */ +export const TOOL_COMMANDS = { + dependencyGraph: "mcpp.project.showDependencyGraph", + moduleDependencyGraph: "mcpp.project.showModuleDependencyGraph", + openSettings: "mcpp.openSettings", + openLanguageServerSettings: "mcpp.openMcpplsSettings", + selfCheck: "mcpp.selfCheck", +} as const; + +/** + * Ids kept for compatibility. A keybinding or a script that names one of these + * still works; the menu shows the new title. + */ +export const DEPRECATED_COMMANDS = { + configureClangd: "mcpp.configureClangd", +} as const; + +export type CommandId = + | (typeof CLI_COMMANDS)[keyof typeof CLI_COMMANDS] + | (typeof LEGACY_LANGUAGE_SERVER_COMMANDS)[keyof typeof LEGACY_LANGUAGE_SERVER_COMMANDS] + | (typeof LANGUAGE_SERVER_COMMANDS)[keyof typeof LANGUAGE_SERVER_COMMANDS] + | (typeof CACHE_COMMANDS)[keyof typeof CACHE_COMMANDS] + | (typeof LIBRARY_COMMANDS)[keyof typeof LIBRARY_COMMANDS] + | (typeof TOOL_COMMANDS)[keyof typeof TOOL_COMMANDS]; + +/** Every id the manifest contributes, in the order the palette shows them. */ +export function contributedCommandIds(): string[] { + return [ + ...Object.values(TOOL_COMMANDS), + ...Object.values(CLI_COMMANDS), + ...Object.values(CACHE_COMMANDS), + ...Object.values(LIBRARY_COMMANDS), + ...Object.values(LANGUAGE_SERVER_COMMANDS), + ...Object.values(LEGACY_LANGUAGE_SERVER_COMMANDS), + ...Object.values(DEPRECATED_COMMANDS), + ]; +} diff --git a/src/commands/menu.ts b/src/commands/menu.ts new file mode 100644 index 0000000..342690e --- /dev/null +++ b/src/commands/menu.ts @@ -0,0 +1,103 @@ +import { CACHE_COMMANDS, CLI_COMMANDS, LANGUAGE_SERVER_COMMANDS, LIBRARY_COMMANDS, TOOL_COMMANDS } from "./ids"; + +/** + * The status-bar menu, as **data**: entries are keyed by command id, the titles + * are resolved through `src/i18n/t.ts` when the menu is built, and the icon is a + * codicon id without the `$()` (the same convention `TreeNode.icon` uses). + * + * The groups are not decoration. They are what the menu is built from — the + * quick pick renders one separator per group — so an entry and its section can + * never disagree, and a section that ends up empty simply produces no separator. + * + * ## Why the icons are files + * + * `vscode.QuickPickItem.iconPath` accepts a `ThemeIcon`, but VS Code 1.132 + * discards its colour: `MainThreadQuickOpen.expandIconPath` turns the icon into a + * bare codicon class (`o.iconClass = asClassName(e)`), and the list renderer only + * ever paints that class in the row's ordinary foreground. A `Uri` is different — + * it becomes the row's `background-image`, painted as-is — so the only way to + * colour a quick pick icon is to *ship a coloured image*. + * + * That is what `iconColor` is for. It is a **palette word** (`blue`, `green`, …), + * not a theme-colour id like `TreeNode.iconColor`, because the colour is baked + * into the file and cannot follow the theme the way a `ThemeIcon` does. Each word + * resolves to the default light and dark value of the matching `charts.*` token, + * so the menu and the project tree agree; `media/quick-menu/---- + * .svg` is generated from `@vscode/codicons` by + * `tools/generate-quick-menu-icons.mjs`, and `test/commands/menu.test.ts` walks + * this table and stats every file so the two cannot drift apart. + * + * The palette groups rows by **what they do**, the same way the tree does: blue + * builds or adds, green runs or verifies, purple tests or collects, yellow + * manages the toolchain, red deletes, and `neutral` is for rows whose only job is + * to configure or report. + */ +export interface QuickMenuItem { + labelKey: string; + command: string; + /** A codicon id, e.g. `"tools"`. */ + icon: string; + /** A palette word; see the note above. */ + iconColor: QuickMenuColour; + group: "project" | "library" | "toolchain" | "cache" | "languageServer" | "settings"; +} + +/** The palette words `iconColor` may use; the generator owns their values. */ +export const QUICK_MENU_COLOURS = ["blue", "green", "purple", "yellow", "red", "neutral"] as const; + +export type QuickMenuColour = (typeof QUICK_MENU_COLOURS)[number]; + +/** The groups, in the order the menu shows them. */ +export const QUICK_MENU_GROUPS: ReadonlyArray<{ id: QuickMenuItem["group"]; labelKey: string }> = [ + { id: "project", labelKey: "Current mcpp project" }, + { id: "library", labelKey: "mcpp library ecosystem" }, + { id: "toolchain", labelKey: "mcpp toolchain management" }, + { id: "cache", labelKey: "Build cache" }, + { id: "languageServer", labelKey: "C++ Modules language service" }, + { id: "settings", labelKey: "Settings and diagnostics" }, +]; + +/** + * The asset name for one row's icon in one theme. + * + * The generator writes exactly these names; the test checks that every one of + * them exists, which is what keeps this convention and the files in step. + */ +export function quickMenuIconAsset(item: QuickMenuItem, theme: "dark" | "light"): string { + return `${item.icon}--${item.iconColor}--${theme}.svg`; +} + +export const quickMenuItems: readonly QuickMenuItem[] = [ + { labelKey: "Build", command: CLI_COMMANDS.build, icon: "tools", iconColor: "blue", group: "project" }, + { labelKey: "Run", command: CLI_COMMANDS.run, icon: "play", iconColor: "green", group: "project" }, + { labelKey: "Test", command: CLI_COMMANDS.test, icon: "beaker", iconColor: "purple", group: "project" }, + { labelKey: "Clean project artifacts", command: CACHE_COMMANDS.cleanProject, icon: "trash", iconColor: "red", group: "project" }, + + { labelKey: "Search and add a dependency", command: LIBRARY_COMMANDS.search, icon: "library", iconColor: "blue", group: "library" }, + { labelKey: "Refresh the mcpp Package Index", command: LIBRARY_COMMANDS.updateIndex, icon: "cloud-download", iconColor: "blue", group: "library" }, + + { labelKey: "Show toolchains", command: CLI_COMMANDS.showToolchains, icon: "chip", iconColor: "yellow", group: "toolchain" }, + { labelKey: "Install toolchain", command: CLI_COMMANDS.installToolchain, icon: "cloud-download", iconColor: "yellow", group: "toolchain" }, + { labelKey: "Select the global default toolchain", command: CLI_COMMANDS.selectDefaultToolchain, icon: "star-full", iconColor: "yellow", group: "toolchain" }, + + { labelKey: "Refresh cache statistics", command: CACHE_COMMANDS.refreshStats, icon: "refresh", iconColor: "green", group: "cache" }, + { labelKey: "Clean stale artifacts", command: CACHE_COMMANDS.cleanStale, icon: "trash", iconColor: "red", group: "cache" }, + { labelKey: "Collect the global cache to a budget", command: CACHE_COMMANDS.collect, icon: "archive", iconColor: "purple", group: "cache" }, + { labelKey: "Verify the shared cache", command: CACHE_COMMANDS.verify, icon: "verified", iconColor: "green", group: "cache" }, + + { labelKey: "C++ Modules: restart the language server", command: LANGUAGE_SERVER_COMMANDS.restart, icon: "debug-restart", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: select the analysis context", command: LANGUAGE_SERVER_COMMANDS.selectContext, icon: "symbol-interface", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: show the module graph", command: LANGUAGE_SERVER_COMMANDS.showModuleGraph, icon: "type-hierarchy", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: open the log", command: LANGUAGE_SERVER_COMMANDS.showLogs, icon: "output", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: capture the logs (report + bundle)", command: LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle, icon: "file-zip", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: show the diagnostic report", command: LANGUAGE_SERVER_COMMANDS.collectReport, icon: "report", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: open the log folder", command: LANGUAGE_SERVER_COMMANDS.openLogFolder, icon: "folder-opened", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: show the last captured bundle", command: LANGUAGE_SERVER_COMMANDS.revealBundle, icon: "folder", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: reset this workspace's cache", command: LANGUAGE_SERVER_COMMANDS.resetWorkspaceCache, icon: "trash", iconColor: "neutral", group: "languageServer" }, + + { labelKey: "Build and refresh the language service", command: CLI_COMMANDS.autoConfigureModules, icon: "refresh", iconColor: "blue", group: "settings" }, + { labelKey: "Environment self-check", command: TOOL_COMMANDS.selfCheck, icon: "heart", iconColor: "green", group: "settings" }, + { labelKey: "mcpp settings", command: TOOL_COMMANDS.openSettings, icon: "settings-gear", iconColor: "neutral", group: "settings" }, +]; + +export const quickMenuStatusText = "$(tools) mcpp"; diff --git a/src/config/access.ts b/src/config/access.ts new file mode 100644 index 0000000..862e465 --- /dev/null +++ b/src/config/access.ts @@ -0,0 +1,133 @@ +/** + * Typed reads and writes for `mcpp.*` settings. + * + * Every read goes through the registry, so a value that does not match its + * declaration (a hand-edited `settings.json`) is replaced by the declared + * default and reported once — never silently passed on to the rest of the code. + * + * Writes always name a target. The configuration panel and the commands choose + * it deliberately; nothing here writes `mcppls.*` or any other section. + */ + +import * as vscode from "vscode"; + +import { defaultValue, expectation, validateValue } from "./validate"; +import { SECTION, SETTINGS, setting, subKey, type SettingEntry } from "./registry"; + +export type ValueSource = "default" | "user" | "workspace" | "workspaceFolder" | "language" | "invalid"; + +export interface EffectiveValue { + value: T; + source: ValueSource; +} + +const warned = new Set(); + +function configuration(resource?: vscode.Uri): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration(SECTION, resource ?? null); +} + +/** + * Where the effective value comes from, for the panel's "current value" column + * and for the environment self-check. + */ +export function effective(key: string, resource?: vscode.Uri): EffectiveValue { + const entry = setting(key); + if (entry === undefined) { + return { value: undefined as unknown as T, source: "invalid" }; + } + const inspected = configuration(resource).inspect(entry.key.slice(SECTION.length + 1)); + const candidates: Array<[ValueSource, unknown]> = [ + ["workspaceFolder", inspected?.workspaceFolderValue], + ["workspace", inspected?.workspaceValue], + ["user", inspected?.globalValue], + ]; + for (const [source, raw] of candidates) { + if (raw === undefined) { + continue; + } + const result = validateValue(entry, raw); + if (result.ok) { + return { value: result.value as T, source }; + } + warnOnce(entry, source, raw); + return { value: defaultValue(entry), source: "invalid" }; + } + return { value: defaultValue(entry), source: "default" }; +} + +/** Shorthand for callers that only want the value. */ +export function read(key: string, resource?: vscode.Uri): T { + return effective(key, resource).value; +} + +function warnOnce(entry: SettingEntry, source: ValueSource, raw: unknown): void { + const signature = `${entry.key}@${source}`; + if (warned.has(signature)) { + return; + } + warned.add(signature); + void vscode.window.showWarningMessage( + `${entry.key}: ${JSON.stringify(raw)} is not a valid value (expected ${expectation(entry)}); using the default.`, + ); +} + +/** Test seam: forget which invalid values have been reported. */ +export function resetInvalidWarnings(): void { + warned.clear(); +} + +export type WriteTarget = "user" | "workspace" | "workspaceFolder"; + +function configurationTarget(target: WriteTarget): vscode.ConfigurationTarget { + switch (target) { + case "workspace": + return vscode.ConfigurationTarget.Workspace; + case "workspaceFolder": + return vscode.ConfigurationTarget.WorkspaceFolder; + default: + return vscode.ConfigurationTarget.Global; + } +} + +/** + * Write one setting. A `resource`-scoped setting must name the folder it + * applies to, otherwise VS Code would write it to the wrong place. + */ +export async function write( + key: string, + value: unknown, + target: WriteTarget, + resource?: vscode.Uri, +): Promise { + const entry = setting(key); + if (entry === undefined) { + throw new Error(`unknown mcpp setting: ${key}`); + } + if (entry.scope === "resource" && target !== "user" && resource === undefined) { + throw new Error(`${key} is resource-scoped; a folder URI is required`); + } + await configuration(resource).update(subKey(entry.key), value, configurationTarget(target)); +} + +/** Every setting whose effective value differs from its declared default. */ +export function changedSettings(resource?: vscode.Uri): Array<{ key: string; value: unknown; source: ValueSource }> { + const changed: Array<{ key: string; value: unknown; source: ValueSource }> = []; + for (const entry of SETTINGS) { + const current = effective(entry.key, resource); + if (current.source === "default" || current.source === "language") { + continue; + } + changed.push({ key: entry.key, value: current.value, source: current.source }); + } + return changed; +} + +/** Fires for any `mcpp.*` change; the panel and the views subscribe once. */ +export function onDidChange(listener: () => void): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration(SECTION)) { + listener(); + } + }); +} diff --git a/src/config/migrate.ts b/src/config/migrate.ts new file mode 100644 index 0000000..053fd34 --- /dev/null +++ b/src/config/migrate.ts @@ -0,0 +1,66 @@ +/** + * Renamed settings: the old name keeps working, and the user is offered the + * move once. + * + * This mirrors what the C++ Modules extension does for its own renames: read + * the alias, write nothing until the user agrees, and never remove the old + * value on their behalf. + */ + +import * as vscode from "vscode"; + +import { effective, write } from "./access"; +import { renamedKeys, subKey, SECTION } from "./registry"; + +export interface RenamedSetting { + from: string; + to: string; + value: unknown; +} + +/** Renames whose old name still holds a value the new name does not. */ +export function pendingRenames(resource?: vscode.Uri): RenamedSetting[] { + const pending: RenamedSetting[] = []; + const configuration = vscode.workspace.getConfiguration(SECTION, resource ?? null); + for (const { from, to } of renamedKeys()) { + const oldValue = configuration.get(subKey(from)); + if (oldValue === undefined) { + continue; + } + const current = effective(to, resource); + if (current.source !== "default") { + continue; + } + pending.push({ from, to, value: oldValue }); + } + return pending; +} + +/** + * Perform the move for every pending rename. The old key is left in place — + * removing a user's setting is their decision, and a stale alias is harmless + * because nothing reads it. + */ +export async function applyRenames( + pending: readonly RenamedSetting[], + target: "user" | "workspace", + resource?: vscode.Uri, +): Promise { + let moved = 0; + for (const rename of pending) { + try { + await write(rename.to, rename.value, target, resource); + moved += 1; + } catch { + // A rejected write (read-only settings, no folder) is not fatal: the user + // can still set the new key themselves. + } + } + return moved; +} + +/** Notification text, kept here so the panel and the activation path agree. */ +export function renamePrompt(pending: readonly RenamedSetting[]): string { + const names = pending.map((rename) => `${rename.from} → ${rename.to}`).join(", "); + return `mcpp: ${pending.length} setting(s) were renamed (${names}). Move them to the new names?`; +} diff --git a/src/config/panel.ts b/src/config/panel.ts new file mode 100644 index 0000000..86eeb9e --- /dev/null +++ b/src/config/panel.ts @@ -0,0 +1,403 @@ +/** + * The VS Code half of the configuration panel: `mcpp: Open the settings panel`. + * + * The panel is deliberately narrow. It is a **reading aid** for the 60+ settings + * in `data/config-registry.json`: it groups them, shows each one's effective + * value and where that value comes from, and explains when a change takes + * effect. It does not replace the Settings editor — every row has a link that + * opens the native one — and it names the boundary out loud: it writes + * `mcpp.*` only, never `mcppls.*`, which belongs to the C++ Modules extension. + * + * Safety rules this file is responsible for: + * + * - a key is written only if the registry declares it (so `mcpp.*` only); + * - a value is validated against the registry before it is written, so the + * panel cannot be the thing that puts a bad value in `settings.json`; + * - a `resource`-scoped setting is never written to a workspace level without a + * folder URI (VS Code has nowhere to put it); + * - a rejected write (a read-only level, a folder that went away) is swallowed: + * the panel re-sends the effective model, and the user sees the real state. + * + * `src/config/panelHtml.ts` owns the document; this module owns the data and the + * messages. The webview answers every action with `{ type: "model", model }`. + */ + +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { TOOL_COMMANDS } from "../commands/ids"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { MCPPLS_EXTENSION_ID } from "../mcppls/contract"; +import { WebviewDocument } from "../webview/document"; +import { effective, onDidChange, write, type WriteTarget } from "./access"; +import { + PANEL_UI, + decodePanelMessage, + renderPanelHtml, + type PanelModel, + type PanelPreset, + type PanelRow, + type PanelSection, +} from "./panelHtml"; +import { PRESETS, presetValues } from "./presets"; +import { GROUPS, SECTION, SETTINGS, setting, settingsInGroup, subKey, type SettingEntry } from "./registry"; +import { validateValue } from "./validate"; + +/** `@ext:` queries need `publisher.name`, which is not in any registry. */ +const EXTENSION_ID = "mcpp-community.mcpp-vscode"; +const PANEL_VIEW_TYPE = "mcpp.settingsPanel"; +const OPEN_SETTINGS = "workbench.action.openSettings"; +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "settings.css"; + +/** + * The slice of `vscode.ExtensionContext` this module needs. `extensionUri` is + * optional so the declared shape stays callable from tests and from hosts that + * do not have one; when it is missing the media directory is derived from + * `__dirname` (`dist/src/config/` → the extension root). + */ +export interface PanelHostContext { + subscriptions: { push(...items: unknown[]): unknown }; + extensionUri?: vscode.Uri; +} + +interface PanelSession { + panel: vscode.WebviewPanel; + resource?: vscode.Uri; + host: PanelHostContext; + /** + * The document and its per-panel nonce. The panel's html is assigned exactly + * once per open — every later update travels by `postMessage` — but the kit + * is still used so every webview host in this extension holds its document + * the same way (one nonce minting point, one assignment site). + */ + webviewDocument: WebviewDocument; +} + +/** One panel per window: reopening reveals and refreshes the existing one. */ +let session: PanelSession | undefined; + +/** + * Register `mcpp.openSettings`. The command takes no arguments; the panel's + * resource (the folder whose values it shows) follows the active editor when it + * is inside a workspace folder, otherwise the first folder. + */ +export function registerSettingsPanel(host: PanelHostContext): void { + host.subscriptions.push( + vscode.commands.registerCommand(TOOL_COMMANDS.openSettings, () => { + open(host); + }), + onDidChange(() => { + if (session !== undefined) { + postModel(session); + } + }), + { + dispose: () => { + session?.panel.dispose(); + session = undefined; + }, + }, + ); +} + +function open(host: PanelHostContext): void { + if (session !== undefined) { + session.panel.reveal(); + postModel(session); + return; + } + const resource = resourceUri(); + const panel = vscode.window.createWebviewPanel(PANEL_VIEW_TYPE, t("mcpp settings"), vscode.ViewColumn.Active, { + enableScripts: true, + localResourceRoots: [mediaRoot(host)], + retainContextWhenHidden: false, + }); + const active: PanelSession = { panel, resource, host, webviewDocument: new WebviewDocument(STYLESHEET) }; + session = active; + const model = buildModel(resource); + const html = renderPanelHtml(model, { + ...active.webviewDocument.assets(mediaRoot(host), panel.webview), + // The client script is inline and nonced; the CSP names no external script + // source, so this stays empty on purpose. + scriptUri: "", + }); + active.webviewDocument.paint(panel.webview, html); + panel.webview.onDidReceiveMessage((raw: unknown) => { + void handle(active, raw); + }); + panel.onDidDispose(() => { + if (session === active) { + session = undefined; + } + }); +} + +async function handle(active: PanelSession, raw: unknown): Promise { + const message = decodePanelMessage(raw); + if (message === undefined) { + return; + } + try { + switch (message.type) { + case "update": + await applyUpdate(active, message.key, message.value, message.target); + break; + case "reset": + await resetEverywhere(message.key, active.resource); + break; + case "preset": + await applyPreset(message.id, active.resource); + break; + case "openNative": + await vscode.commands.executeCommand(OPEN_SETTINGS, `@ext:${EXTENSION_ID} ${message.key}`); + break; + case "openMcpplsSettings": + await vscode.commands.executeCommand(OPEN_SETTINGS, `@ext:${MCPPLS_EXTENSION_ID}`); + break; + } + } catch { + // A write can fail for reasons that are the user's business, not the + // panel's: a read-only settings level, a folder that disappeared. The model + // sent below shows the value that is actually in effect. + } + postModel(active); +} + +async function applyUpdate( + active: PanelSession, + key: string, + value: unknown, + target: "user" | "workspace", +): Promise { + const entry = setting(key); + if (entry === undefined) { + return; + } + const validated = validateValue(entry, value); + if (!validated.ok || !writable(entry, target, active.resource)) { + return; + } + await write(entry.key, validated.value, target, active.resource); +} + +/** A resource-scoped setting needs a folder URI for any workspace-level write. */ +function writable(entry: SettingEntry, target: WriteTarget, resource?: vscode.Uri): boolean { + return !(entry.scope === "resource" && target !== "user" && resource === undefined); +} + +function configurationTarget(target: WriteTarget): vscode.ConfigurationTarget { + switch (target) { + case "workspace": + return vscode.ConfigurationTarget.Workspace; + case "workspaceFolder": + return vscode.ConfigurationTarget.WorkspaceFolder; + default: + return vscode.ConfigurationTarget.Global; + } +} + +/** + * Remove one level's override. `configuration.update(key, undefined, target)` is + * the only way to delete a value; leaving the level alone is not enough, because + * a workspace value would then keep shadowing the default. + */ +async function resetKey(key: string, target: WriteTarget, resource?: vscode.Uri): Promise { + const entry = setting(key); + if (entry === undefined || !writable(entry, target, resource)) { + return; + } + await vscode.workspace + .getConfiguration(SECTION, resource ?? null) + .update(subKey(key), undefined, configurationTarget(target)); +} + +/** "Reset" means "back to the default", so every level that could hold it is cleared. */ +async function resetEverywhere(key: string, resource?: vscode.Uri): Promise { + const targets: WriteTarget[] = + resource === undefined ? ["user", "workspace"] : ["user", "workspace", "workspaceFolder"]; + for (const target of targets) { + try { + await resetKey(key, target, resource); + } catch { + // An unwritable level is skipped; the others still get cleared. + } + } +} + +async function applyPreset(id: string, resource?: vscode.Uri): Promise { + // `defaults` carries no values by design; it is the one preset that resets. + if (id === "defaults") { + for (const entry of SETTINGS) { + await resetEverywhere(entry.key, resource); + } + return; + } + const target: WriteTarget = resource === undefined ? "user" : "workspace"; + for (const { key, value } of presetValues(id)) { + const entry = setting(key); + if (entry === undefined || !writable(entry, target, resource)) { + continue; + } + const validated = validateValue(entry, value); + if (!validated.ok) { + continue; + } + try { + await write(entry.key, validated.value, target, resource); + } catch { + // Keep applying the rest of the preset rather than stopping halfway. + } + } +} + +/** + * The whole model. Labels go through `t()`, so the panel follows the editor's + * language exactly like the runtime messages do; registry titles and + * descriptions are the same English strings `package.nls.*` carries, and fall + * back to English until their translations land in `data/i18n`. + */ +function buildModel(resource?: vscode.Uri): PanelModel { + const sections: PanelSection[] = GROUPS.map((group) => ({ + id: group.id, + title: t(group.title), + rows: settingsInGroup(group.id).map((entry) => buildRow(entry, resource)), + })).filter((section) => section.rows.length > 0); + const presets: PanelPreset[] = PRESETS.map((preset) => ({ + id: preset.id, + title: t(preset.title), + description: t(preset.description), + })); + const model: PanelModel = { sections, presets, ui: panelLabels() }; + const label = resource === undefined ? undefined : folderLabel(resource); + if (label !== undefined) { + model.resourceLabel = label; + } + return model; +} + +function buildRow(entry: SettingEntry, resource?: vscode.Uri): PanelRow { + const current = effective(entry.key, resource); + const row: PanelRow = { + key: entry.key, + title: t(entry.title), + description: t(entry.description), + type: entry.type, + value: current.value, + scope: entry.scope, + applies: entry.applies, + // `effective()` also reports `language`, which is a level, not a source the + // panel can show a button for; it reads as a plain user-level value. + source: current.source === "language" ? "default" : current.source, + tier: entry.tier, + since: entry.since, + }; + if (entry.enum !== undefined) { + // Enum values are identifiers (`auto`, `workspaceFolder`, `warning`); the + // registry has no separate label for them and inventing copy here would + // give the panel words the Settings editor does not use. + row.options = entry.enum.map((value) => ({ value, label: value })); + } + if (entry.minimum !== undefined) { + row.minimum = entry.minimum; + } + if (entry.maximum !== undefined) { + row.maximum = entry.maximum; + } + if (entry.deprecated === true) { + row.deprecated = true; + if (entry.deprecationMessage !== undefined) { + row.deprecationMessage = t(entry.deprecationMessage); + } + } + return row; +} + +/** + * Every visible string, resolved once per model. These are the literals + * `tools/l10n-check.mjs` holds to `data/i18n/zh-cn.json`. + */ +function panelLabels(): Record { + return { + [PANEL_UI.htmlLang]: htmlLanguage(), + [PANEL_UI.title]: t("mcpp settings"), + [PANEL_UI.boundary]: t( + "This panel changes mcpp-vscode settings only; the C++ Modules extension ({0}) keeps its own mcppls.* settings and this panel never writes them.", + MCPPLS_EXTENSION_ID, + ), + [PANEL_UI.openMcpplsSettings]: t("Open the C++ Modules settings"), + [PANEL_UI.resourceLabel]: t("Values shown for {0}"), + [PANEL_UI.search]: t("Search settings"), + [PANEL_UI.onlyModified]: t("Only modified"), + [PANEL_UI.showAdvanced]: t("Show advanced settings"), + [PANEL_UI.target]: t("Save settings to"), + [PANEL_UI.targetUser]: t("User settings"), + [PANEL_UI.targetWorkspace]: t("Workspace settings"), + [PANEL_UI.targetWorkspaceUnavailable]: t("No workspace folder is open"), + [PANEL_UI.presets]: t("Presets"), + [PANEL_UI.modified]: t("Changed from the default"), + [PANEL_UI.deprecated]: t("Deprecated"), + [PANEL_UI.invalid]: t("Invalid"), + [PANEL_UI.appliesNextBuild]: t("Takes effect on the next build"), + [PANEL_UI.appliesNextClean]: t("Takes effect on the next clean"), + [PANEL_UI.appliesViewReload]: t("Reload the window to see this"), + [PANEL_UI.sourceDefault]: t("Default"), + [PANEL_UI.sourceUser]: t("User"), + [PANEL_UI.sourceWorkspace]: t("Workspace"), + [PANEL_UI.sourceWorkspaceFolder]: t("Workspace folder"), + [PANEL_UI.sourceInvalid]: t("Invalid"), + [PANEL_UI.reset]: t("Reset"), + [PANEL_UI.resetInvalid]: t("Reset the invalid value to the default"), + [PANEL_UI.openNative]: t("Open in the Settings editor"), + [PANEL_UI.arrayHint]: t("One value per line"), + [PANEL_UI.noMatches]: t("No settings match the search"), + [PANEL_UI.toggleSection]: t("Toggle this section"), + }; +} + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} + +function postModel(active: PanelSession): void { + void active.panel.webview.postMessage({ type: "model", model: buildModel(active.resource) }); +} + +/** + * The folder whose values the panel shows. An active editor inside a workspace + * folder wins (that is the project the user is looking at); otherwise the first + * folder. `undefined` means "no resource", and then only user-level writes are + * offered. + */ +function resourceUri(): vscode.Uri | undefined { + const active = vscode.window.activeTextEditor?.document.uri; + if (active !== undefined) { + const folder = vscode.workspace.getWorkspaceFolder(active); + if (folder !== undefined) { + return folder.uri; + } + } + return vscode.workspace.workspaceFolders?.[0]?.uri; +} + +function folderLabel(resource: vscode.Uri): string { + return vscode.workspace.getWorkspaceFolder(resource)?.name ?? path.basename(resource.fsPath); +} + +function mediaRoot(host: PanelHostContext): vscode.Uri { + const extensionUri = host.extensionUri ?? vscode.extensions.getExtension(EXTENSION_ID)?.extensionUri; + if (extensionUri !== undefined) { + return vscode.Uri.joinPath(extensionUri, MEDIA_DIRECTORY); + } + // `dist/src/config/` -> the extension root, where `media/` ships. + return vscode.Uri.file(path.join(__dirname, "..", "..", "..", MEDIA_DIRECTORY)); +} diff --git a/src/config/panelHtml.ts b/src/config/panelHtml.ts new file mode 100644 index 0000000..7af7456 --- /dev/null +++ b/src/config/panelHtml.ts @@ -0,0 +1,612 @@ +/** + * The configuration panel's document, as a pure function. + * + * Everything the panel shows is data: `src/config/panel.ts` resolves the + * registry and the current values into a `PanelModel`, and this module turns + * that into one self-contained HTML document. No `vscode`, no file system, no + * network — which is what makes the interesting parts (escaping, the strict + * CSP, the advanced/deprecated/invalid marking) unit-testable. + * + * House rules the tests pin down: + * + * - **Strict CSP.** `default-src 'none'` plus exactly three sources: the + * stylesheet, the nonced inline script, and the webview's own image source. + * There are no external resources and no inline `style=` attributes, so a + * theme change cannot be the only thing that makes a control usable. + * - **No hard-coded copy.** Every user-visible label is looked up in + * `PanelModel.ui`, which the caller has already localized; the renderer never + * invents English. A key the caller forgot degrades to the key itself rather + * than to a blank control. + * - **No unescaped interpolation.** Every value from the registry or from + * `settings.json` goes through `escapeHtml`; the client script only ever + * assigns through `textContent`, never `innerHTML`. + * - **State is text plus shape, not colour.** Source, deprecation and invalid + * state are rendered as labelled badges and hidden attributes; the stylesheet + * adds borders around them. Colour is decoration only, so high-contrast + * themes stay readable. + * + * The webview talks back with JSON `postMessage`s (see `decodePanelMessage`) + * and the host answers with `{ type: "model", model }`, which the client script + * applies in place. + */ + +import { format } from "../i18n/translate"; + +/** One choice of an enum setting. */ +export interface PanelOption { + value: string; + label: string; +} + +/** One setting, ready to render. Values and copy are already resolved. */ +export interface PanelRow { + key: string; + title: string; + description: string; + type: "boolean" | "string" | "number" | "array"; + value: boolean | string | number | string[]; + options?: PanelOption[]; + minimum?: number; + maximum?: number; + scope: "resource" | "window"; + applies: "immediate" | "next-build" | "next-clean" | "view-reload"; + source: "default" | "user" | "workspace" | "workspaceFolder" | "invalid"; + tier: "public" | "advanced"; + deprecated?: boolean; + deprecationMessage?: string; + since: string; +} + +/** One registry group, with its rows in registry order. */ +export interface PanelSection { + id: string; + title: string; + rows: PanelRow[]; +} + +/** One preset button. */ +export interface PanelPreset { + id: string; + title: string; + description: string; +} + +export interface PanelModel { + sections: PanelSection[]; + presets: PanelPreset[]; + /** Every label the panel shows, already localized by the caller. */ + ui: Record; + /** The workspace folder the values belong to, when the panel was opened from one. */ + resourceLabel?: string; +} + +export interface PanelAssets { + cspSource: string; + nonce: string; + styleUri: string; + scriptUri: string; +} + +/** + * The `ui` keys the renderer reads, so the caller and the renderer cannot drift + * apart on a string. `panel.ts` fills all of them through `t()`. + */ +export const PANEL_UI = { + htmlLang: "panel.htmlLang", + title: "panel.title", + boundary: "panel.boundary", + openMcpplsSettings: "panel.openMcpplsSettings", + resourceLabel: "panel.resourceLabel", + search: "panel.search", + onlyModified: "panel.onlyModified", + showAdvanced: "panel.showAdvanced", + target: "panel.target", + targetUser: "panel.target.user", + targetWorkspace: "panel.target.workspace", + targetWorkspaceUnavailable: "panel.target.workspaceUnavailable", + presets: "panel.presets", + modified: "panel.modified", + deprecated: "panel.deprecated", + invalid: "panel.invalid", + appliesNextBuild: "panel.applies.nextBuild", + appliesNextClean: "panel.applies.nextClean", + appliesViewReload: "panel.applies.viewReload", + sourceDefault: "panel.source.default", + sourceUser: "panel.source.user", + sourceWorkspace: "panel.source.workspace", + sourceWorkspaceFolder: "panel.source.workspaceFolder", + sourceInvalid: "panel.source.invalid", + reset: "panel.reset", + resetInvalid: "panel.resetInvalid", + openNative: "panel.openNative", + arrayHint: "panel.arrayHint", + noMatches: "panel.noMatches", + toggleSection: "panel.toggleSection", +} as const; + +const SOURCE_UI: Record = { + default: PANEL_UI.sourceDefault, + user: PANEL_UI.sourceUser, + workspace: PANEL_UI.sourceWorkspace, + workspaceFolder: PANEL_UI.sourceWorkspaceFolder, + invalid: PANEL_UI.sourceInvalid, +}; + +const APPLIES_UI: Partial> = { + "next-build": PANEL_UI.appliesNextBuild, + "next-clean": PANEL_UI.appliesNextClean, + "view-reload": PANEL_UI.appliesViewReload, +}; + +export type PanelUiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * The CSP is an attribute value, but its single quotes are syntax: escaping + * them would turn `'none'` into `'none'` and make the document state a + * different policy than it means. Only the characters that could end the + * attribute or start a tag are escaped; `cspSource` and the nonce come from + * VS Code and from `crypto`, not from the registry. + */ +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** ` name="value"`, or nothing when the value is absent. */ +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +/** ` name`, or nothing. Boolean HTML attributes only. */ +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** The text of the `applies` hint, or `""` when the setting is immediate. */ +function appliesHint(row: PanelRow, label: PanelUiLabel): string { + const key = APPLIES_UI[row.applies]; + return key === undefined ? "" : label(key); +} + +function renderControl(row: PanelRow, label: PanelUiLabel): string { + const invalid = row.source === "invalid"; + const key = escapeHtml(row.key); + const aria = escapeHtml(row.title); + if (row.type === "boolean") { + return ( + `` + ); + } + if (row.type === "number") { + return ( + `` + ); + } + if (row.type === "array") { + const lines = Array.isArray(row.value) ? row.value.join("\n") : String(row.value); + return ( + `` + + `

${escapeHtml(label(PANEL_UI.arrayHint))}

` + ); + } + if (row.options !== undefined && row.options.length > 0) { + const options = row.options + .map( + (option) => + ``, + ) + .join(""); + return ( + `` + ); + } + return ( + `` + ); +} + +function renderRow(row: PanelRow, label: PanelUiLabel): string { + const invalid = row.source === "invalid"; + const modified = row.source !== "default"; + const deprecated = row.deprecated === true; + const deprecation = row.deprecationMessage ?? ""; + const hint = appliesHint(row, label); + const advanced = row.tier === "advanced"; + const resetTitle = invalid ? label(PANEL_UI.resetInvalid) : label(PANEL_UI.reset); + return [ + `
`, + `
`, + ` ${escapeHtml(row.title)}`, + ` ${escapeHtml(label(PANEL_UI.modified))}`, + ` ${escapeHtml(label(PANEL_UI.deprecated))}`, + ` ${escapeHtml(label(PANEL_UI.invalid))}`, + ` ${escapeHtml(label(SOURCE_UI[row.source]))}`, + `
`, + `

${escapeHtml(row.description)}

`, + `

${escapeHtml(deprecation)}

`, + `

${escapeHtml(hint)}

`, + `
${renderControl(row, label)}
`, + `
`, + ` `, + ` `, + `
`, + `
`, + ].join("\n"); +} + +function renderSection(section: PanelSection, label: PanelUiLabel): string { + const rows = section.rows.map((row) => renderRow(row, label)).join("\n"); + return [ + `
`, + `

`, + ` `, + `

`, + `
`, + rows, + `
`, + `
`, + ].join("\n"); +} + +function renderPresets(presets: readonly PanelPreset[], label: PanelUiLabel): string { + if (presets.length === 0) { + return ""; + } + const buttons = presets + .map( + (preset) => + ``, + ) + .join("\n "); + return [ + ``, + ].join("\n"); +} + +function renderToolbar(label: PanelUiLabel, hasResource: boolean): string { + return [ + `
`, + ` `, + ` `, + ` `, + ` `, + `
`, + ].join("\n"); +} + +/** + * A model embedded in the page's own script. `<` is escaped so a registry + * string (or a value from `settings.json`) can never close the script element. + */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ 0; }); + } + return element.value; + } + + function writeControl(element, row) { + var kind = element.getAttribute("data-kind"); + if (kind === "boolean") { element.checked = row.value === true; } + else if (kind === "array") { element.value = (row.value || []).join("\\n"); } + else { element.value = row.value === undefined || row.value === null ? "" : String(row.value); } + element.disabled = row.source === "invalid"; + } + + function applyRow(row) { + var element = rowElements[row.key]; + if (!element) { return; } + element.setAttribute("data-source", row.source); + element.setAttribute("data-modified", row.source === "default" ? "false" : "true"); + setText(element.querySelector('[data-badge="source"]'), ui[SOURCE_KEYS[row.source]] || row.source); + setHidden(element.querySelector('[data-badge="modified"]'), row.source === "default"); + setHidden(element.querySelector('[data-badge="deprecated"]'), row.deprecated !== true); + setHidden(element.querySelector('[data-badge="invalid"]'), row.source !== "invalid"); + var deprecation = element.querySelector(".row-deprecation"); + var message = typeof row.deprecationMessage === "string" ? row.deprecationMessage : ""; + setText(deprecation, message); + setHidden(deprecation, message.length === 0); + var applies = element.querySelector(".row-applies"); + var appliesKey = APPLIES_KEYS[row.applies]; + var hint = appliesKey ? (ui[appliesKey] || "") : ""; + setText(applies, hint); + setHidden(applies, hint.length === 0); + var input = element.querySelector("[data-control]"); + if (input) { writeControl(input, row); } + } + + function rowVisible(key, element, query, onlyChanged, advanced) { + if (!advanced && element.getAttribute("data-tier") === "advanced") { return false; } + if (onlyChanged && element.getAttribute("data-modified") !== "true") { return false; } + if (query.length === 0) { return true; } + var title = element.querySelector(".row-title"); + var description = element.querySelector(".row-description"); + var haystack = key + " " + (title ? title.textContent : "") + " " + (description ? description.textContent : ""); + return haystack.toLowerCase().indexOf(query) >= 0; + } + + function filter() { + var query = searchInput ? searchInput.value.trim().toLowerCase() : ""; + var onlyChanged = onlyModifiedInput ? onlyModifiedInput.checked : false; + var advanced = showAdvancedInput ? showAdvancedInput.checked : false; + var anySection = false; + for (var index = 0; index < sectionElements.length; index += 1) { + var section = sectionElements[index]; + var rows = section.querySelectorAll("[data-row]"); + var anyRow = false; + for (var inner = 0; inner < rows.length; inner += 1) { + var element = rows[inner]; + var shown = rowVisible(element.getAttribute("data-row"), element, query, onlyChanged, advanced); + element.hidden = !shown; + if (shown) { anyRow = true; } + } + section.hidden = !anyRow; + if (anyRow) { anySection = true; } + } + if (emptyState) { emptyState.hidden = anySection; } + } + + function applyModel(next) { + if (!next) { return; } + state = next; + ui = next.ui || {}; + var sections = next.sections || []; + for (var index = 0; index < sections.length; index += 1) { + var rows = sections[index].rows || []; + for (var inner = 0; inner < rows.length; inner += 1) { applyRow(rows[inner]); } + } + var presets = next.presets || []; + for (var presetIndex = 0; presetIndex < presets.length; presetIndex += 1) { + var button = presetElements[presets[presetIndex].id]; + if (button) { + setText(button, presets[presetIndex].title); + if (typeof presets[presetIndex].description === "string") { button.title = presets[presetIndex].description; } + } + } + filter(); + } + + if (searchInput) { searchInput.addEventListener("input", filter); } + if (onlyModifiedInput) { onlyModifiedInput.addEventListener("change", filter); } + if (showAdvancedInput) { showAdvancedInput.addEventListener("change", filter); } + + document.addEventListener("change", function (event) { + var element = event.target; + if (!element || typeof element.getAttribute !== "function") { return; } + if (!element.hasAttribute("data-control")) { return; } + var row = element.closest("[data-row]"); + if (!row) { return; } + var value = readControl(element); + if (value === undefined) { return; } + post({ type: "update", key: row.getAttribute("data-row"), value: value, target: targetSelect ? targetSelect.value : "user" }); + }); + + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var button = target.closest("button"); + if (!button) { return; } + var preset = button.getAttribute("data-preset"); + if (preset) { post({ type: "preset", id: preset }); return; } + if (button.hasAttribute("data-section-toggle")) { + var section = button.closest("[data-section]"); + if (section) { + var collapsed = section.getAttribute("data-collapsed") === "true"; + section.setAttribute("data-collapsed", collapsed ? "false" : "true"); + button.setAttribute("aria-expanded", collapsed ? "true" : "false"); + } + return; + } + var action = button.getAttribute("data-action"); + if (action === "reset" || action === "openNative") { + var row = button.closest("[data-row]"); + if (row) { + if (action === "reset") { post({ type: "reset", key: row.getAttribute("data-row") }); } + else { post({ type: "openNative", key: row.getAttribute("data-row") }); } + } + return; + } + if (button.id === "panel-open-mcppls") { post({ type: "openMcpplsSettings" }); } + }); + + window.addEventListener("message", function (event) { + var data = event.data; + if (data && data.type === "model" && data.model) { applyModel(data.model); } + }); + + filter(); + post({ type: "ready" }); +})();`; +} + +/** + * The whole document. `assets.scriptUri` is accepted for the contract but not + * used: the client script is inline and nonced, and the CSP names no external + * script source, so nothing else can be loaded. + */ +export function renderPanelHtml(model: PanelModel, assets: PanelAssets): string { + const label: PanelUiLabel = (key) => model.ui[key] ?? key; + const hasResource = model.resourceLabel !== undefined; + const sections = model.sections.map((section) => renderSection(section, label)).join("\n"); + const lang = model.ui[PANEL_UI.htmlLang] ?? ""; + const resource = + model.resourceLabel === undefined + ? "" + : `

${escapeHtml(format(label(PANEL_UI.resourceLabel), [model.resourceLabel]))}

`; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + return ` + + + + + + +${escapeHtml(label(PANEL_UI.title))} + + +
+

${escapeHtml(label(PANEL_UI.title))}

+

${escapeHtml(label(PANEL_UI.boundary))}

+ ${resource} +
+${renderToolbar(label, hasResource)} +${renderPresets(model.presets, label)} +
+${sections} +
+ + + + +`; +} + +/** Values posted back by the webview, decoded and validated. */ +export type PanelMessage = + | { type: "update"; key: string; value: unknown; target: "user" | "workspace" } + | { type: "reset"; key: string } + | { type: "preset"; id: string } + | { type: "openNative"; key: string } + | { type: "openMcpplsSettings" } + | { type: "ready" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that + * is not exactly one of the six shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + * + * A settings key is always required and always non-empty; `update` additionally + * needs a write target and a `value` field (the value itself may be anything — + * `validate.ts` checks it against the registry before it is written). + */ +export function decodePanelMessage(raw: unknown): PanelMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "ready": + return { type: "ready" }; + case "openMcpplsSettings": + return { type: "openMcpplsSettings" }; + case "update": { + if (!nonEmptyString(raw.key) || !("value" in raw)) { + return undefined; + } + if (raw.target !== "user" && raw.target !== "workspace") { + return undefined; + } + return { type: "update", key: raw.key, value: raw.value, target: raw.target }; + } + case "reset": + return nonEmptyString(raw.key) ? { type: "reset", key: raw.key } : undefined; + case "preset": + return nonEmptyString(raw.id) ? { type: "preset", id: raw.id } : undefined; + case "openNative": + return nonEmptyString(raw.key) ? { type: "openNative", key: raw.key } : undefined; + default: + return undefined; + } +} diff --git a/src/config/presets.ts b/src/config/presets.ts new file mode 100644 index 0000000..21d3b2e --- /dev/null +++ b/src/config/presets.ts @@ -0,0 +1,110 @@ +/** + * Named bundles of setting values for the configuration panel. + * + * Pure: a preset is data, so the panel, the tests and the docs all read the same + * table. A preset only ever names keys that exist in the registry (enforced by + * `presetProblems()` and the unit tests) — a preset that silently does nothing is + * worse than no preset. + */ + +import { setting } from "./registry"; + +export interface Preset { + id: string; + /** English; the panel localizes this through `src/i18n/t.ts`. */ + title: string; + description: string; + /** Key -> value, applied in order. */ + values: Readonly>; +} + +export const PRESETS: readonly Preset[] = [ + { + id: "defaults", + title: "Defaults", + description: "Put every mcpp setting back to its shipped default.", + values: {}, + }, + { + id: "quiet", + title: "Quiet", + description: "No status-bar counters, no automatic refresh, no editor diagnostics from this extension.", + values: { + "mcpp.cache.statusBar": false, + "mcpp.cache.autoRefreshSeconds": 0, + "mcpp.languageService.stateRefreshSeconds": 0, + "mcpp.ui.notifications.success": "silent", + "mcpp.task.focusTerminal": false, + "mcpp.toml.diagnostics.unknownKey": "off", + "mcpp.toml.diagnostics.legacyKeys": "off", + "mcpp.buildScript.diagnostics": false, + }, + }, + { + id: "everything", + title: "Everything on", + description: "All observation and editing help enabled, including the ones that run mcpp a little more often.", + values: { + "mcpp.cache.statusBar": true, + "mcpp.cache.autoRefreshSeconds": 60, + "mcpp.cache.warnAboveGiB": 10, + "mcpp.languageService.readState": true, + "mcpp.languageService.stateRefreshSeconds": 30, + "mcpp.languageService.notifyOnDegraded": true, + "mcpp.toml.hover": true, + "mcpp.toml.navigation": true, + "mcpp.toml.diagnostics.enabled": true, + "mcpp.toml.diagnostics.unknownSection": "warning", + "mcpp.toml.diagnostics.unknownKey": "warning", + "mcpp.toml.diagnostics.planeSeparation": "warning", + "mcpp.toml.diagnostics.legacyKeys": "info", + "mcpp.buildScript.intelligence": true, + "mcpp.buildScript.diagnostics": true, + "mcpp.buildScript.snippets": true, + "mcpp.buildScript.imports.knownModules": true, + "mcpp.views.cache.topN": 10, + "mcpp.ui.notifications.success": "statusBar", + "mcpp.log.level": "info", + }, + }, + { + id: "diagnose", + title: "Diagnose", + description: "Turn the logging up and read the C++ Modules state, for when something is wrong.", + values: { + "mcpp.log.level": "debug", + "mcpp.languageService.readState": true, + "mcpp.languageService.stateRefreshSeconds": 15, + "mcpp.languageService.notifyOnDegraded": true, + "mcpp.diagnostics.selfCheckOnStartup": true, + "mcpp.toml.diagnostics.unknownSection": "warning", + "mcpp.toml.diagnostics.unknownKey": "warning", + }, + }, +]; + +export function preset(id: string): Preset | undefined { + return PRESETS.find((entry) => entry.id === id); +} + +/** The key/value pairs a preset would write. `defaults` writes nothing: see `resetKeys()`. */ +export function presetValues(id: string): Array<{ key: string; value: unknown }> { + const found = preset(id); + if (found === undefined) { + return []; + } + return Object.entries(found.values).map(([key, value]) => ({ key, value })); +} + +/** Registry keys a preset mentions that do not exist (must be empty). */ +export function presetProblems(): string[] { + const problems: string[] = []; + for (const entry of PRESETS) { + for (const key of Object.keys(entry.values)) { + if (setting(key) === undefined) { + problems.push(`preset ${entry.id} names unknown setting ${key}`); + } + } + } + return problems; +} diff --git a/src/config/registry.ts b/src/config/registry.ts new file mode 100644 index 0000000..2f73423 --- /dev/null +++ b/src/config/registry.ts @@ -0,0 +1,184 @@ +/** + * The settings registry: **the single source of truth** for everything this + * extension makes configurable. + * + * `data/config-registry.json` holds the declaration; `package.json`'s + * `contributes.configuration` is hand-written but held to it by + * `tools/check-config.mjs`, and `docs/settings.md` is generated from it. The + * configuration panel (`src/config/panel.ts`) renders it, and + * `mcpp: environment self-check` dumps the entries the user has changed. + * + * Shape rules are enforced by `tools/check-config.mjs` and by the unit tests: + * unique keys, every `group` present in `groups`, ascending `order` within a + * group, and a `default` that `validate.ts` accepts. + */ + +import registryJson from "../../data/config-registry.json"; + +export type SettingType = "boolean" | "string" | "number" | "array"; +export type SettingScope = "resource" | "window"; +export type SettingTier = "public" | "advanced"; +export type SettingApplies = "immediate" | "next-build" | "next-clean" | "view-reload"; + +export interface SettingEntry { + key: string; + type: SettingType; + default: boolean | string | number | string[]; + enum?: string[]; + minimum?: number; + maximum?: number; + scope: SettingScope; + group: string; + order: number; + tier: SettingTier; + applies: SettingApplies; + since: string; + deprecated?: boolean; + aliases?: string[]; + title: string; + description: string; + deprecationMessage?: string; +} + +export interface SettingGroup { + id: string; + order: number; + title: string; +} + +interface RegistryFile { + version: number; + groups: SettingGroup[]; + settings: SettingEntry[]; +} + +const registry = registryJson as unknown as RegistryFile; + +export const SETTINGS: readonly SettingEntry[] = registry.settings; +export const GROUPS: readonly SettingGroup[] = [...registry.groups].sort((a, b) => a.order - b.order); + +const BY_KEY = new Map(SETTINGS.map((entry) => [entry.key, entry])); +const GROUP_BY_ID = new Map(GROUPS.map((group) => [group.id, group])); + +/** The section every key lives under; VS Code's `getConfiguration` needs it. */ +export const SECTION = "mcpp"; + +/** `"mcpp.cache.staleDays"` -> `"cache.staleDays"`. */ +export function subKey(key: string): string { + return key.startsWith(`${SECTION}.`) ? key.slice(SECTION.length + 1) : key; +} + +export function setting(key: string): SettingEntry | undefined { + return BY_KEY.get(key); +} + +export function requireSetting(key: string): SettingEntry { + const entry = BY_KEY.get(key); + if (entry === undefined) { + throw new Error(`unknown mcpp setting: ${key}`); + } + return entry; +} + +export function groupOf(key: string): SettingGroup | undefined { + const entry = BY_KEY.get(key); + return entry === undefined ? undefined : GROUP_BY_ID.get(entry.group); +} + +/** Entries of one group, in their declared order. */ +export function settingsInGroup(groupId: string): SettingEntry[] { + return SETTINGS.filter((entry) => entry.group === groupId) + .slice() + .sort((a, b) => a.order - b.order); +} + +export function settingsByTier(tier: SettingTier): SettingEntry[] { + return SETTINGS.filter((entry) => entry.tier === tier); +} + +/** Keys this build mentions in the manifest, in registry order. */ +export function manifestKeys(): string[] { + return SETTINGS.map((entry) => entry.key); +} + +/** The key an old name now resolves to, or `undefined` when it is unknown. */ +export function aliasedKey(oldKey: string): string | undefined { + for (const entry of SETTINGS) { + if (entry.aliases?.includes(oldKey) === true) { + return entry.key; + } + } + return undefined; +} + +/** Old name -> current name, for the one-time migration prompt. */ +export function renamedKeys(): Array<{ from: string; to: string }> { + const pairs: Array<{ from: string; to: string }> = []; + for (const entry of SETTINGS) { + for (const alias of entry.aliases ?? []) { + pairs.push({ from: alias, to: entry.key }); + } + } + return pairs; +} + +/** + * The registry's own invariants. Returns a list of problems (empty when sound). + * + * The same rules live in `tools/check-config.mjs`, but having them here means a + * malformed `data/config-registry.json` is caught by the unit tests as well — + * and lets `activate()` log a single line instead of failing feature by feature. + */ +export function registryShapeProblems(): string[] { + const problems: string[] = []; + const groupIds = new Set(); + for (const group of GROUPS) { + if (groupIds.has(group.id)) { + problems.push(`duplicate group ${group.id}`); + } + groupIds.add(group.id); + if (typeof group.title !== "string" || group.title.length === 0) { + problems.push(`group ${group.id} has no title`); + } + } + const seen = new Set(); + const lastOrder = new Map(); + for (const entry of SETTINGS) { + if (!entry.key.startsWith(`${SECTION}.`)) { + problems.push(`${entry.key} is not a ${SECTION}.* key`); + } + if (seen.has(entry.key)) { + problems.push(`duplicate key ${entry.key}`); + } + seen.add(entry.key); + if (!groupIds.has(entry.group)) { + problems.push(`${entry.key} names undeclared group ${entry.group}`); + } + const previous = lastOrder.get(entry.group); + if (previous !== undefined && !(entry.order > previous)) { + problems.push(`${entry.group} order is not ascending at ${entry.key}`); + } + lastOrder.set(entry.group, entry.order); + if (entry.title.length === 0 || entry.description.length === 0) { + problems.push(`${entry.key} has an empty title or description`); + } + if (entry.enum !== undefined && !entry.enum.includes(entry.default as string)) { + problems.push(`${entry.key} default is not in its enum`); + } + if (entry.deprecated === true && entry.deprecationMessage === undefined) { + problems.push(`${entry.key} is deprecated without a deprecationMessage`); + } + } + return problems; +} + +/** Every setting key, ordered by group then by the group's own order. */ +export function groupedKeys(): string[] { + const keys: string[] = []; + for (const group of GROUPS) { + for (const entry of settingsInGroup(group.id)) { + keys.push(entry.key); + } + } + return keys; +} diff --git a/src/config/validate.ts b/src/config/validate.ts new file mode 100644 index 0000000..91ec337 --- /dev/null +++ b/src/config/validate.ts @@ -0,0 +1,93 @@ +/** + * Value validation for the settings registry. Pure: no `vscode`. + * + * Used in two places: `access.ts` when a value arrives from the editor (a + * hand-edited `settings.json` can hold anything), and the configuration panel + * before it writes. An invalid value never reaches the rest of the extension – + * the declared default is used and the user is told once. + */ + +import type { SettingEntry } from "./registry"; + +export type ValidationFailure = + | "type" + | "enum" + | "minimum" + | "maximum" + | "items"; + +export interface ValidationResult { + ok: boolean; + /** Present when `ok`: the coerced value. */ + value?: boolean | string | number | string[]; + /** Present when `!ok`: why it was rejected. */ + reason?: ValidationFailure; +} + +function typeOf(entry: SettingEntry): string { + return entry.type === "array" ? "array of strings" : entry.type; +} + +export function validateValue(entry: SettingEntry, raw: unknown): ValidationResult { + if (raw === undefined || raw === null) { + return { ok: false, reason: "type" }; + } + switch (entry.type) { + case "boolean": { + if (typeof raw !== "boolean") { + return { ok: false, reason: "type" }; + } + return { ok: true, value: raw }; + } + case "number": { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return { ok: false, reason: "type" }; + } + if (entry.minimum !== undefined && raw < entry.minimum) { + return { ok: false, reason: "minimum" }; + } + if (entry.maximum !== undefined && raw > entry.maximum) { + return { ok: false, reason: "maximum" }; + } + return { ok: true, value: raw }; + } + case "string": { + if (typeof raw !== "string") { + return { ok: false, reason: "type" }; + } + if (entry.enum !== undefined && !entry.enum.includes(raw)) { + return { ok: false, reason: "enum" }; + } + return { ok: true, value: raw }; + } + case "array": { + if (!Array.isArray(raw)) { + return { ok: false, reason: "type" }; + } + if (raw.some((item) => typeof item !== "string")) { + return { ok: false, reason: "items" }; + } + return { ok: true, value: [...raw] as string[] }; + } + default: + return { ok: false, reason: "type" }; + } +} + +/** The declared default, as a fresh value (arrays are copied). */ +export function defaultValue(entry: SettingEntry): T { + return (Array.isArray(entry.default) ? [...entry.default] : entry.default) as T; +} + +/** Human-readable expectation, for the "value rejected" notice. */ +export function expectation(entry: SettingEntry): string { + if (entry.enum !== undefined) { + return entry.enum.join(" | "); + } + if (entry.type === "number" && (entry.minimum !== undefined || entry.maximum !== undefined)) { + const low = entry.minimum ?? Number.NEGATIVE_INFINITY; + const high = entry.maximum ?? Number.POSITIVE_INFINITY; + return `${low} … ${high}`; + } + return typeOf(entry); +} diff --git a/src/discovery.ts b/src/discovery.ts deleted file mode 100644 index e5d0efc..0000000 --- a/src/discovery.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { - existsSync, - statSync, -} from "node:fs"; -import path from "node:path"; - -export interface McppProjectDiscovery { - root: string; - manifestPath: string; - compilationDatabasePath: string; -} - -function isWindowsPath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\"); -} - -function unique(values: string[]): string[] { - return values.filter((value, index) => values.indexOf(value) === index); -} - -function isPathWithin(candidate: string, root: string): boolean { - const relative = path.relative(root, candidate); - return relative === "" || ( - relative !== ".." - && !relative.startsWith(`..${path.sep}`) - && !path.isAbsolute(relative) - ); -} - -export function findNearestMcppProject( - startPath: string, - workspaceRoot?: string, -): McppProjectDiscovery | undefined { - let current = path.resolve(startPath); - const boundary = workspaceRoot === undefined ? undefined : path.resolve(workspaceRoot); - - try { - if (statSync(current).isFile()) { - current = path.dirname(current); - } - } catch { - // 新创建的 VS Code 工作区路径可能尚不存在,此时按目录处理。 - } - - if (boundary !== undefined && !isPathWithin(current, boundary)) { - return undefined; - } - - while (true) { - const manifestPath = path.join(current, "mcpp.toml"); - if (existsSync(manifestPath)) { - return { - root: current, - manifestPath, - compilationDatabasePath: path.join(current, "compile_commands.json"), - }; - } - - if (boundary !== undefined && current === boundary) { - return undefined; - } - const parent = path.dirname(current); - if (parent === current) { - return undefined; - } - current = parent; - } -} - -export function deriveClangdCandidates(compilerPath: string): string[] { - const windows = isWindowsPath(compilerPath); - const pathApi = windows ? path.win32 : path.posix; - const compilerName = pathApi.basename(compilerPath); - const extension = /\.exe$/i.test(compilerName) ? ".exe" : ""; - const sibling = pathApi.join(pathApi.dirname(compilerPath), `clangd${extension}`); - const xlingsMatch = compilerPath.match( - /^(.*?)([\\/])xim-x-llvm[\\/](\d+(?:\.\d+){1,3})[\\/]bin[\\/]clang(?:\+\+|-cl)(\.exe)?$/i, - ); - - const xlingsCandidate = xlingsMatch === null - ? undefined - : `${xlingsMatch[1]}${xlingsMatch[2]}xim-x-llvm-tools${xlingsMatch[2]}${xlingsMatch[3]}${xlingsMatch[2]}bin${xlingsMatch[2]}clangd${xlingsMatch[4] ?? ""}`; - const userXlingsMatch = compilerPath.match( - /^(.*?)([\\/])\.mcpp[\\/]registry[\\/]data[\\/]xpkgs[\\/]xim-x-llvm[\\/](\d+(?:\.\d+){1,3})[\\/]bin[\\/]clang(?:\+\+|-cl)(\.exe)?$/i, - ); - const userXlingsCandidate = userXlingsMatch === null - ? undefined - : `${userXlingsMatch[1]}${userXlingsMatch[2]}.xlings${userXlingsMatch[2]}data${userXlingsMatch[2]}xpkgs${userXlingsMatch[2]}xim-x-llvm-tools${userXlingsMatch[2]}${userXlingsMatch[3]}${userXlingsMatch[2]}bin${userXlingsMatch[2]}clangd${userXlingsMatch[4] ?? ""}`; - - return unique([ - sibling, - ...(xlingsCandidate === undefined ? [] : [xlingsCandidate]), - ...(userXlingsCandidate === undefined ? [] : [userXlingsCandidate]), - "clangd", - ]); -} diff --git a/src/extension.ts b/src/extension.ts index 199dbcf..0c2fa67 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,89 +1,75 @@ -import { existsSync, readFileSync } from "node:fs"; -import process from "node:process"; - import * as vscode from "vscode"; +import { McppCliController, discoveryBoundaryFromSettings } from "./cli/controller"; +import { runMcpp } from "./cli/process"; +import { parseProtocolInfo } from "./cli/protocol"; +import { buildSelfCheckText } from "./cli/selfCheck"; +import { CLI_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, LIBRARY_COMMANDS, TOOL_COMMANDS } from "./commands/ids"; +import { findNearestMcppProject, type McppProjectDiscovery } from "./projects/discovery"; +import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./projects/context"; import { - analyzeCompilationDatabase, - buildClangdArguments, - buildClangdConfigurationPlan, - compareToolIdentities, - type CheckResult, - type CompilationDatabaseAnalysis, - type ModulesSupportMode, - type ToolIdentityComparison, -} from "./analysis"; -import { - deriveClangdCandidates, - findNearestMcppProject, - type McppProjectDiscovery, -} from "./discovery"; -import { - findXlingsExecutable, - llvmToolsVersionSpec, - xlingsInstallArgs, -} from "./llvmTools"; -import { CLI_COMMANDS } from "./commands"; -import { McppCliController } from "./cliController"; -import { runClangdCheck, runToolVersion, type ToolVersionResult } from "./process"; + createLanguageServerBridge, + type LanguageServerBridge, + type LanguageServerCommandResult, +} from "./mcppls/bridge"; +import { compareVersions } from "./library/xpkg"; +import { CAPABILITIES, MCPPLS_EXTENSION_ID, VERIFIED_MCPPLS_MINIMUM, VERIFIED_MCPPLS_RANGE } from "./mcppls/contract"; +import { formatResult } from "./mcppls/messages"; +import { describeState } from "./mcppls/state"; import { - configurationReadyAfterRestart, - configurationAffectsModuleSupport, - createKeyedSingleFlightReconciler, - createLatestOperationTracker, - createSerialExecutor, - describeRefreshOutcome, - moduleSupportState, - registerCompilationDatabaseReconciliation, - shouldRestartClangd, - shouldCheckModuleSupport, - shouldRenderProjectStatus, - shouldUseWorkspaceClangd, - statusCommandForCapability, - workspaceAllowsToolExecution, - type ModuleSupportState, -} from "./workflow"; -import { classifyTaskExit, type TaskCompletion } from "./tasks"; -import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./inProject"; + languageServerEnabled, + languageServerInstalled, + languageServerServes, + languageServerVersion, + readLanguageServerState, +} from "./mcppls/stateSource"; +import { registerBuildScriptProviders } from "./buildscript/providers"; +import { registerTomlProviders } from "./toml/providers"; import { buildModuleSetupPlan, executeModuleSetup, moduleSetupConfirmation, - type ModuleSetupBlockedReason, + type ModuleSetupDecision, type ModuleSetupStepResult, -} from "./moduleSetup"; - -const COMMAND_CONFIGURE = "mcpp.configureClangd"; -const COMMAND_REFRESH = "mcpp.refreshCompilationDatabase"; -const COMMAND_CHECK = "mcpp.checkModuleSupport"; - -interface ProjectContext { - project: McppProjectDiscovery; - analysis: CompilationDatabaseAnalysis; -} - -interface ClangdResolution { - path: string; - version: ToolVersionResult; - comparison: ToolIdentityComparison; +} from "./workflows/moduleSetup"; +import type { TaskCompletion } from "./cli/tasks"; +import { changedSettings, onDidChange as onConfigurationChanged, read } from "./config/access"; +import { applyRenames, pendingRenames, renamePrompt } from "./config/migrate"; +import { registerSettingsPanel } from "./config/panel"; +import { languagePreference, setLanguagePreference, t, type LanguagePreference } from "./i18n/t"; +import { readCacheSnapshot, registerCacheView } from "./cache/cacheView"; +import { registerLanguageServerCommands } from "./views/languageServerView"; +import { createLibraryDetailOpener } from "./library/detailPanel"; +import { loadSnapshot, invalidateIndexCache } from "./library/indexLocator"; +import { watchProjectFiles } from "./library/projectWatcher"; +import { registerLibraryView } from "./library/libraryView"; +import { registerProjectView } from "./views/projectView"; + +/** + * The commands an extension declares in its own `package.json`, read without + * activating it. `undefined` means "no static information", which the capability + * probe treats as "assume it works until a call says otherwise". + */ +function declaredCommandsOf(id: string): readonly string[] | undefined { + const extension = vscode.extensions.getExtension(id); + const commands = (extension?.packageJSON as { contributes?: { commands?: Array<{ command?: string }> } }) + ?.contributes?.commands; + if (!Array.isArray(commands)) { + return undefined; + } + return commands + .map((entry) => entry.command) + .filter((command): command is string => typeof command === "string"); } -interface ProjectReconciliation { - context: ProjectContext | undefined; - databaseFound: boolean; - configured: boolean; -} +/** When the language service was last asked to reload, for the self-check. */ +let lastRefresh: { at: string; state: string; command?: string } | undefined; -interface ModuleStatus { - state: ModuleSupportState; - message: string; +/** `mcpp.ui.language` decides which of our strings the user sees. */ +function applyLanguagePreference(): void { + setLanguagePreference(read("mcpp.ui.language")); } -type ConfigureMode = "automatic" | "interactive"; - -const moduleStatusByProject = new Map(); -const moduleCheckOperations = createLatestOperationTracker(); -let lastReconciledProjectRoot: string | undefined; function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; @@ -96,11 +82,19 @@ function findCurrentProject(): McppProjectDiscovery | undefined { if (workspaceFolder === undefined) { return undefined; } - return findNearestMcppProject(activeUri.fsPath, workspaceFolder.uri.fsPath); + return findNearestMcppProject( + activeUri.fsPath, + workspaceFolder.uri.fsPath, + discoveryBoundaryFromSettings(workspaceFolder.uri), + ); } for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const project = findNearestMcppProject(workspaceFolder.uri.fsPath); + const project = findNearestMcppProject( + workspaceFolder.uri.fsPath, + workspaceFolder.uri.fsPath, + discoveryBoundaryFromSettings(workspaceFolder.uri), + ); if (project !== undefined) { return project; } @@ -108,741 +102,153 @@ function findCurrentProject(): McppProjectDiscovery | undefined { return undefined; } -function loadProjectContext(project: McppProjectDiscovery | undefined = findCurrentProject()): ProjectContext | undefined { - if (project === undefined) { - return undefined; - } - - if (!existsSync(project.compilationDatabasePath)) { - return { - project, - analysis: { - kind: "unknown", - capability: "unavailable", - reason: `找不到编译数据库:${project.compilationDatabasePath}`, - }, - }; - } - - try { - return { - project, - analysis: analyzeCompilationDatabase(readFileSync(project.compilationDatabasePath, "utf8")), - }; - } catch (error) { - return { - project, - analysis: { - kind: "unknown", - capability: "unavailable", - reason: error instanceof Error ? error.message : String(error), - }, - }; - } -} - -function moduleSetupBlockedMessage(reason: ModuleSetupBlockedReason): string { - switch (reason) { - case "project-toolchain-override": - return "当前项目显式固定了非 LLVM 工具链;一键配置不会修改 mcpp.toml,请先手动切换项目工具链。"; - case "untrusted": - return "当前工作区未受信任,不会执行外部程序。请先信任工作区。"; - case "busy": - return "已有 mcpp 操作正在运行,请等待完成后再试。"; - case "unrecognized-inventory": - return "无法识别 mcpp 工具链状态,请查看 mcpp 输出频道并检查 mcpp 版本。"; - } -} - -function configurationTarget(uri: vscode.Uri): vscode.ConfigurationTarget { - return vscode.workspace.getWorkspaceFolder(uri) === undefined - ? vscode.ConfigurationTarget.Workspace - : vscode.ConfigurationTarget.WorkspaceFolder; -} - -function projectConfiguration(project: McppProjectDiscovery): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration("mcpp", vscode.Uri.file(project.root)); -} - -function officialClangdConfiguration(project: McppProjectDiscovery): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration("clangd", vscode.Uri.file(project.root)); -} - -function appendProcessOutput( - output: vscode.OutputChannel, - title: string, - executable: string, - args: string[], - result: { exitCode: number; stdout: string; stderr: string }, -): void { - try { - output.appendLine(`\n[${new Date().toISOString()}] ${title}`); - output.appendLine(`$ ${executable} ${args.join(" ")}`); - if (result.stdout.length > 0) { - output.appendLine(result.stdout.trimEnd()); - } - if (result.stderr.length > 0) { - output.appendLine(result.stderr.trimEnd()); - } - output.appendLine(`[exit ${result.exitCode}]`); - } catch { - // 重载窗口时输出频道可能先于异步检查关闭。 - } -} - -function appendOutputLine(output: vscode.OutputChannel, value: string): void { +function outputText(output: vscode.OutputChannel, line: string): void { try { - output.appendLine(value); + output.appendLine(line); } catch { - // 重载窗口时输出频道可能先于异步操作关闭。 + // The output channel can close before an async task finishes during shutdown. } } -async function resolveClangd(context: ProjectContext): Promise { - const compilerPath = context.analysis.compilerPath; - if (compilerPath === undefined) { - return undefined; - } - - const compilerVersion = await runToolVersion(compilerPath); - const configuredPath = projectConfiguration(context.project).get("clangd.path", "").trim(); - const candidates = configuredPath.length > 0 - ? [configuredPath] - : deriveClangdCandidates(compilerPath); - let fallback: ClangdResolution | undefined; - - for (const candidate of candidates) { - if (candidate !== "clangd" && !existsSync(candidate)) { - continue; - } - - const version = await runToolVersion(candidate); - if (version.exitCode !== 0 || version.identity === undefined) { - continue; - } - - const comparison = compareToolIdentities(compilerVersion.identity, version.identity); - const resolution = { path: candidate, version, comparison }; - if (comparison.compatible) { - return resolution; - } - fallback ??= resolution; - } - - return fallback; -} - -async function maybeDisableCppTools(project: McppProjectDiscovery): Promise { - if (vscode.extensions.getExtension("ms-vscode.cpptools") === undefined) { - return; - } - if (!projectConfiguration(project).get("configureCppTools", true)) { - return; - } - - const uri = vscode.Uri.file(project.root); - const configuration = vscode.workspace.getConfiguration("C_Cpp", uri); - if (configuration.get("intelliSenseEngine") === "disabled") { - return; - } - - const disable = "关闭 IntelliSense"; - const choice = await vscode.window.showWarningMessage( - "clangd 和 Microsoft C/C++ IntelliSense 都可能报告诊断。是否只关闭当前工作区的 cpptools IntelliSense?", - disable, - "保留 IntelliSense", - ); - if (choice === disable) { - await configuration.update( - "intelliSenseEngine", - "disabled", - configurationTarget(uri), - ); - } -} - -async function restartClangd(): Promise { - if (vscode.extensions.getExtension("llvm-vs-code-extensions.vscode-clangd") === undefined) { - return false; - } - try { - await vscode.commands.executeCommand("clangd.restart"); - return true; - } catch { - // clangd 扩展可能尚未激活,但配置仍然已经写入。 - return false; - } -} - -async function configureClangd( - context: ProjectContext, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, - mode: ConfigureMode, - forceRestart: boolean = false, -): Promise { - const interactive = mode === "interactive"; - if (context.analysis.capability === "syntax-only") { - if (interactive) { - await vscode.window.showWarningMessage( - `${context.analysis.kind.toUpperCase()} 模块产物无法由 clangd 读取。语法高亮仍然可用,但模块语义诊断需要 LLVM mcpp 工具链。`, - ); - } - updateStatusBar(status, context); - return false; - } - if (context.analysis.capability !== "full" || context.analysis.compilerPath === undefined) { - if (interactive) { - await vscode.window.showWarningMessage( - `${context.analysis.reason} 请先运行“mcpp: 刷新编译数据库”。`, - ); - } - return false; - } - if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { - const message = "当前工作区未受信任,mcpp 不会执行 CDB 中的编译器或 clangd,也不会接管 clangd 配置。"; - if (interactive) { - await vscode.window.showWarningMessage(message); - } else { - appendOutputLine(output, `[自动配置] ${message}`); - } - return false; - } - - const clangd = await resolveClangd(context); - if (clangd === undefined) { - const message = "没有找到可用的 clangd。请安装与 mcpp LLVM 编译器来自同一 revision 的 clangd,或设置 mcpp.clangd.path;clangd 可以来自 xlings llvm-tools,也可以独立安装。"; - if (interactive) { - await vscode.window.showErrorMessage(message); - } else { - appendOutputLine(output, `[自动配置] ${message}`); - } - return false; - } - if (!clangd.comparison.compatible && !interactive) { - appendOutputLine( - output, - `[自动配置] 跳过不匹配的 clangd ${clangd.path}:${clangd.comparison.reason}。`, - ); - return false; - } - - const clangdConfiguration = officialClangdConfiguration(context.project); - const modulesSupport = projectConfiguration(context.project) - .get("modulesSupport", "auto"); - const plan = buildClangdConfigurationPlan( - clangdConfiguration.get("path", "clangd"), - clangdConfiguration.get("arguments", []), - clangd.path, - { - compilerPath: context.analysis.compilerPath, - compilationArguments: context.analysis.arguments, - modulesSupport, - clangdIdentity: clangd.version.identity, - platform: process.platform, - hasPrebuiltModules: context.analysis.hasPrebuiltModules, - workspaceFolder: context.project.root, - }, - ); - - if (plan.changed) { - // clangd.arguments 不是资源域设置,必须与 clangd.path 一起写到工作区层级。 - await clangdConfiguration.update("path", plan.path, vscode.ConfigurationTarget.Workspace); - await clangdConfiguration.update("arguments", plan.arguments, vscode.ConfigurationTarget.Workspace); - } - if (interactive) { - await maybeDisableCppTools(context.project); - } - const restartRequired = shouldRestartClangd(plan.changed, interactive, forceRestart); - const restartSucceeded = restartRequired ? await restartClangd() : false; - if (!configurationReadyAfterRestart(restartRequired, restartSucceeded)) { - const message = "clangd 配置已写入,但无法重启语言服务器。请查看 mcpp 输出频道,或手动执行 clangd 重启命令。"; - if (interactive) { - await vscode.window.showErrorMessage(message); - } else { - appendOutputLine(output, `[自动配置] ${message}`); - } - return false; - } - updateStatusBar(status, context); - - if (!clangd.comparison.compatible) { - await vscode.window.showWarningMessage( - `clangd 已配置,但 LLVM 身份与 mcpp 编译器不匹配:${clangd.comparison.reason}。`, - ); - } else if (interactive) { - await vscode.window.showInformationMessage("mcpp 已为当前工作区配置匹配的 clangd。"); - } else if (plan.changed) { - appendOutputLine(output, `[自动配置] clangd.path = ${plan.path}`); - } - - return true; -} - -function renderModuleStatus(status: vscode.StatusBarItem, moduleStatus: ModuleStatus): void { - status.command = COMMAND_CHECK; - status.show(); - status.text = moduleStatus.state === "available" - ? "$(pass) mcpp: 模块可用" - : "$(warning) mcpp: 模块不可用"; - status.tooltip = moduleStatus.message; -} - -function invalidateModuleStatus(projectRoot: string): void { - moduleCheckOperations.invalidate(projectRoot); - moduleStatusByProject.delete(projectRoot); -} - -function storeModuleStatus( - status: vscode.StatusBarItem, - context: ProjectContext, - moduleStatus: ModuleStatus, - checkToken?: number, -): boolean { - if ( - checkToken !== undefined - && !moduleCheckOperations.isCurrent(context.project.root, checkToken) - ) { - return false; - } - moduleStatusByProject.set(context.project.root, moduleStatus); - if (shouldRenderProjectStatus(findCurrentProject()?.root, context.project.root)) { - renderModuleStatus(status, moduleStatus); - } - return true; -} - -function updateStatusBar(status: vscode.StatusBarItem, context?: ProjectContext): void { - const currentProjectRoot = findCurrentProject()?.root; - if (context === undefined) { - if (currentProjectRoot === undefined) { - status.hide(); - } - return; - } - - if (context.analysis.capability !== "full") { - invalidateModuleStatus(context.project.root); - } - if (!shouldRenderProjectStatus(currentProjectRoot, context.project.root)) { - return; - } - - status.command = statusCommandForCapability(context.analysis.capability); - status.show(); - if (context.analysis.capability === "full") { - const moduleStatus = moduleStatusByProject.get(context.project.root); - if (moduleStatus !== undefined) { - renderModuleStatus(status, moduleStatus); - return; - } - status.text = "$(symbol-interface) mcpp: LLVM 模块"; - status.tooltip = "正在等待 clangd 检查模块编译命令和 PCM。"; - return; - } - if (context.analysis.capability === "syntax-only") { - status.text = `$(info) mcpp: 仅${context.analysis.kind.toUpperCase()}语法`; - status.tooltip = `${context.analysis.reason} 模块语法高亮仍然可用。`; - return; - } - - status.text = "$(warning) mcpp: 缺少模块 CDB"; - status.tooltip = context.analysis.reason; -} - -function checkResultMessage(classification: CheckResult): string { - switch (classification) { - case "ready": - return "clangd 已加载 mcpp 编译命令和模块产物。"; - case "pcm-mismatch": - return "clangd 与 mcpp 编译器或 PCM 来自不同的 LLVM 构建。建议运行“一键配置模块代码提示”安装匹配的 llvm-tools,或设置 mcpp.clangd.path。"; - case "module-unavailable": - return "clangd 无法加载所需模块产物,请运行“mcpp: 刷新编译数据库”,并检查 CDB 中的 PCM 路径。"; - case "wrong-language-mode": - return "clangd 没有为当前文件读取 C++20 或更高版本的编译命令。"; - default: - return "clangd 模块检查失败,完整诊断请查看 mcpp 输出频道。"; - } -} - -async function runModuleSupportCheck( - context: ProjectContext, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, - mode: ConfigureMode, -): Promise { - const interactive = mode === "interactive"; - if (context.analysis.capability === "syntax-only") { - const message = `${context.analysis.kind.toUpperCase()} 模块产物不能由 clangd 消费,模块代码提示不可用。语法高亮仍然可用。`; - updateStatusBar(status, context); - if (interactive) { - const configure = "一键配置"; - const choice = await vscode.window.showWarningMessage( - `${message}\n\n如需启用模块代码提示,请切换到 LLVM 工具链后重新构建。`, - configure, - "关闭", - ); - if (choice === configure) { - await vscode.commands.executeCommand(CLI_COMMANDS.autoConfigureModules); +function resultText(output: vscode.OutputChannel, result: LanguageServerCommandResult): void { + const formatted = formatResult(result); + outputText(output, `[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); + if (result.state === "unavailable") { + const install = t("Install extension"); + void vscode.window.showWarningMessage(formatted.message, install).then((choice) => { + if (choice === install) { + void vscode.commands.executeCommand("workbench.extensions.search", `@id:${MCPPLS_EXTENSION_ID}`); } - } - return undefined; - } - if ( - context.analysis.capability !== "full" - || context.analysis.compilerPath === undefined - || context.analysis.sourceFile === undefined - ) { - invalidateModuleStatus(context.project.root); - const message = context.analysis.capability === "full" - ? "compile_commands.json 没有可供 clangd 检查的源文件。" - : context.analysis.reason; - if (context.analysis.capability === "full") { - storeModuleStatus(status, context, { state: "unavailable", message }); - } - if (interactive) { - await vscode.window.showWarningMessage(message); - } - return context.analysis.capability === "full" - ? { state: "unavailable", message } - : undefined; - } - - if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { - invalidateModuleStatus(context.project.root); - const message = "当前工作区未受信任,不会执行 clangd 模块检查。信任工作区后扩展会自动重新检查。"; - const moduleStatus = { state: "unavailable", message } as const; - storeModuleStatus(status, context, moduleStatus); - if (interactive) { - await vscode.window.showWarningMessage(message); - } else { - appendOutputLine(output, `[自动检查] ${message}`); - } - return moduleStatus; - } - - const checkToken = moduleCheckOperations.begin(context.project.root); - moduleStatusByProject.delete(context.project.root); - if (shouldRenderProjectStatus(findCurrentProject()?.root, context.project.root)) { - status.command = COMMAND_CHECK; - status.text = "$(sync~spin) mcpp: 正在检查模块"; - status.tooltip = "正在使用 clangd 检查模块编译命令和 PCM。"; - status.show(); - } - - const clangd = await resolveClangd(context); - if (clangd === undefined) { - const message = "当前 LLVM 工具链没有找到可用的 clangd。请设置 mcpp.clangd.path。"; - const moduleStatus = { state: "unavailable", message } as const; - if (!storeModuleStatus(status, context, moduleStatus, checkToken)) { - return undefined; - } - if (interactive) { - await vscode.window.showErrorMessage(message); - } else { - appendOutputLine(output, `[自动检查] ${message}`); - } - return moduleStatus; - } - - const arguments_ = buildClangdArguments( - officialClangdConfiguration(context.project).get("arguments", []), - { - compilerPath: context.analysis.compilerPath, - compilationArguments: context.analysis.arguments, - modulesSupport: projectConfiguration(context.project) - .get("modulesSupport", "auto"), - clangdIdentity: clangd.version.identity, - platform: process.platform, - hasPrebuiltModules: context.analysis.hasPrebuiltModules, - workspaceFolder: context.project.root, - }, - ); - const result = await runClangdCheck( - clangd.path, - context.analysis.sourceFile, - context.project.root, - arguments_, - ); - appendProcessOutput( - output, - interactive ? "检查模块支持" : "自动检查模块支持", - clangd.path, - [`--check=${context.analysis.sourceFile}`, ...arguments_], - result, - ); - if (interactive) { - output.show(true); - } - - const message = checkResultMessage(result.classification); - const moduleStatus = { - state: moduleSupportState(result.classification), - message, - }; - if (!storeModuleStatus(status, context, moduleStatus, checkToken)) { - return undefined; + }); + return; } - if (interactive) { - if (moduleStatus.state === "available") { - await vscode.window.showInformationMessage(message); - } else { - await vscode.window.showErrorMessage(message); - } - } else { - appendOutputLine(output, `[自动检查] ${message}`); + if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } else if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); } - return moduleStatus; } -async function updateModuleSupportForContext( - context: ProjectContext, - configured: boolean, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, -): Promise { - if (shouldCheckModuleSupport( - context.analysis.capability, - configured, - context.analysis.sourceFile, - )) { - const moduleStatus = await runModuleSupportCheck(context, status, output, "automatic"); - return moduleStatus?.state === "available"; - } - - if (context.analysis.capability === "full") { - invalidateModuleStatus(context.project.root); - const message = configured - ? "compile_commands.json 没有可供 clangd 检查的源文件。" - : "clangd 未完成配置,模块语义当前不可用。详情请查看 mcpp 输出频道。"; - storeModuleStatus(status, context, { state: "unavailable", message }); +function moduleSetupBlockedMessage(reason: Extract["reason"]): string { + switch (reason) { + case "untrusted": + return t("This workspace is not trusted, so mcpp will not run and the C++ Modules language service will not refresh. Trust the workspace first."); + case "busy": + return t("An mcpp operation is already running; wait for it to finish and try again."); } - return false; } -async function executeXlingsInstallTask( - xlingsPath: string, - args: string[], - cwd: string, -): Promise { - const task = new vscode.Task( - { type: "mcpp-xlings", command: args[0] ?? "xlings" }, - vscode.TaskScope.Workspace, - "mcpp: 安装 llvm-tools", - "mcpp", - new vscode.ProcessExecution(xlingsPath, args, { cwd }), - ); - task.presentationOptions = { - reveal: vscode.TaskRevealKind.Always, - panel: vscode.TaskPanelKind.Dedicated, - focus: true, - clear: true, - showReuseMessage: false, - }; - - let execution: vscode.TaskExecution | undefined; - let earlyCompletion: TaskCompletion | undefined; - let settled = false; - let processEndSubscription: vscode.Disposable | undefined; - let taskEndSubscription: vscode.Disposable | undefined; - const disposeListeners = (): void => { - processEndSubscription?.dispose(); - taskEndSubscription?.dispose(); - }; - const finish = (completion: TaskCompletion): void => { - if (settled) { - return; - } - settled = true; - disposeListeners(); - resolveCompletion?.(completion); - }; - let resolveCompletion: ((completion: TaskCompletion) => void) | undefined; - - const completion = new Promise((resolve) => { - resolveCompletion = resolve; - processEndSubscription = vscode.tasks.onDidEndTaskProcess((event) => { - if (event.execution.task !== task) { - return; - } - const classified = classifyTaskExit(event.exitCode); - if (execution === undefined) { - earlyCompletion ??= classified; - return; - } - finish(classified); - }); - taskEndSubscription = vscode.tasks.onDidEndTask((event) => { - if (event.execution.task !== task) { - return; - } - const classified = classifyTaskExit(undefined); - if (execution === undefined) { - earlyCompletion ??= classified; - return; - } - finish(classified); - }); - }); - - try { - execution = await vscode.tasks.executeTask(task); - } catch (error) { - disposeListeners(); - throw error; +function taskCompletionText(completion: TaskCompletion): string { + if (completion.state === "cancelled") { + return t("The build was cancelled; the C++ Modules language service was not refreshed."); } - if (earlyCompletion !== undefined) { - finish(earlyCompletion); + if (completion.state === "failed") { + return t("The mcpp build failed (exit code {0}); the C++ Modules language service re-read the existing build description.", completion.exitCode ?? t("unknown")); } - return completion; + return t("The mcpp build finished; the C++ Modules language service has been refreshed."); } async function autoConfigureModulesWizard( - context: ProjectContext, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, + bridge: LanguageServerBridge, cliController: McppCliController, + output: vscode.OutputChannel, ): Promise { - appendOutputLine(output, "[一键配置] 开始一键配置模块代码提示..."); - if (!vscode.workspace.isTrusted) { await vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); return; } - const inventory = await cliController.readToolchainInventory(context.project); - if (inventory === undefined) { + if (cliController.isBusy()) { + await vscode.window.showWarningMessage(moduleSetupBlockedMessage("busy")); return; } - const decision = buildModuleSetupPlan( - inventory, - context.analysis.capability, - vscode.workspace.isTrusted, - cliController.isBusy(), - ); + + const decision = buildModuleSetupPlan(true, false); if (decision.kind === "blocked") { await vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); return; } - - const confirmation = moduleSetupConfirmation(context.analysis.capability, decision); + const confirmation = moduleSetupConfirmation(); + // One definition: the label is shown *and* the reply is compared against it. + const confirmLabel = t("Confirm one-click setup"); const choice = await vscode.window.showWarningMessage( confirmation.message, { modal: true, detail: confirmation.detail }, - "确认一键配置", + confirmLabel, ); - if (choice !== "确认一键配置") { + if (choice !== confirmLabel) { return; } - let currentContext = context; const outcome = await executeModuleSetup(decision, { - preparePlan: () => cliController.runAutomaticModuleSetup(decision), - reload: async (): Promise => { - const refreshed = loadProjectContext(context.project); - if (refreshed === undefined) { - return { stage: "reload", state: "failed", detail: "无法重新加载 mcpp 工程。" }; - } - currentContext = refreshed; - updateStatusBar(status, currentContext); - if (currentContext.analysis.capability !== "full" || currentContext.analysis.kind !== "llvm") { - return { - stage: "reload", - state: "failed", - detail: "构建后没有得到可供 clangd 使用的 LLVM 编译数据库。", - }; - } - return { stage: "reload", state: "succeeded" }; - }, - ensureClangd: async (): Promise => { - const clangd = await resolveClangd(currentContext); - if (clangd?.comparison.compatible) { - const configured = await configureClangd(currentContext, status, output, "automatic", true); - return configured - ? { stage: "clangd", state: "succeeded" } - : { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" }; - } - - const xlingsPath = findXlingsExecutable(); - const compilerPath = currentContext.analysis.compilerPath; - if (xlingsPath === undefined || compilerPath === undefined) { - return { - stage: "clangd", - state: "failed", - detail: "未找到 xlings 或 LLVM 编译器,无法安装匹配的 llvm-tools。", - }; - } - const compilerVersion = await runToolVersion(compilerPath); - const installArgs = xlingsInstallArgs( - compilerVersion.identity === undefined ? undefined : llvmToolsVersionSpec(compilerVersion.identity), - ); - const installed = await executeXlingsInstallTask(xlingsPath, installArgs, currentContext.project.root); - appendOutputLine( - output, - `[一键配置] xlings install 完成(退出码 ${installed.exitCode ?? "未知"})`, - ); - if (installed.state !== "succeeded") { + build: async (): Promise => { + const completion = await cliController.runProjectTask("build", { notify: false }); + if (completion === undefined) { return { - stage: "clangd", - state: installed.state, - exitCode: installed.exitCode, - detail: installed.state === "cancelled" ? "llvm-tools 安装已取消。" : "llvm-tools 安装失败。", + stage: "build", + state: "not-started", + detail: t("mcpp build was not started."), }; } - - const refreshed = loadProjectContext(currentContext.project); - if (refreshed === undefined) { - return { stage: "clangd", state: "failed", detail: "安装 llvm-tools 后无法重新加载工程。" }; - } - currentContext = refreshed; - updateStatusBar(status, currentContext); - const resolved = await resolveClangd(currentContext); - if (resolved === undefined || !resolved.comparison.compatible) { - return { stage: "clangd", state: "failed", detail: "未找到与 LLVM 编译器匹配的 clangd。" }; - } - const configured = await configureClangd(currentContext, status, output, "automatic", true); - return configured - ? { stage: "clangd", state: "succeeded" } - : { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" }; + return { + stage: "build", + state: completion.state, + exitCode: completion.exitCode, + }; }, - checkModules: async (): Promise => { - const moduleStatus = await runModuleSupportCheck(currentContext, status, output, "automatic"); - return moduleStatus?.state === "available" - ? { stage: "check", state: "succeeded" } - : { stage: "check", state: "failed", detail: "模块支持检查未通过。" }; + refreshLanguageServer: async (): Promise => { + const result = await bridge.refreshLanguageServerAfterBuild(); + resultText(output, result); + return { + stage: "language-server", + state: result.state === "completed" ? "succeeded" : "failed", + detail: result.state === "completed" ? undefined : formatResult(result).message, + }; }, }); + const buildStep = outcome.steps[0]; if (outcome.state === "succeeded") { - if (outcome.degraded) { - await vscode.window.showWarningMessage("构建失败,语言服务已刷新。请查看任务终端获取构建错误。"); - } else { - await vscode.window.showInformationMessage("mcpp 模块代码提示一键配置完成。"); - } + await vscode.window.showInformationMessage( + taskCompletionText(buildStep?.state === "failed" ? { state: "failed", exitCode: buildStep.exitCode } : { state: "succeeded" }), + ); } else if (outcome.state === "cancelled") { - await vscode.window.showWarningMessage(`一键配置已取消(${outcome.stage})。`); + await vscode.window.showWarningMessage(taskCompletionText({ state: "cancelled" })); } else { - await vscode.window.showErrorMessage(`一键配置失败(${outcome.stage})。${outcome.steps.at(-1)?.detail ?? "请查看 mcpp 输出频道。"}`); + await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? t("Configuring the C++ Modules language service failed.")); } } export async function activate(extensionContext: vscode.ExtensionContext): Promise { - moduleStatusByProject.clear(); - moduleCheckOperations.clear(); const output = vscode.window.createOutputChannel("mcpp"); - const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); - const refreshStatus = (): void => updateStatusBar(status, loadProjectContext()); + // Language first: everything below may want to speak to the user. + applyLanguagePreference(); + extensionContext.subscriptions.push(onConfigurationChanged(applyLanguagePreference)); + + const bridge = createLanguageServerBridge({ + extensionInstalled: (id) => vscode.extensions.getExtension(id) !== undefined, + declaredCommands: declaredCommandsOf, + activateExtension: async (id) => { + await vscode.extensions.getExtension(id)?.activate(); + }, + executeCommand: (command: string, ...args: unknown[]) => vscode.commands.executeCommand(command, ...args), + }); const runGuarded = (operation: () => Promise): (() => Promise) => async () => { try { await operation(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `发生未预期错误:${message}`); - void vscode.window.showErrorMessage(`mcpp:${message}`); + outputText(output, t("Unexpected error: {0}", message)); + void vscode.window.showErrorMessage(t("mcpp: {0}", message)); } }; + const invokeLanguageServer = ( + operation: () => Promise, + ): (() => Promise) => runGuarded(async () => { + resultText(output, await operation()); + }); + const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); - const compilationDatabaseWatcher = vscode.workspace.createFileSystemWatcher("**/compile_commands.json"); const inProjectContext = registerInProjectContext({ currentProject: findCurrentProject, setContextValue: (key, value) => vscode.commands.executeCommand("setContext", key, value), @@ -853,348 +259,367 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi manifestWatcher.onDidDelete(listener), ], }); - const executeWithWorkspaceClangd = createSerialExecutor(); - const reconcileProjectContext = async ( - project: McppProjectDiscovery | undefined, - forceRestart: boolean, - ): Promise => { - const context = loadProjectContext(project); - updateStatusBar(status, context); - if (context === undefined) { - return { - context, - databaseFound: false, - configured: false, - }; - } - const configured = await configureClangd( - context, - status, - output, - "automatic", - forceRestart, - ); - await updateModuleSupportForContext(context, configured, status, output); - return { - context, - databaseFound: existsSync(context.project.compilationDatabasePath), - configured, - }; - }; - const reconcileProjectByRoot = createKeyedSingleFlightReconciler( - (projectRoot: string, forceRestart) => executeWithWorkspaceClangd( - async () => { - const project = findNearestMcppProject(projectRoot); - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, projectRoot)) { - const context = loadProjectContext(project); - return { - context, - databaseFound: context !== undefined - && existsSync(context.project.compilationDatabasePath), - configured: false, - }; - } - return reconcileProjectContext(project, forceRestart); - }, - ), - ); - const reconcileProject = ( - project: McppProjectDiscovery, - forceRestart: boolean = false, - ): Promise => { - invalidateModuleStatus(project.root); - return reconcileProjectByRoot(project.root, forceRestart); - }; - const requestAutomaticReconciliation = ( - compilationDatabase: vscode.Uri, - forceRestart: boolean, - ): void => { - const project = findNearestMcppProject(compilationDatabase.fsPath); - if (project === undefined) { + const afterProjectTask = async ( + _project: McppProjectDiscovery, + _kind: "build" | "run" | "test" | "clean", + completion: TaskCompletion, + ): Promise => { + if (completion.state === "cancelled") { return; } - invalidateModuleStatus(project.root); - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { + const mode = read("mcpp.languageService.refreshAfterBuild"); + if (mode === "off") { + lastRefresh = { at: new Date().toISOString(), state: "skipped (mcpp.languageService.refreshAfterBuild = off)" }; return; } - void reconcileProject(project, forceRestart).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); - }); - }; - const requestCurrentProjectReconciliation = (forceRestart: boolean): void => { - const project = findCurrentProject(); - if (project === undefined) { - refreshStatus(); - return; + const result = + mode === "restart" ? await bridge.restartLanguageServer() : await bridge.refreshLanguageServerAfterBuild(); + lastRefresh = { at: new Date().toISOString(), state: result.state, command: result.command }; + resultText(output, result); + if (completion.state === "succeeded" && result.state === "completed") { + await vscode.window.showInformationMessage(taskCompletionText(completion)); + } else if (completion.state === "failed" && result.state === "completed") { + await vscode.window.showWarningMessage(taskCompletionText(completion)); } - void reconcileProject(project, forceRestart).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); + }; + + const cliController = new McppCliController({ + output, + extensionUri: extensionContext.extensionUri, + currentProject: findCurrentProject, + afterProjectTask, + isTrusted: () => vscode.workspace.isTrusted, + languageServerSummary: () => { + const view = readLanguageServerState(); + return view.available ? view.state : undefined; + }, + }); + + // ── views ──────────────────────────────────────────────────────────────── + // Each view owns its own tree provider and its own commands; the project view + // reads the manifest, the cache view runs mcpp's read-only queries, and the + // C++ Modules view only forwards to mcppls. + const applyViewVisibility = (): void => { + // Literal keys, so the wiring gate can see them. + // `mcpp.views.enabled` is the master switch: with it off every view is hidden. + // Whether VS Code also drops the container's icon from the activity bar is + // up to VS Code itself — an extension cannot mark its own container + // hidden-when-empty (the setting's description says the same). The `when` + // clauses in package.json are negated (`!mcpp.sidebarHidden`) so the default + // state is visible — otherwise activation would never run to set the key back. + void vscode.commands.executeCommand("setContext", "mcpp.sidebarHidden", !read("mcpp.views.enabled")); + void vscode.commands.executeCommand("setContext", "mcpp.views.project", read("mcpp.views.project.show")); + void vscode.commands.executeCommand("setContext", "mcpp.views.library", read("mcpp.views.library.show")); + void vscode.commands.executeCommand("setContext", "mcpp.views.cache", read("mcpp.views.cache.show")); + }; + applyViewVisibility(); + extensionContext.subscriptions.push(onConfigurationChanged(applyViewVisibility)); + + // The language service is a block inside the project view, so its state source has + // to exist before the view is registered. + const languageService = registerLanguageServerCommands(extensionContext, { bridge, output }); + registerProjectView(extensionContext, { currentProject: findCurrentProject, languageService }); + // The library ecosystem: a sidebar view plus an editor-area package page. The + // detail page writes `mcpp.toml` through `mcpp add`, so it tells the view to + // refresh — no save event fires for a file written outside the editor. + // Keep the selected library project when an editor-area webview takes focus. + let libraryProjectRoot: string | undefined = vscode.window.activeTextEditor?.document.uri.scheme === "file" + || vscode.workspace.workspaceFolders?.length === 1 ? findCurrentProject()?.root : undefined; + const libraryProject = (): string | undefined => libraryProjectRoot; + const chooseLibraryProject = async (): Promise => { + const manifests = await vscode.workspace.findFiles(MCPP_MANIFEST_GLOB, "**/{node_modules,target,.git}/**", 200); + const roots = [...new Set(manifests.map((uri) => uri.fsPath.slice(0, -"/mcpp.toml".length)))]; + const active = libraryProject(); + if (active && !roots.includes(active)) { roots.unshift(active); } + const selected = await vscode.window.showQuickPick(roots.map((root) => ({ label: vscode.workspace.asRelativePath(root), description: root, root })), { + title: t("Choose the target mcpp project"), placeHolder: t("Dependencies will be added to this project's mcpp.toml"), }); + if (selected) { libraryProjectRoot = selected.root; watchSelectedLibraryProject(); refreshLibraries(); } + return selected?.root; }; - const configurationWatcher = vscode.workspace.onDidChangeConfiguration((event) => { - const project = findCurrentProject(); - if (project === undefined) { - return; - } - const uri = vscode.Uri.file(project.root); - if (configurationAffectsModuleSupport( - (section) => event.affectsConfiguration(section, uri), - )) { - requestCurrentProjectReconciliation(true); - } + const openLibraryDetail = createLibraryDetailOpener(extensionContext, { + mcppExecutable: (root) => cliController.mcppExecutable(root ? { root, manifestPath: `${root}/mcpp.toml` } : undefined), + projectRoot: libraryProject, + chooseProject: chooseLibraryProject, + onPackageOpened: (key) => library.select(key), + output, + isTrusted: () => vscode.workspace.isTrusted, + onDependenciesChanged: () => { void library.refresh(); }, + onProjectFilesRead: (root, manifest, lock) => { if (root === libraryProjectRoot) { selectedLibraryPoll?.observe(manifest, lock); } }, }); - const trustWatcher = vscode.workspace.onDidGrantWorkspaceTrust(() => { - requestCurrentProjectReconciliation(true); + const library = registerLibraryView(extensionContext, { + projectRoot: libraryProject, + mcppExecutable: () => cliController.mcppExecutable(libraryProjectRoot ? { root: libraryProjectRoot, manifestPath: `${libraryProjectRoot}/mcpp.toml` } : undefined), + openDetail: openLibraryDetail, + output, + isTrusted: () => vscode.workspace.isTrusted, }); - - const afterProjectTask = async ( - project: McppProjectDiscovery, - kind: "build" | "run" | "test" | "clean", - completion: { state: "succeeded" | "failed" | "cancelled"; exitCode?: number }, - ): Promise => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - invalidateModuleStatus(project.root); - refreshStatus(); - return; - } - - const reconciled = await reconcileProject(project, true); - if (kind === "build") { - if (reconciled.context?.analysis.capability === "syntax-only") { - const buildMessage = completion.state === "succeeded" ? "mcpp 构建完成" : "mcpp 构建失败"; - await vscode.window.showWarningMessage( - `${buildMessage};${reconciled.context.analysis.reason}。模块语法高亮仍然可用。`, - ); + const dependencyWatcher = vscode.workspace.createFileSystemWatcher("**/mcpp.{toml,lock}"); + let selectedLibraryWatcher: vscode.Disposable | undefined; + let selectedLibraryPoll: ReturnType | undefined; + let libraryTimer: NodeJS.Timeout | undefined; + const refreshLibraries = (): void => { + void selectedLibraryPoll?.check(); + if (libraryTimer) { clearTimeout(libraryTimer); } + libraryTimer = setTimeout(() => { + libraryTimer = undefined; + void library.refresh(); + void openLibraryDetail.refresh(); + }, 120); + }; + // A non-recursive RelativePattern also observes scratch/gitignored projects + // which a workspace's recursive watcher may intentionally exclude. + function watchSelectedLibraryProject(): void { + selectedLibraryWatcher?.dispose(); + selectedLibraryWatcher = undefined; + selectedLibraryPoll = undefined; + if (!libraryProjectRoot) { return; } + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(vscode.Uri.file(libraryProjectRoot), "mcpp.{toml,lock}")); + selectedLibraryPoll = watchProjectFiles(libraryProjectRoot, refreshLibraries); + selectedLibraryWatcher = vscode.Disposable.from(selectedLibraryPoll, watcher, watcher.onDidCreate(refreshLibraries), watcher.onDidChange(refreshLibraries), watcher.onDidDelete(refreshLibraries)); + } + watchSelectedLibraryProject(); + extensionContext.subscriptions.push( + { dispose: () => selectedLibraryWatcher?.dispose() }, + dependencyWatcher, + dependencyWatcher.onDidCreate(refreshLibraries), + dependencyWatcher.onDidChange(refreshLibraries), + dependencyWatcher.onDidDelete(refreshLibraries), + vscode.window.onDidChangeActiveTextEditor((editor) => { if (editor?.document.uri.scheme === "file") { const active = findCurrentProject()?.root; if (active && active !== libraryProjectRoot) { libraryProjectRoot = active; watchSelectedLibraryProject(); } refreshLibraries(); } }), + vscode.workspace.onDidChangeWorkspaceFolders(() => { libraryProjectRoot = undefined; watchSelectedLibraryProject(); refreshLibraries(); }), + vscode.workspace.onDidGrantWorkspaceTrust(refreshLibraries), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.library") || event.affectsConfiguration("mcpp.path") || event.affectsConfiguration("mcpp.ui.language")) { refreshLibraries(); } + }), + { dispose: () => { if (libraryTimer) { clearTimeout(libraryTimer); } } }, + ); + // `registerLibraryView` registers its own webview provider; these are only the + // entry points a menu or the project view can name. + extensionContext.subscriptions.push( + vscode.commands.registerCommand(LIBRARY_COMMANDS.search, async () => { + await vscode.commands.executeCommand("mcpp.library.focus"); + }), + vscode.commands.registerCommand(LIBRARY_COMMANDS.openDetail, async (id: unknown) => { + if (typeof id === "string" && id.length > 0) { + await openLibraryDetail(id); + } + }), + vscode.commands.registerCommand(LIBRARY_COMMANDS.updateIndex, async () => { + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage(t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first.")); return; } - const outcome = describeRefreshOutcome( - completion.exitCode ?? 1, - reconciled.databaseFound, - reconciled.configured, + const root = libraryProject(); + const project = root ? { root, manifestPath: `${root}/mcpp.toml` } : undefined; + if (read("mcpp.library.indexPath").trim()) { + output.appendLine(t("Updating the global mcpp index; a configured custom checkout is only reread.")); + } + library.setUpdateStatus(t("Updating mcpp index…")); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t("Refreshing the mcpp Package Index…") }, + () => runMcpp(vscode.workspace.isTrusted, cliController.mcppExecutable(project), ["index", "update"], project?.root, { timeoutMs: 300_000 }), ); - if (outcome.level === "information") { - await vscode.window.showInformationMessage(outcome.message); - } else if (outcome.level === "warning") { - await vscode.window.showWarningMessage(outcome.message); - } else { - await vscode.window.showErrorMessage(outcome.message); + if (result === undefined || result.exitCode !== 0) { + library.setUpdateStatus(t("Index update failed; showing the last available local snapshot.")); + if (result?.stderr) { output.appendLine(result.stderr.trimEnd()); } + void vscode.window.showErrorMessage(t("mcpp index update failed with exit code {0}", result?.exitCode ?? -1)); + return; } - return; - } - - if (completion.state === "succeeded") { - const label = kind === "run" ? "运行" : kind === "test" ? "测试" : "清理"; - await vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); - } - }; - const cliController = new McppCliController({ + invalidateIndexCache(); + await library.refresh(); + await openLibraryDetail.refresh(); + const snapshot = await loadSnapshot({ projectRoot: root }); + library.setUpdateStatus(t("Index updated; {0} local packages.", snapshot.entries.length)); + }), + ); + // There is no `mcpp.library.indexFound` context key and no `viewsWelcome` + // entry for the library view. `paneview.ts`'s base `ViewPane` answers + // `shouldShowWelcome() === false`; only the tree-shaped panes override it, so + // welcome content can never render inside a `"type": "webview"` view. The + // library view states its own empty case in the document instead, which is the + // only place VS Code will show it. + + registerCacheView(extensionContext, { output, currentProject: findCurrentProject, - afterProjectTask, + mcppExecutable: (project) => cliController.mcppExecutable(project), isTrusted: () => vscode.workspace.isTrusted, }); + registerSettingsPanel(extensionContext); + + // Text analysis only: no mcpp process, so both stay available in a restricted + // workspace, which is exactly what the `limited` capability promises. + registerTomlProviders( + extensionContext, + () => read("mcpp.toml.completion"), + () => ({ + syntax: read("mcpp.toml.diagnostics.syntax"), + unknownSection: read("mcpp.toml.diagnostics.unknownSection"), + unknownKey: read("mcpp.toml.diagnostics.unknownKey"), + planeSeparation: read("mcpp.toml.diagnostics.planeSeparation"), + legacyKeys: read("mcpp.toml.diagnostics.legacyKeys"), + }), + () => read("mcpp.toml.diagnostics.enabled"), + () => read("mcpp.toml.hover"), + () => read("mcpp.toml.navigation"), + ); + registerBuildScriptProviders(extensionContext, () => { + if (!read("mcpp.buildScript.intelligence") || !read("mcpp.buildScript.diagnostics")) { + return "off"; + } + return read<"warning" | "info" | "off">("mcpp.buildScript.diagnostics.severity"); + }, languageServerServes); extensionContext.subscriptions.push( output, - status, + manifestWatcher, + inProjectContext, ...cliController.register(), - vscode.commands.registerCommand(COMMAND_CONFIGURE, runGuarded(async () => { + + // The ids from 0.4.x keep working. + vscode.commands.registerCommand(CLI_COMMANDS.autoConfigureModules, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); + await vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); return; } - await executeWithWorkspaceClangd(async () => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - const context = loadProjectContext(project); - if (context === undefined) { - return; - } - const configured = await configureClangd(context, status, output, "interactive"); - await updateModuleSupportForContext(context, configured, status, output); - }); + await autoConfigureModulesWizard(bridge, cliController, output); })), - vscode.commands.registerCommand(COMMAND_REFRESH, runGuarded(async () => { + vscode.commands.registerCommand(LEGACY_LANGUAGE_SERVER_COMMANDS.refreshCompilationDatabase, runGuarded(async () => { await cliController.runProjectTask("build"); })), - vscode.commands.registerCommand(COMMAND_CHECK, runGuarded(async () => { - const project = findCurrentProject(); - if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); - return; - } - await executeWithWorkspaceClangd(async () => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - const ctx = loadProjectContext(project); - if (ctx === undefined) { - return; - } - - // When module support is already known to be unavailable, offer actionable choices - const currentStatus = moduleStatusByProject.get(ctx.project.root); - if (currentStatus?.state === "unavailable" && ctx.analysis.capability === "full") { - interface StatusActionItem extends vscode.QuickPickItem { - action: "auto-configure" | "recheck" | "rebuild"; - } - const choice = await vscode.window.showQuickPick([ - { - label: "$(rocket) 一键配置模块代码提示", - description: "自动安装匹配的 llvm-tools 并配置 clangd", - action: "auto-configure", - }, - { - label: "$(check) 重新检查模块支持", - description: "再次运行 clangd 模块检查", - action: "recheck", - }, - { - label: "$(database) 刷新编译数据库", - description: "重新生成 compile_commands.json", - action: "rebuild", - }, - ], { - title: "mcpp 模块代码提示不可用", - placeHolder: "选择操作以修复模块代码提示", - }); - if (choice === undefined) { - return; - } - switch (choice.action) { - case "auto-configure": - await autoConfigureModulesWizard(ctx, status, output, cliController); - return; - case "recheck": - await runModuleSupportCheck(ctx, status, output, "interactive"); - return; - case "rebuild": - await cliController.runProjectTask("build"); - return; - } - } - - // QuickPick for syntax-only (GCC/MSVC) projects — no module code intelligence available - if (ctx.analysis.capability === "syntax-only") { - interface SyntaxOnlyActionItem extends vscode.QuickPickItem { - action: "auto-configure" | "install-toolchain" | "select-default"; - } - const kindLabel = ctx.analysis.kind.toUpperCase(); - const choice = await vscode.window.showQuickPick([ - { - label: "$(rocket) 一键配置模块代码提示", - description: `当前为 ${kindLabel} 工具链,查看如何切换到 LLVM`, - action: "auto-configure", - }, - { - label: "$(cloud-download) 安装 LLVM 工具链", - description: "通过 mcpp 下载并安装 LLVM 工具链", - action: "install-toolchain", - }, - { - label: "$(settings-gear) 选择全局默认工具链", - description: "从已安装的工具链中选择默认", - action: "select-default", - }, - ], { - title: `mcpp: 当前为 ${kindLabel} 工具链,模块代码提示不可用`, - placeHolder: "切换到 LLVM 工具链以启用模块代码提示", - }); - if (choice === undefined) { - return; - } - switch (choice.action) { - case "auto-configure": - await autoConfigureModulesWizard(ctx, status, output, cliController); - return; - case "install-toolchain": - await vscode.commands.executeCommand(CLI_COMMANDS.installToolchain); - return; - case "select-default": - await vscode.commands.executeCommand(CLI_COMMANDS.selectDefaultToolchain); - return; - } - } - // Original flow: run module check - await runModuleSupportCheck(ctx, status, output, "interactive"); - }); + // Environment self-check: one copyable snapshot of everything a bug report needs. + vscode.commands.registerCommand(TOOL_COMMANDS.selfCheck, runGuarded(async () => { + await showSelfCheck(output, cliController, bridge, extensionContext.extension.packageJSON.version); })), - vscode.commands.registerCommand("mcpp.autoConfigureModules", runGuarded(async () => { - const project = findCurrentProject(); - if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); - return; - } - await executeWithWorkspaceClangd(async () => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - const ctx = loadProjectContext(project); - if (ctx !== undefined) { - await autoConfigureModulesWizard(ctx, status, output, cliController); - } - }); - })), - configurationWatcher, - trustWatcher, - inProjectContext, - vscode.window.onDidChangeActiveTextEditor(() => { - refreshStatus(); - const current = findCurrentProject(); - if (current !== undefined && current.root !== lastReconciledProjectRoot) { - lastReconciledProjectRoot = current.root; - requestCurrentProjectReconciliation(false); - } - }), - vscode.workspace.onDidChangeWorkspaceFolders(() => { - refreshStatus(); - lastReconciledProjectRoot = undefined; - requestCurrentProjectReconciliation(true); - }), - manifestWatcher, - compilationDatabaseWatcher, + + vscode.workspace.onDidGrantWorkspaceTrust(() => cliController.refreshStatus()), ); + void offerSettingRenames(extensionContext); + void noteUnverifiedLanguageService(output); + + if (read("mcpp.diagnostics.selfCheckOnStartup")) { + void showSelfCheck(output, cliController, bridge, extensionContext.extension.packageJSON.version); + } + extensionContext.subscriptions.push( - manifestWatcher.onDidCreate(() => { - refreshStatus(); - cliController.refreshStatus(); - }), - manifestWatcher.onDidChange(() => { - refreshStatus(); - cliController.refreshStatus(); - }), - manifestWatcher.onDidDelete(() => { - refreshStatus(); - cliController.refreshStatus(); - }), - ...registerCompilationDatabaseReconciliation( - compilationDatabaseWatcher, - requestAutomaticReconciliation, - ), + manifestWatcher.onDidCreate(() => cliController.refreshStatus()), + manifestWatcher.onDidChange(() => cliController.refreshStatus()), + manifestWatcher.onDidDelete(() => cliController.refreshStatus()), ); +} - try { - const project = findCurrentProject(); - if (project === undefined) { - await reconcileProjectContext(undefined, false); - } else { - await reconcileProject(project, false); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); +/** + * The 0.4.x keys that were renamed still hold values nothing reads. Offer to move + * them, once per workspace — and leave the old key in place either way, because + * deleting a user's setting is their decision. + */ +async function offerSettingRenames(context: vscode.ExtensionContext): Promise { + const pending = pendingRenames(vscode.window.activeTextEditor?.document.uri); + if (pending.length === 0 || context.workspaceState.get("mcpp.renamesOffered") === true) { + return; + } + + const toUser = t("Move them to my user settings"); + const toWorkspace = t("Move them to this workspace"); + const choice = await vscode.window.showInformationMessage(renamePrompt(pending), toUser, toWorkspace); + if (choice !== toUser && choice !== toWorkspace) { + // Dismissed is not answered: the marker is only written after a choice, so + // the offer comes back next session instead of being lost forever — a + // marker written before the prompt stranded exactly those users (external + // review P2-9). + return; + } + await context.workspaceState.update("mcpp.renamesOffered", true); + const moved = await applyRenames(pending, choice === toUser ? "user" : "workspace"); + void vscode.window.showInformationMessage(t("mcpp: moved {0} setting(s).", moved)); +} + +/** + * A version notice, never a gate. `extensionDependencies` cannot express a version + * range, so a user can legitimately end up with a build of the language service + * older than the one this extension was tested against; the capability probe means + * that still works, but it is worth one sentence in the log. + */ +async function noteUnverifiedLanguageService(output: vscode.OutputChannel): Promise { + const version = languageServerVersion(); + if (version === undefined) { + return; } + // The same comparator the version matrix uses, against the contract's own + // minimum — the old hand-rolled comparison had dead `< 0` branches and a + // second, drifting copy of the floor (external review P1-3). + const below = compareVersions(version, VERIFIED_MCPPLS_MINIMUM) < 0; + if (below) { + output.appendLine( + t("{0} {1} is older than the verified range ({2}); the capability probe will hide what it cannot do.", MCPPLS_EXTENSION_ID, version, VERIFIED_MCPPLS_RANGE), + ); + } +} + +/** + * The self-check gathers, never guesses: anything it cannot read is reported as + * unknown rather than left blank. It is the first thing to ask for in a bug + * report, and it writes to the `mcpp` channel so it can be copied in one go. + */ +async function showSelfCheck( + output: vscode.OutputChannel, + cliController: McppCliController, + bridge: LanguageServerBridge, + extensionVersion: unknown, +): Promise { + const project = findCurrentProject(); + const executable = cliController.mcppExecutable(project); + const timeoutSeconds = read("mcpp.runtime.timeoutSeconds"); + // The untrusted-workspace promise in the manifest is "no mcpp command runs", + // and `mcpp.path` is resource-scoped, so the probe refuses rather than + // executing a workspace-named program; the report says so in plain words. + const probeResult = await runMcpp(vscode.workspace.isTrusted, executable, ["--protocol-version"], project?.root, { + timeoutMs: timeoutSeconds > 0 ? timeoutSeconds * 1000 : undefined, + }); + const info = probeResult !== undefined && probeResult.exitCode === 0 ? parseProtocolInfo(probeResult.stdout) : undefined; + const state = readLanguageServerState(); + const capabilities = CAPABILITIES.map((entry) => ({ + key: entry.key, + state: bridge.isGone(entry.key) ? "missing" : bridge.isUnconfirmed(entry.key) ? "unconfirmed" : "available", + })); + const text = buildSelfCheckText({ + extensionVersion: typeof extensionVersion === "string" ? extensionVersion : "unknown", + vscodeVersion: vscode.version, + platform: `${process.platform}-${process.arch}`, + languagePreference: languagePreference(), + trusted: vscode.workspace.isTrusted, + workspaceRoots: (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath), + projectRoot: project?.root, + mcppPath: executable, + mcppProbe: + info === undefined + ? undefined + : { version: info.mcppVersion, envelopeMax: info.envelopeMax, kinds: Object.keys(info.kinds) }, + mcppls: { + installed: languageServerInstalled(), + version: languageServerVersion(), + enabled: languageServerEnabled(), + state: describeState(state), + capabilities, + }, + changedSettings: changedSettings(project === undefined ? undefined : vscode.Uri.file(project.root)), + lastRefresh: lastRefresh, + cache: await readCacheSnapshot(), + }); + output.appendLine(""); + output.appendLine("===== mcpp: environment self-check ====="); + output.appendLine(text); + if (probeResult === undefined) { + output.appendLine(t("The workspace is not trusted; mcpp itself was not probed.")); + } + output.appendLine("========================================"); + output.show(true); } export function deactivate(): void { - moduleStatusByProject.clear(); - moduleCheckOperations.clear(); - // VS Code 会释放 activate() 注册的所有订阅。 + // VS Code disposes everything registered in activate(). } diff --git a/src/graphs/model.ts b/src/graphs/model.ts new file mode 100644 index 0000000..a998b59 --- /dev/null +++ b/src/graphs/model.ts @@ -0,0 +1,92 @@ +/** Shared presentation model; providers own package/module semantics and source access. */ +export interface VisualNode { + id: string; + label: string; + lines: string[]; + kind: "project" | "manifest" | "index" | "unknown"; + files?: string[]; + entry?: boolean; +} +export interface VisualEdge { from: string; to: string; label?: string; dashed?: boolean; } +export interface VisualGraph { nodes: VisualNode[]; edges: VisualEdge[]; roots: string[]; truncated?: boolean; } +export interface GraphExpansion { depth: number; expanded: ReadonlySet; collapsed: ReadonlySet; focus?: string; } +export interface VisibleGraph extends VisualGraph { hidden: Map; limited: boolean; } +export const GRAPH_BUDGET = { nodes: 150, edges: 300 } as const; + +/** Choose a root for every directed component, including disconnected cycles. */ +export function graphRoots(nodes: readonly VisualNode[], edges: readonly VisualEdge[], preferred: string[] = []): string[] { + const adj = adjacency(edges), incoming = new Set(edges.map(edge => edge.to)); + const roots: string[] = [], seen = new Set(), known = new Set(nodes.map(node => node.id)); + const visit = (id: string): void => { + if (!known.has(id) || seen.has(id)) { return; } + roots.push(id); + const stack = [id]; + while (stack.length) { + const next = stack.pop()!; + if (seen.has(next)) { continue; } + seen.add(next); + for (const child of adj.get(next) ?? []) { stack.push(child); } + } + }; + for (const id of preferred) { visit(id); } + for (const node of nodes) { if (!incoming.has(node.id)) { visit(node.id); } } + for (const node of nodes) { visit(node.id); } + return roots; +} +function adjacency(edges: readonly VisualEdge[]): Map { + const result = new Map(); + for (const edge of edges) { + const list = result.get(edge.from) ?? []; + list.push(edge.to); result.set(edge.from, list); + } + return result; +} + +/** Bound the subgraph BEFORE layout. Breadth first keeps wide and deep graphs navigable. */ +export function visibleGraph(graph: VisualGraph, state: GraphExpansion, budget: { nodes: number; edges: number } = GRAPH_BUDGET): VisibleGraph { + const adj = adjacency(graph.edges), known = new Set(graph.nodes.map(node => node.id)); + const seen = new Set(), remaining = new Map(); + const queue: Array<[string, number]> = []; + let limited = false; + const add = (id: string, depth: number): void => { + if (!known.has(id)) { return; } + if (!seen.has(id)) { + if (seen.size >= budget.nodes) { limited = true; return; } + seen.add(id); + } + if ((remaining.get(id) ?? -1) >= depth) { return; } + remaining.set(id, depth); queue.push([id, depth]); + }; + if (state.focus && known.has(state.focus)) { + add(state.focus, state.depth); + // Context includes direct importers without recursively expanding their other dependencies. + for (const edge of graph.edges) { if (edge.to === state.focus) { add(edge.from, 0); } } + } else { for (const root of graph.roots) { add(root, state.depth); } } + for (let cursor = 0; cursor < queue.length; cursor++) { + const [id, depth] = queue[cursor]; + if (state.collapsed.has(id) || (depth <= 0 && !state.expanded.has(id))) { continue; } + for (const child of adj.get(id) ?? []) { add(child, Math.max(0, depth - 1)); } + } + const candidates = graph.edges.filter(edge => seen.has(edge.from) && seen.has(edge.to) && !state.collapsed.has(edge.from)); + const edges = candidates.slice(0, budget.edges); + limited ||= candidates.length > edges.length; + const rendered = new Map>(); + for (const edge of edges) { const list = rendered.get(edge.from) ?? new Set(); list.add(edge.to); rendered.set(edge.from, list); } + const hidden = new Map(); + for (const id of seen) { + const missing = new Set((adj.get(id) ?? []).filter(child => !rendered.get(id)?.has(child))); + if (missing.size) { hidden.set(id, missing.size); } + } + return { nodes: graph.nodes.filter(node => seen.has(node.id)), edges, roots: graph.roots, truncated: graph.truncated, hidden, limited }; +} + +export function mermaidGraph(graph: VisibleGraph): { source: string; nodeIds: string[] } { + const nodeIds = graph.nodes.map(node => node.id), ids = new Map(nodeIds.map((id, index) => [id, `n${index}`])); + const label = (value: string): string => value.replace(/[\r\n]/g, " ").replace(/[&"<>[\]{}|`\\#]/g, char => `#${char.charCodeAt(0)};`); + const source = ["flowchart LR", ...graph.nodes.map(node => { + const hidden = graph.hidden.get(node.id); + const text = [node.label, ...node.lines, ...(hidden ? [`+${hidden}`] : [])].map(label).join("
"); + return ` ${ids.get(node.id)}["${text}"]:::${node.kind}`; + }), ...graph.edges.map(edge => ` ${ids.get(edge.from)} ${edge.dashed ? "-.-" : "--"}>${edge.label ? `|"${label(edge.label)}"|` : ""} ${ids.get(edge.to)}`), " classDef unknown stroke-dasharray: 4 3"].join("\n"); + return { source, nodeIds }; +} diff --git a/src/graphs/panelHtml.ts b/src/graphs/panelHtml.ts new file mode 100644 index 0000000..6b9e34c --- /dev/null +++ b/src/graphs/panelHtml.ts @@ -0,0 +1,31 @@ +import { t } from "../i18n/t"; +import { escapeHtml } from "../util/text"; +import type { WebviewAssets } from "../webview/document"; + +export function graphPanelHtml(assets: WebviewAssets, renderer: string, script: string): string { + const labels = { + copied: t("Copied"), error: t("Could not render the graph. Copy Mermaid source or refresh to retry."), + loading: t("Reading project dependencies…"), empty: t("No module relationships were found for this project."), + limited: t("View limit reached. Focus a node to explore its dependencies."), + partial: t("The source graph is incomplete. Some modules may be omitted."), + expand: t("Expand next level"), selected: t("Selected node"), + }; + const button = (action: string, label: string): string => ``; + return ` + + +

${escapeHtml(t("Dependency graph"))}

+
+
+ + + +
+
${escapeHtml(t("Select a node to explore its relationships."))}
${button("expand", t("Expand next level"))}${button("collapse", t("Collapse"))}${button("focus", t("Focus"))}
+
+
+ +
${escapeHtml(t("Legend and source"))}
${escapeHtml(t("Project"))}path / partitionindex / external${escapeHtml(t("Unavailable"))}

${escapeHtml(t("Packages show declarations; modules show mcppls imports. External modules stop after one hop."))}

${button("manifest", t("Open mcpp.toml"))}
+ + `; +} diff --git a/src/i18n/t.ts b/src/i18n/t.ts new file mode 100644 index 0000000..f36c3af --- /dev/null +++ b/src/i18n/t.ts @@ -0,0 +1,81 @@ +/** + * Runtime strings, in one place, following the editor's language by default. + * + * The **English text is the key** (the same convention `vscode.l10n` uses), so a + * missing translation degrades to readable English rather than to an identifier. + * `data/i18n/zh-cn.json` carries the translations; `tools/generate-l10n.mjs` + * turns it into `l10n/bundle.l10n.zh-cn.json`, which is what makes `auto` work + * inside VS Code. + * + * `mcpp.ui.language` overrides the choice for **our** strings — runtime messages + * and our webviews. It cannot change the command palette or the Settings UI: + * VS Code resolves `package.nls.*` once at startup from its own locale. That + * limitation is documented in `docs/settings.md` and stated in the panel. + */ + +import type * as vscode from "vscode"; + +import zhCn from "../../data/i18n/zh-cn.json"; +import { format, translate, type Bundle, type LanguagePreference } from "./translate"; + +export type { LanguagePreference } from "./translate"; + +const BUNDLES: Readonly> = { + en: {}, + "zh-cn": zhCn as Bundle, +}; + +let preference: LanguagePreference = "auto"; + +/** The effective preference, for the environment self-check and the panel. */ +export function languagePreference(): LanguagePreference { + return preference; +} + +export function setLanguagePreference(value: LanguagePreference | undefined): void { + preference = value === "en" || value === "zh-cn" ? value : "auto"; +} + +export type Substitution = string | number | boolean; + +/** + * The editor's own `l10n`, loaded on demand. + * + * `t()` needs it only on the `auto` path, so requiring it lazily keeps this + * module importable from **pure** modules: the TOML completion tables are unit + * tested without an editor, and importing them must not fail on a missing + * `vscode`. Outside an editor host there is no locale to consult, and the + * English text — which is the key — is the correct answer: it is exactly what a + * missing translation degrades to. + */ +function editorL10n(): typeof vscode.l10n | undefined { + try { + return (require("vscode") as typeof vscode).l10n; + } catch { + return undefined; + } +} + +/** + * Resolve one runtime string. + * + * - `auto` asks VS Code, which consults `l10n/bundle.l10n..json` and + * falls back to the English text we passed in; + * - a manual preference reads our own bundle, so the escape hatch works even + * when the editor is in a third language. + */ +export function t(english: string, ...args: readonly Substitution[]): string { + if (preference === "auto") { + const l10n = editorL10n(); + return l10n === undefined ? format(english, args) : l10n.t(english, ...args); + } + return translate(preference, english, args, BUNDLES[preference]); +} + +/** Substitute into an already-translated template (webviews, generated HTML). */ +export { format }; + +/** The bundle this build ships, for `tools/l10n-check.mjs`. */ +export function translationBundle(): Bundle { + return BUNDLES["zh-cn"]; +} diff --git a/src/i18n/translate.ts b/src/i18n/translate.ts new file mode 100644 index 0000000..122b706 --- /dev/null +++ b/src/i18n/translate.ts @@ -0,0 +1,55 @@ +/** + * The language-independent half of `src/i18n/t.ts`. Pure: no `vscode`, so the + * resolution rules are unit-testable. + * + * English text is the key; a translation bundle maps it to the target language. + * A missing entry returns the English text, which is why a forgotten translation + * reads as English rather than as an identifier. + */ + +export type LanguagePreference = "auto" | "en" | "zh-cn"; +export type Bundle = Readonly>; + +/** `{0}`-style substitution, the same placeholder syntax `vscode.l10n` uses. */ +export function format(template: string, args: readonly unknown[]): string { + return template.replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number.parseInt(index, 10)]; + return value === undefined ? whole : String(value); + }); +} + +/** + * Resolve one string for an explicit preference. `auto` is not handled here: + * that path belongs to VS Code, which owns the editor's locale. + */ +export function translate( + preference: Exclude, + english: string, + args: readonly unknown[], + bundle: Bundle, +): string { + if (preference === "en") { + return format(english, args); + } + const translated = bundle[english]; + return format(typeof translated === "string" && translated.length > 0 ? translated : english, args); +} + +/** `"zh-cn"` for any Chinese locale VS Code may report; `undefined` otherwise. */ +export function localeFromEditorLanguage(language: string | undefined): "en" | "zh-cn" | undefined { + if (language === undefined) { + return undefined; + } + return language.toLowerCase().startsWith("zh") ? "zh-cn" : "en"; +} + +/** Missing entries for a set of used strings, for `tools/l10n-check.mjs`. */ +export function missingTranslations(used: Iterable, bundle: Bundle): string[] { + const missing: string[] = []; + for (const key of used) { + if (typeof bundle[key] !== "string" || bundle[key].length === 0) { + missing.push(key); + } + } + return missing; +} diff --git a/src/library/addDependency.ts b/src/library/addDependency.ts new file mode 100644 index 0000000..48d7d2b --- /dev/null +++ b/src/library/addDependency.ts @@ -0,0 +1,143 @@ +/** + * `mcpp add @ [--dev]` — the only way this extension changes a + * project's dependencies. + * + * **`mcpp.toml` is never written here.** `mcpp add` is the official "add a + * dependency to mcpp.toml" command; it owns the file's formatting, comments and + * lockfile. Editing TOML from an extension is how a manifest gets corrupted. + * + * The version is **mandatory**, and that is mcpp's rule, not ours: a bare + * `mcpp add compat.argparse` is refused with + * `error: package version required: \`mcpp add compat.argparse@\` (M2 supports + * exact-version only)`. The caller therefore always passes the version it picked + * from `mcpp xpkg parse --json` (the greatest one for this platform), and this + * module refuses to run without one rather than producing an error the user + * cannot act on. + * + * Failures are values, not exceptions: the exit code and the stderr tail travel + * back to the caller, which shows them. Nothing here throws, so a failed add can + * never leave the detail page half-rendered. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import { clampOutput } from "../util/text"; + +/** `mcpp add` may have to fetch an index or build the cache entry, so it gets a long budget. */ +const ADD_TIMEOUT_MS = 300_000; + +export interface AddDependencyDeps { + /** The configured `mcpp` path, or `mcpp`. */ + mcppExecutable: () => string; + /** The workspace folder holding `mcpp.toml`; `undefined` is a refusal, not a crash. */ + projectRoot: () => string | undefined; + /** Where the command and its output are logged, the same channel the CLI uses. */ + output?: vscode.OutputChannel; + /** `vscode.workspace.isTrusted`; an untrusted workspace may not be written to. */ + isTrusted: () => boolean; + quiet?: boolean; +} + +export interface AddDependencyRequest { + /** The package id, `ns.name`. */ + id: string; + /** The exact version; `undefined` is refused. */ + version?: string; + /** `--dev`, i.e. a `[dev-dependencies]` entry. */ + dev: boolean; +} + +export interface AddDependencyResult { + ok: boolean; + /** The argv that ran (empty when nothing ran), for the log and the UI. */ + argv: string[]; + exitCode: number; + /** A sentence for the detail page, already localized. */ + message: string; +} + +function failure(message: string, argv: readonly string[] = []): AddDependencyResult { + return { ok: false, argv: [...argv], exitCode: 1, message }; +} + +/** The argv of one add, without running it. The preview in the UI uses the same shape. */ +export function addDependencyArgv(request: { id: string; version: string; dev: boolean }): string[] { + return ["add", `${request.id}@${request.version}`, ...(request.dev ? ["--dev"] : [])]; +} + +/** + * Run one `mcpp add`, with a progress notification, and report what happened. + * + * Every refusal is explicit and happens **before** anything runs: no version, no + * `mcpp.toml`, an untrusted workspace. A run that fails returns the exit code and + * the last lines of stderr, which the detail page prints verbatim — the user gets + * mcpp's own words, not ours. + */ +export async function addDependency( + deps: AddDependencyDeps, + request: AddDependencyRequest, +): Promise { + const version = request.version?.trim() ?? ""; + if (request.id.trim().length === 0 || version.length === 0) { + const message = t("mcpp add needs an exact version: mcpp add {0}@.", request.id); + if (!deps.quiet) void vscode.window.showWarningMessage(message); + return failure(message); + } + const root = deps.projectRoot(); + if (root === undefined) { + const message = t("This workspace has no mcpp.toml."); + if (!deps.quiet) void vscode.window.showWarningMessage(message); + return failure(message); + } + if (!deps.isTrusted()) { + const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); + if (!deps.quiet) void vscode.window.showWarningMessage(message); + return failure(message); + } + + const argv = addDependencyArgv({ id: request.id, version, dev: request.dev }); + const executable = deps.mcppExecutable(); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t("Adding {0}…", request.id) }, + () => + runMcpp(deps.isTrusted(), executable, argv, root, { + timeoutMs: ADD_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (result === undefined) { + // The seam refused: the guard above usually answers first with a friendlier + // message, so this is the defensive spelling of the same refusal. + const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); + return failure(message); + } + + try { + deps.output?.appendLine(`\n[${new Date().toISOString()}] mcpp ${argv.join(" ")}`); + deps.output?.appendLine(`$ ${[executable, ...argv].join(" ")}`); + const stdout = clampOutput(result.stdout); + const stderr = clampOutput(result.stderr); + if (stdout.text.trim().length > 0) deps.output?.appendLine(stdout.text.trimEnd()); + if (stderr.text.trim().length > 0) deps.output?.appendLine(stderr.text.trimEnd()); + deps.output?.appendLine(`[exit ${result.exitCode}]`); + } catch { + // The channel can already be gone during shutdown. + } + + if (result.exitCode === 0) { + const message = t("Added {0} to mcpp.toml.", `${request.id}@${version}`); + if (!deps.quiet) void vscode.window.showInformationMessage(message); + return { ok: true, argv, exitCode: 0, message }; + } + + const detail = clampOutput(result.stderr.length > 0 ? result.stderr : result.stdout).text.trim(); + const message = + detail.length === 0 + ? t("mcpp add failed with exit code {0}.", result.exitCode) + : t("mcpp add failed (exit {0}). See output for details.", result.exitCode); + if (!deps.quiet) void vscode.window.showErrorMessage(message); + return { ok: false, argv, exitCode: result.exitCode, message }; +} diff --git a/src/library/detailHtml.ts b/src/library/detailHtml.ts new file mode 100644 index 0000000..d061614 --- /dev/null +++ b/src/library/detailHtml.ts @@ -0,0 +1,686 @@ +/** + * The package detail page's document, as a pure function (plan §12.1–§12.5). + * + * The sidebar answers "which package"; this page answers "what is it, how do I + * consume it, and how do I add it". It opens in the **editor area** (a + * `WebviewPanel`, see `src/library/detailPanel.ts`), because a 200 px sidebar + * cannot show a version matrix and a code block. + * + * The same house rules as the other webviews apply: strict CSP, every label from + * `DetailModel.ui`, every interpolation escaped, no inline `style=`, and no state + * that is only a colour. Everything shown is either authoritative + * (`mcpp xpkg parse --json`, `mcpp.toml`) or explicitly attributed (the example + * code comes from the index's own CI-built test projects, and says so). + * + * The example block is tokenized by `indexModel.tokenizeCppLine` — a deliberately + * small highlighter: comments, strings, the preprocessor directive, a short + * keyword list and punctuation. It is not a parser and is not meant to be. + */ + +import { dependencyLocation, type DependencyKind, type ProjectDependencyState } from "./projectState"; + +import { + BADGE_UI, + surfaceLabel, + tokenizeCppLine, + type BadgeKey, + type Surface, +} from "./indexModel"; + +/** One platform's row in the version matrix. */ +export interface DetailVersionGroup { + platform: string; + versions: string[]; + /** The platform this extension host is running on. */ + current: boolean; +} + +/** One declared dependency, as far as it is cheaply known. */ +export interface DetailDependency { + id: string; + version?: string; + /** The version the project's `mcpp.lock` resolved, when it is known. */ + resolved?: string; + key?: string; +} + +/** One window of real example code. */ +export interface DetailSnippet { + file: string; + startLine: number; + lines: string[]; + usageLine: number; + fileKey?: string; +} + +/** The outcome of the last `mcpp add`, shown in place. */ +export interface DetailResult { + /** `pending` is the client's own "I asked the host" line, replaced by the answer. */ + state: "ok" | "error" | "pending"; + message: string; +} + +/** The workspace's own answer about one package. */ +export interface DetailInstalled { + version: string; + /** Declared under `[dev-dependencies]`. */ + dev: boolean; +} + +export interface DetailModel { + /** Everything is already localized by the caller. */ + ui: Record; + key?: string; + generation?: number; + projectRoot?: string; + platform?: string; + projectState?: ProjectDependencyState; + dependencyGroups?: Record; + dependencyKind?: DependencyKind; + manifestAvailable?: boolean; + blockedReason?: string; + versionNotice?: string; + loading?: boolean; + preview?: boolean; + previewVersion?: string; + busy?: boolean; + id: string; + name: string; + description?: string; + licenses: string[]; + repo?: string; + registry: string; + surface?: Surface; + surfaces: Surface[]; + badges: BadgeKey[]; + /** The version matrix, current platform first. */ + versions: DetailVersionGroup[]; + /** Every version the current platform has, greatest first. */ + currentVersions: string[]; + /** What `mcpp add` would use by default. */ + latest?: string; + /** + * The version this workspace already asks for — `mcpp.toml` first, then + * `mcpp.lock` — so the page can say "you have this one" and offer the switch + * instead of a blind add. Absent when the project does not depend on the + * package, or when the dependency names no version (a path or git entry). + */ + installed?: DetailInstalled; + standard?: string; + dependencies: DetailDependency[]; + includeDirs: string[]; + targets: string[]; + snippets: DetailSnippet[]; + /** The example project the snippets come from, when there is one. */ + exampleProject?: string; + exampleProvenance?: string; + /** + * How a reader brings the package into code (§22): the example project's + * real `import …;` / `#include …` lines when there are any, else the honest + * synthetic form for the surface. Empty when the surface is not importable. + */ + usage: string[]; + /** The index site's package page; omitted for a registry that has no site. */ + indexUrl?: string; + /** `mcpp add {0}@{1}` / with `--dev`, so the preview and the run agree. */ + commandTemplate: string; + commandDevTemplate: string; + /** Set when `mcpp xpkg parse` could not be read; the page still renders. */ + parseNotice?: string; + /** The data source, in plain words, for the footer. */ + dataSource: string; + result?: DetailResult; +} + +export interface DetailAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** The `ui` keys the renderer reads, so the caller and the renderer cannot drift. */ +export const DETAIL_UI = { + htmlLang: "detail.htmlLang", + title: "detail.title", + project: "detail.project", + chooseProject: "detail.chooseProject", + platform: "detail.platform", + version: "detail.version", + otherPlatforms: "detail.otherPlatforms", + declared: "detail.declared", + locked: "detail.locked", + sourceDependency: "detail.sourceDependency", + unknownUsage: "detail.unknownUsage", + usageSource: "detail.usageSource", + source: "detail.source", + loading: "detail.loading", + pending: "detail.pending", + groupChange: "detail.groupChange", + output: "detail.output", + retry: "detail.retry", + openManifest: "detail.openManifest", + openFile: "detail.openFile", + readOnlySource: "detail.readOnlySource", + preview: "detail.preview", + platforms: "detail.platforms", + kind: "detail.kind", + kindNormal: "detail.kind.normal", + kindDev: "detail.kind.dev", + kindBuild: "detail.kind.build", + addDevAction: "detail.addDevAction", + buildHint: "detail.buildHint", + moveHint: "detail.moveHint", + overview: "detail.overview", + license: "detail.license", + repo: "detail.repo", + openRepo: "detail.openRepo", + registry: "detail.registry", + surface: "detail.surface", + surfaceExternal: "detail.surface.external", + standard: "detail.standard", + versions: "detail.versions", + versionsAll: "detail.versions.all", + versionsCurrent: "detail.versions.current", + versionsNone: "detail.versions.none", + versionsPick: "detail.versions.pick", + dependencies: "detail.dependencies", + dependenciesNone: "detail.dependencies.none", + dependenciesHint: "detail.dependencies.hint", + resolved: "detail.resolved", + code: "detail.code", + codeNone: "detail.code.none", + codeSource: "detail.code.source", + codeProject: "detail.code.project", + add: "detail.add", + addDev: "detail.addDev", + switchTo: "detail.switchTo", + alreadyAdded: "detail.alreadyAdded", + installed: "detail.installed", + opening: "detail.opening", + addLatest: "detail.addLatest", + addNoVersion: "detail.add.noVersion", + command: "detail.command", + usage: "detail.usage", + copy: "detail.copy", + copied: "detail.copied", + copying: "detail.copying", + indexLink: "detail.indexLink", + badgeExamples: BADGE_UI.examples.key, + badgeCn: BADGE_UI.cn.key, + badgeOpenkalEcosystem: BADGE_UI["openkal-ecosystem"].key, + badgeOpenkalCompat: BADGE_UI["openkal-compat"].key, + badgeOpenkalPosix: BADGE_UI["openkal-posix"].key, + badgeOpenkalPlatform: BADGE_UI["openkal-platform"].key, + targets: "detail.targets", + includeDirs: "detail.includeDirs", +} as const; + +const BADGE_KEY_UI: Readonly> = { + examples: DETAIL_UI.badgeExamples, + cn: DETAIL_UI.badgeCn, + "openkal-ecosystem": DETAIL_UI.badgeOpenkalEcosystem, + "openkal-compat": DETAIL_UI.badgeOpenkalCompat, + "openkal-posix": DETAIL_UI.badgeOpenkalPosix, + "openkal-platform": DETAIL_UI.badgeOpenkalPlatform, +}; + +/** Messages carry a page key and generation at the transport boundary. + * Mounting replays the latest model in place; it never rebuilds the document. + */ +export type DetailMessage = + | { type: "add"; version: string; dev: boolean } + | { type: "openUrl"; url: string } + | { type: "copy"; text: string; token?: string } + | { type: "selectProject" | "showOutput" | "retry" | "openManifest" | "mounted" } + | { type: "openDependency" | "openFile"; key: string }; + +export type UiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** `{0}`-style substitution into an already-localized ui string. */ +function fill(label: UiLabel, key: string, args: readonly (string | number)[]): string { + return label(key).replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number(index)]; + return value === undefined ? whole : String(value); + }); +} + +function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string[] { + return badges.map((badge) => label(BADGE_KEY_UI[badge])); +} + +/** One source line, tokenized. The renderer escapes every token. */ +function renderLine(line: string, usage: boolean): string { + if (line.length === 0) { + return "\n"; + } + const tokens = tokenizeCppLine(line) + .map((token) => `${escapeHtml(token.text)}`) + .join(""); + return `${tokens}\n`; +} + +function actionIcon(kind: "copy" | "source"): string { + const paths = kind === "copy" ? '' : ''; + return ``; +} + +function renderSnippets(model: DetailModel, label: UiLabel): string { + if (model.snippets.length === 0) { + return `

${escapeHtml(label(DETAIL_UI.codeNone))}

`; + } + const blocks = model.snippets.map((snippet, index) => { + const lines = snippet.lines + .map((line, index) => renderLine(line, snippet.startLine + index === snippet.usageLine)) + .join(""); + const caption = fill(label, DETAIL_UI.codeSource, [snippet.file, snippet.startLine]); + return [ + `

${escapeHtml(index === 0 ? label(DETAIL_UI.code) : snippet.file.split("/").pop() ?? snippet.file)}

${snippet.fileKey === undefined ? "" : ``}
`, + `
${lines}
`, + `

${escapeHtml(caption)}

`, + ].join("\n"); + }); + return blocks.join("\n"); +} + +/** Read-only platform information; the current-environment select owns the command. */ +function renderVersions(model: DetailModel, label: UiLabel): string { + if (!model.versions.length) { return `

${escapeHtml(label(DETAIL_UI.versionsNone))}

`; } + const row = (group: DetailVersionGroup) => `
  • + ${escapeHtml(group.platform)} + ${group.versions.map((version) => `${escapeHtml(version)}`).join("") || "—"} + ${group.current ? `${escapeHtml(label(DETAIL_UI.versionsCurrent))}` : ""}
  • `; + return `
      ${model.versions.filter((group) => group.current).map(row).join("")}
    +
    ${escapeHtml(label(DETAIL_UI.otherPlatforms))} +
      ${model.versions.filter((group) => !group.current).map(row).join("")}
    `; +} + +function renderDependencies(model: DetailModel, label: UiLabel): string { + if (model.dependencies.length === 0) { + return `

    ${escapeHtml(label(DETAIL_UI.dependenciesNone))}

    `; + } + const rows = model.dependencies.map((dependency) => { + const parts = [dependency.version ?? ""]; + if (dependency.resolved !== undefined) { + parts.push(fill(label, DETAIL_UI.resolved, [dependency.resolved])); + } + return ( + `
  • ${dependency.key ? `` : `${escapeHtml(dependency.id)}`}` + + (parts.length === 0 ? "" : ` — ${escapeHtml(parts.join(" · "))}`) + + `
  • ` + ); + }); + return [ + `

    ${escapeHtml(label(DETAIL_UI.dependenciesHint))}

    `, + `
      `, + rows.join("\n"), + `
    `, + ].join("\n"); +} + +function renderMeta(model: DetailModel, label: UiLabel): string { + const tag = (kind: string, value: string, title: string) => `${escapeHtml(value)}`; + return `
    ${model.surface ? tag("surface", surfaceLabel(model.surface, label), label(DETAIL_UI.surface)) : ""}${model.standard ? tag("standard", model.standard.toUpperCase(), label(DETAIL_UI.standard)) : ""}${model.licenses.map((license) => tag("license", license, license)).join("")}
    `; +} + +function renderProject(model: DetailModel, label: UiLabel): string { + return `
    ${escapeHtml(model.platform ?? "—")}
    `; +} + +function renderExtras(model: DetailModel, label: UiLabel): string { + const blocks: string[] = []; + if (model.targets.length > 0) { + blocks.push( + `

    ${escapeHtml(label(DETAIL_UI.targets))}: ${escapeHtml(model.targets.join(", "))}

    `, + ); + } + if (model.includeDirs.length > 0) { + blocks.push( + `

    ${escapeHtml(label(DETAIL_UI.includeDirs))}: ${escapeHtml(model.includeDirs.join(", "))}

    `, + ); + } + return blocks.join("\n"); +} + +/** + * The primary block: what this page is *for*, immediately under the title. + * + * It used to be the last thing on the page, below four sections of prose, so the + * button a reader came for was the one thing they had to scroll to find. The + * version is chosen in the list below (`renderVersions`), and this block shows + * the command the choice produces. + */ +function groupsFor(model: DetailModel): Record { + const empty: ProjectDependencyState = { declared: false, dev: false, sourceType: "none", ambiguous: false }; + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const state = model.projectState ?? (model.installed ? { ...empty, declared: true, declaredVersion: model.installed.version, dev: model.installed.dev, sourceType: "registry" as const } : empty); + return model.dependencyGroups ?? { normal: kind === "normal" ? state : empty, dev: kind === "dev" ? state : { ...empty, dev: true }, build: kind === "build" ? state : empty }; +} + +function selectedVersion(model: DetailModel): string | undefined { + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const declared = groupsFor(model)[kind].declaredVersion; + return declared !== undefined && model.currentVersions.includes(declared) ? declared : model.latest; +} + +function renderActions(model: DetailModel, label: UiLabel): string { + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const groups = groupsFor(model); + const current = groups[kind]; + const version = selectedVersion(model); + const manual = kind === "build" || current.ambiguous || current.declared && current.sourceType !== "registry" || !current.declared && groups[kind === "dev" ? "normal" : "dev"].declared; + const already = current.declaredVersion !== undefined && current.declaredVersion === version && !manual; + const canOpen = model.manifestAvailable ?? !!model.projectRoot; + const disabled = !!model.busy || !!model.loading || (manual ? !canOpen : !!model.blockedReason || !version || already); + const location = dependencyLocation(model.id, kind); + const command = model.preview ? `mcpp search ${model.id}` : manual ? `[${location.table}] +${location.name} = ${JSON.stringify(version ?? "")}` : (kind === "dev" ? model.commandDevTemplate : model.commandTemplate).replace("{0}", model.id).replace("{1}", version ?? "?"); + const addLabel = model.busy ? label(DETAIL_UI.pending) : manual ? label(DETAIL_UI.openManifest) : already ? label(DETAIL_UI.alreadyAdded) : current.declared ? fill(label, DETAIL_UI.switchTo, [version ?? "?"]) : label(kind === "dev" ? DETAIL_UI.addDevAction : DETAIL_UI.add); + const link = (url: string, text: string) => ``; + const kinds: Array<[DependencyKind, string, string]> = [["normal", DETAIL_UI.kindNormal, "dependencies"], ["dev", DETAIL_UI.kindDev, "dev-dependencies"], ["build", DETAIL_UI.kindBuild, "build-dependencies"]]; + return `
    + ${model.loading ? `

    ${escapeHtml(label(DETAIL_UI.loading))}

    ` : ""} + ${model.preview ? `

    ${escapeHtml(label(DETAIL_UI.preview))}

    ${escapeHtml(label(DETAIL_UI.version))}: ${escapeHtml(model.previewVersion ?? "—")}

    ` : ""} + ${model.preview ? "" : `
    ${kind === "dev" ? "dev-dependencies" : kind === "build" ? "build-dependencies" : "dependencies"}${current.resolvedVersion && current.resolvedVersion !== current.declaredVersion ? `${escapeHtml(label(DETAIL_UI.locked))}: ${escapeHtml(current.resolvedVersion)}` : ""}
    `} +
    + ${model.preview ? "" : `
    `} + ${model.repo === undefined ? "" : link(model.repo, label(DETAIL_UI.openRepo))} + ${model.indexUrl === undefined ? "" : link(model.indexUrl, label(DETAIL_UI.indexLink))} +
    + ${model.versionNotice ? `

    ${escapeHtml(model.versionNotice)}

    ` : ""} + ${model.blockedReason ? `

    ${escapeHtml(model.blockedReason)}

    ` : ""} +

    ${escapeHtml(manual ? label(kind === "build" ? DETAIL_UI.buildHint : DETAIL_UI.moveHint) : "")}

    +

    ${escapeHtml(command)}

    +

    ${escapeHtml(model.result?.message ?? "")}

    +
    +
    `; +} + +export function detailClientState(model: DetailModel): Record { + return { + key: model.key ?? model.id, generation: model.generation ?? 0, projectRoot: model.projectRoot, + id: model.id, versions: model.currentVersions, version: selectedVersion(model), + groups: groupsFor(model), kind: model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"), + locations: Object.fromEntries((["normal", "dev", "build"] as const).map((kind) => [kind, dependencyLocation(model.id, kind)])), + loading: !!model.loading, canOpenManifest: model.manifestAvailable ?? !!model.projectRoot, + blocked: !!model.blockedReason, busy: !!model.busy, preview: !!model.preview, + command: model.commandTemplate, commandDev: model.commandDevTemplate, + labels: Object.fromEntries(Object.entries(DETAIL_UI).map(([name, key]) => [name, model.ui[key] ?? key])), + }; +} + +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var saved = api && api.getState ? api.getState() || {} : {}; + var pages = saved.pages || {}; + var busy = state.busy; + var version = state.version || ""; + var kind = state.kind || "normal"; + var copyId = 0; + var copyRequests = {}; + function copyLabel(button, text) { (button.querySelector("[data-action-label]") || button).textContent = text; } + function restoreCopyFeedback() { + Object.keys(copyRequests).forEach(function (token) { + var request = copyRequests[token]; + if (request.key !== pageKey() || request.until < Date.now()) { delete copyRequests[token]; return; } + var buttons = Array.from(document.querySelectorAll(request.command ? "[data-copy-command]" : "[data-copy]")); + var button = buttons.find(function (node) { return request.command ? document.getElementById("detail-command").textContent === request.text : node.getAttribute("data-copy") === request.text; }); + if (button) { button.setAttribute("data-copy-token", token); if (request.message) { copyLabel(button, request.message); } } + }); + } + function pageKey() { return state.key + "|" + (state.projectRoot || ""); } + function post(message) { message.pageKey = state.key; message.generation = state.generation; if (api) { api.postMessage(message); } } + function persist() { + if (state.loading) { return; } + var expanded = Array.from(document.querySelectorAll("details[data-persist]")).filter(function (node) { return node.open; }).map(function (node) { return node.getAttribute("data-persist"); }); + pages[pageKey()] = { version: version, kind: kind, scroll: window.scrollY, expanded: expanded }; + var keys = Object.keys(pages); while (keys.length > 20) { delete pages[keys.shift()]; } + if (api && api.setState) { api.setState({ pages: pages }); } + } + function closeKindMenu(focus) { + var menu = document.getElementById("detail-kind-menu"); + var toggle = document.getElementById("detail-kind-toggle"); + if (menu) { menu.hidden = true; } + if (toggle) { toggle.setAttribute("aria-expanded", "false"); if (focus) { toggle.focus(); } } + } + function manualSelection() { + var group = state.groups[kind]; + return kind === "build" || group.ambiguous || group.declared && group.sourceType !== "registry" || !group.declared && state.groups[kind === "dev" ? "normal" : "dev"].declared; + } + function update() { + var select = document.getElementById("detail-version"); + var button = document.getElementById("detail-add"); + if (state.preview) { return; } + var group = state.groups[kind]; + var manual = manualSelection(); + var same = !!group.declaredVersion && group.declaredVersion === version && !manual; + if (select) { select.value = version; select.disabled = busy || state.loading || !state.versions.length; } + var toggle = document.getElementById("detail-kind-toggle"); if (toggle) { toggle.disabled = busy || state.loading; } + var kindLabel = document.getElementById("detail-kind-label"); + if (kindLabel) { kindLabel.textContent = kind === "dev" ? "dev-dependencies" : kind === "build" ? "build-dependencies" : "dependencies"; } + document.querySelectorAll("[data-kind]").forEach(function (item) { item.setAttribute("aria-checked", String(item.getAttribute("data-kind") === kind)); }); + if (button) { + button.disabled = busy || state.loading || (manual ? !state.canOpenManifest : state.blocked || !version || same); + button.textContent = busy ? state.labels.pending : manual ? state.labels.openManifest : same ? state.labels.alreadyAdded : group.declared ? state.labels.switchTo.split("{0}").join(version) : kind === "dev" ? state.labels.addDevAction : state.labels.add; + if (same) { button.setAttribute("data-installed", ""); } else { button.removeAttribute("data-installed"); } + } + var note = document.getElementById("detail-selection-note"); if (note) { note.textContent = manual ? kind === "build" ? state.labels.buildHint : state.labels.moveHint : ""; note.hidden = !manual; } + var command = document.getElementById("detail-command"); + var location = state.locations[kind]; + if (command) { command.textContent = manual ? "[" + location.table + "]\\n" + location.name + " = " + JSON.stringify(version || "") : (kind === "dev" ? state.commandDev : state.command).split("{0}").join(state.id).split("{1}").join(version || "?"); } + } + function restore() { + var page = pages[pageKey()] || {}; + if (state.versions.indexOf(page.version) >= 0) { version = page.version; } + kind = ["normal", "dev", "build"].indexOf(page.kind) >= 0 ? page.kind : state.kind || "normal"; + document.querySelectorAll("details[data-persist]").forEach(function (node) { node.open = (page.expanded || []).indexOf(node.getAttribute("data-persist")) >= 0; }); + update(); window.scrollTo(0, page.scroll || 0); + } + document.addEventListener("change", function (event) { + if (event.target.id === "detail-version") { version = event.target.value; } + if (event.target.id === "detail-version") { update(); persist(); } + }); + document.addEventListener("toggle", persist, true); + window.addEventListener("scroll", persist); + document.addEventListener("click", function (event) { + var target = event.target.closest && event.target.closest("button"); + if (!target || target.disabled) { return; } + if (target.id === "detail-kind-toggle") { + var menu = document.getElementById("detail-kind-menu"); menu.hidden = !menu.hidden; + target.setAttribute("aria-expanded", String(!menu.hidden)); + if (!menu.hidden) { menu.querySelector('[aria-checked="true"]').focus(); } + return; + } + if (target.hasAttribute("data-kind")) { + kind = target.getAttribute("data-kind"); + var declared = state.groups[kind].declaredVersion; + if (state.versions.indexOf(declared) >= 0) { version = declared; } + closeKindMenu(true); update(); persist(); return; + } + closeKindMenu(false); + var action = target.getAttribute("data-action"); + if (action) { post({ type: action }); return; } + if (target.hasAttribute("data-open-dependency")) { persist(); post({ type: "openDependency", key: target.getAttribute("data-open-dependency") }); return; } + if (target.hasAttribute("data-open-file")) { post({ type: "openFile", key: target.getAttribute("data-open-file") }); return; } + if (target.hasAttribute("data-open-url")) { post({ type: "openUrl", url: target.getAttribute("data-open-url") }); return; } + if (target.hasAttribute("data-copy") || target.hasAttribute("data-copy-command")) { + var token = "copy-" + (++copyId); target.setAttribute("data-copy-token", token); + copyRequests[token] = { key: pageKey(), command: target.hasAttribute("data-copy-command"), text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, until: Date.now() + 20000 }; + post({ type: "copy", text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, token: token }); return; + } + if (target.id === "detail-add" && !busy) { + if (manualSelection()) { post({ type: "openManifest" }); return; } + if (!version) { return; } + busy = true; update(); + var result = document.getElementById("detail-result"); result.hidden = false; result.dataset.state = "pending"; result.textContent = state.labels.pending; + persist(); post({ type: "add", version: version, dev: kind === "dev" }); + } + }); + document.addEventListener("click", function (event) { if (!event.target.closest || !event.target.closest(".detail-add-group")) { closeKindMenu(false); } }); + document.addEventListener("keydown", function (event) { + var menu = document.getElementById("detail-kind-menu"); + if (!menu || menu.hidden) { return; } + if (event.key === "Escape") { event.preventDefault(); closeKindMenu(true); return; } + if (event.key === "Tab") { closeKindMenu(false); return; } + var options = Array.from(menu.querySelectorAll("[data-kind]")); + if (["ArrowDown", "ArrowUp", "Home", "End"].indexOf(event.key) < 0) { return; } + event.preventDefault(); var index = options.indexOf(document.activeElement); + index = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (event.key === "ArrowDown" ? 1 : options.length - 1)) % options.length; + options[index].focus(); + }); + window.addEventListener("message", function (event) { + var data = event.data; + if (!data) { return; } + if (data.type === "detailUpdate") { + persist(); + var focused = document.activeElement && document.activeElement.id; + var doc = new DOMParser().parseFromString(data.html, "text/html"); + ["detail-header", "detail-main", "detail-footer"].forEach(function (id) { var old = document.getElementById(id); var next = doc.getElementById(id); if (old && next) { old.replaceWith(next); } }); + state = data.state; busy = state.busy; version = state.version || ""; kind = state.kind || "normal"; + restore(); restoreCopyFeedback(); + var nextFocus = focused && document.getElementById(focused); if (nextFocus) { nextFocus.focus({ preventScroll: true }); } + return; + } + if (data.key !== state.key || data.generation !== state.generation) { return; } + if (data.type === "copyResult") { + var request = copyRequests[data.token]; + if (request) { + request.message = data.message; request.until = Date.now() + 1800; restoreCopyFeedback(); + setTimeout(function () { var button = document.querySelector('[data-copy-token="' + data.token + '"]'); if (button) { copyLabel(button, state.labels.copy); } delete copyRequests[data.token]; }, 1800); + } + } + if (data.type === "linkResult") { var status = document.getElementById("detail-link-result"); status.textContent = data.message; status.hidden = !data.message; } + }); + restore(); + post({ type: "mounted" }); +})();`; +} + +/** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const title = fill(label, DETAIL_UI.title, [model.id]); + const currentPlatform = model.versions.find((group) => group.current)?.platform; + const versionsHeading = + currentPlatform === undefined ? label(DETAIL_UI.versionsAll) : fill(label, DETAIL_UI.versions, [currentPlatform]); + return ` + + + + + + +${escapeHtml(title)} + + +
    +

    ${escapeHtml(model.id)}

    ${renderMeta(model, label)}
    ${renderProject(model, label)}
    + ${model.description === undefined ? "" : `

    ${escapeHtml(model.description)}

    `} +
    +
    + ${renderActions(model, label)} + + ${model.preview ? "" : `
    +
    +

    ${escapeHtml(label(DETAIL_UI.usage))}

    + ${model.usage.length ? `

    ${escapeHtml(label(DETAIL_UI.usageSource))}

    ` + model.usage.map((line) => `
    ${escapeHtml(line)}
    `).join("") : model.snippets.length ? "" : `

    ${escapeHtml(label(DETAIL_UI.unknownUsage))}

    `} + ${model.snippets.length ? renderSnippets(model, label) : ""} + ${model.exampleProject === undefined ? "" : `

    ${escapeHtml(fill(label, DETAIL_UI.codeProject, [model.exampleProject]))}

    `} + ${model.exampleProvenance === undefined ? "" : `

    ${escapeHtml(model.exampleProvenance)}

    `} +
    + +
    `} +
    +
    ${escapeHtml(label(DETAIL_UI.source))}

    ${escapeHtml(model.dataSource)}

    ${model.projectRoot ? `` : ""}

    ${escapeHtml(badgeLabels(model.badges, label).join(" · "))}

    ${model.parseNotice === undefined ? "" : `

    ${escapeHtml(model.parseNotice)}

    `}
    + + + +`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not one of the three shapes is dropped. `openUrl` is restricted to `https` + * here as well as in the host, so a compromised document cannot ask the host to + * open a `file:` or `command:` uri. + */ +export function decodeDetailMessage(raw: unknown): DetailMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "mounted": + case "selectProject": + case "showOutput": + case "retry": + case "openManifest": + return { type: raw.type }; + case "openDependency": + case "openFile": + return nonEmptyString(raw.key) ? { type: raw.type, key: raw.key } : undefined; + case "add": + return nonEmptyString(raw.version) && typeof raw.dev === "boolean" + ? { type: "add", version: raw.version, dev: raw.dev } + : undefined; + case "openUrl": + return typeof raw.url === "string" && raw.url.startsWith("https://") + ? { type: "openUrl", url: raw.url } + : undefined; + case "copy": + // Clipboard content, same trust level as the https-restricted url. The + // cap is 16 KiB — far above any command or usage line, low enough that a + // hostile document cannot park a novel in the clipboard — and everything + // else about the text is the user's own click. + return nonEmptyString(raw.text) && raw.text.length <= 16_384 + ? { type: "copy", text: raw.text, ...(typeof raw.token === "string" && /^copy-\d+$/.test(raw.token) ? { token: raw.token } : {}) } + : undefined; + default: + return undefined; + } +} diff --git a/src/library/detailPanel.ts b/src/library/detailPanel.ts new file mode 100644 index 0000000..2fb0adb --- /dev/null +++ b/src/library/detailPanel.ts @@ -0,0 +1,632 @@ +/** + * The package detail page: a `WebviewPanel` in the **editor area** (plan §12.1). + * + * The sidebar answers "which package"; this page answers "what is it, how do I + * use it, how do I add it". It is where the authoritative reader finally runs: + * `mcpp xpkg parse --json` (see `src/library/xpkg.ts`), on + * demand, for the one package the reader opened — never once per row. + * + * Boundaries, in the same spirit as `src/cache/cachePanel.ts`: + * + * - **One panel per window.** Opening another package re-renders the existing + * panel instead of stacking a second one. + * - **A failure still renders.** A descriptor that cannot be parsed, an example + * that disappeared, or a failed `mcpp add` each produce a document with the + * reason in it. + * - **Nothing is written here.** The add button delegates to + * `src/library/addDependency.ts`, which runs `mcpp add`; `mcpp.toml` is mcpp's + * file. + */ + +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { WebviewDocument } from "../webview/document"; +import { projectDependencyState, readProjectDeclarations, operationMatches } from "./projectState"; +import type { LibraryRow } from "./libraryHtml"; +import { addDependency } from "./addDependency"; +import { + badgesOf, + descriptorDependencies, + lockPackageVersions, + mergeSurfaces, + parseXpkgJson, + platformKey, + surfaceLabel, + usageLinesFor, + type LibraryEntry, + type Surface, +} from "./indexModel"; +import { cachedDescriptorText, loadSnapshot, readDescriptorText, readExampleFiles } from "./indexLocator"; +import { bundledExample, ExampleDocuments, EXAMPLE_SCHEME } from "./examples"; +import { + DETAIL_UI, + decodeDetailMessage, + renderDetailHtml, + detailClientState, + type DetailModel, + type DetailVersionGroup, + type DetailResult, +} from "./detailHtml"; +import { compareVersions, versionGroups, type XpkgInfo } from "./xpkg"; + +const PANEL_VIEW_TYPE = "mcpp.libraryDetail"; +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "library.css"; + +/** `mcpp xpkg parse` is a local, read-only command; this is generous. */ +const PARSE_TIMEOUT_MS = 20_000; + +export interface DetailPanelDeps { + mcppExecutable: (projectRoot?: string) => string; + chooseProject?: () => Promise; + onPackageOpened?: (key: string) => void; + projectRoot: () => string | undefined; + output: vscode.OutputChannel; + isTrusted: () => boolean; + /** + * Called after a successful `mcpp add`. `mcpp add` writes `mcpp.toml` on disk + * without the editor saving a document, so no save event fires; the sidebar's + * "已添加" state would otherwise stay stale. `extension.ts` wires this to the + * library view's `refresh()`. + */ + onDependenciesChanged?: () => void; + onProjectFilesRead?: (root: string, manifest: string, lock: string) => void; +} + +/** Build the opener once; `extension.ts` passes `openLibraryDetail` to the view. */ +export function createLibraryDetailOpener( + context: vscode.ExtensionContext, + deps: DetailPanelDeps, +): LibraryDetailOpener { + const sourceChanged = new vscode.EventEmitter(); + const exampleDocuments = new ExampleDocuments((uri) => sourceChanged.fire(vscode.Uri.parse(uri))); + context.subscriptions.push(sourceChanged, vscode.workspace.registerTextDocumentContentProvider(EXAMPLE_SCHEME, { + onDidChange: sourceChanged.event, + provideTextDocumentContent: (uri) => exampleDocuments.read(uri.toString()) ?? "", + })); + const session: DetailSession = { + context, + exampleDocuments, + deps, + panel: undefined, + id: undefined, + busy: false, + generation: 0, + projectRoot: deps.projectRoot(), + painted: false, + webviewDocument: new WebviewDocument(STYLESHEET), + }; + context.subscriptions.push({ + dispose: () => { + session.panel?.dispose(); + session.panel = undefined; + }, + }); + const opener = (id: string, preview?: LibraryRow) => open(session, id, preview); + opener.refresh = async () => { + if (session.id === undefined || session.panel === undefined) { return; } + const root = deps.projectRoot(); + if (root !== session.projectRoot) { + session.projectRoot = root; session.generation++; session.result = undefined; + if (session.model) { paintModel(session, { ...session.model, generation: session.generation, projectRoot: root, projectState: undefined, installed: undefined, loading: true, blockedReason: t("Reading package details…"), result: undefined }); } + } + await render(session, session.id); + }; + return opener; +} + +export interface LibraryDetailOpener { + (id: string, preview?: LibraryRow): Promise; + refresh(): Promise; +} + +/** One panel per window, remembered so a second `open` reveals rather than stacks. */ +export interface DetailSession { + exampleDocuments: ExampleDocuments; + context: vscode.ExtensionContext; + deps: DetailPanelDeps; + panel: vscode.WebviewPanel | undefined; + id: string | undefined; + /** `mcpp add` in flight: a second click must not start a second command. */ + busy: boolean; + generation: number; + renderRequest?: number; + projectRoot?: string; + preview?: LibraryRow; + model?: DetailModel; + painted: boolean; + lastHtml?: string; + result?: DetailResult; + /** + * The document and its nonce, kept per session so re-opening the *same* + * package renders byte for byte the same document and does not reload the + * page (which would throw away the reader's scroll position). + */ + webviewDocument: WebviewDocument; +} + +/** The parse result per descriptor, so re-opening a package costs no process. */ +const parsed = new Map(); + +export async function open(session: DetailSession, id: string, preview?: LibraryRow): Promise { + if (session.panel === undefined) { + const panel = vscode.window.createWebviewPanel( + PANEL_VIEW_TYPE, + t("Library"), + vscode.ViewColumn.Active, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY)], + retainContextWhenHidden: false, + }, + ); + session.panel = panel; + panel.onDidDispose(() => { + session.panel = undefined; + session.id = undefined; + session.generation++; + session.painted = false; + session.lastHtml = undefined; + // The next open creates a brand-new webview: what was on screen says + // nothing about it, so the document comparison starts from nothing. + session.webviewDocument.invalidate(); + }); + panel.webview.onDidReceiveMessage((raw: unknown) => { + void handle(session, raw); + }); + } + const panel = session.panel; + const changed = session.id !== id; + session.id = id; + session.preview = preview; + session.deps.onPackageOpened?.(id); + if (changed) { session.generation++; session.result = undefined; } + const nextProject = session.deps.projectRoot(); + if (nextProject !== session.projectRoot) { session.generation++; session.result = undefined; } + session.projectRoot = nextProject; + panel.title = preview?.id ?? path.basename(id, ".lua"); + if (changed || !session.painted) { + const loading = emptyModel(preview?.id ?? path.basename(id, ".lua"), labels(), t("Reading package details…")); + loading.key = id; loading.generation = session.generation; loading.projectRoot = session.projectRoot; + loading.loading = true; loading.blockedReason = t("Reading package details…"); + paintModel(session, loading); + } + panel.reveal(vscode.ViewColumn.Active, false); + await render(session, id); +} + +async function handle(session: DetailSession, raw: unknown): Promise { + const message = decodeDetailMessage(raw); + if (message === undefined || session.panel === undefined || session.id === undefined) { return; } + const envelope = raw as { pageKey?: unknown; generation?: unknown }; + if (message.type === "mounted") { + if (session.model) { paintModel(session, session.model, true); } + return; + } + if (envelope.pageKey !== session.id || envelope.generation !== session.generation) { return; } + const operation = { key: session.id, projectRoot: session.projectRoot, generation: session.generation }; + const current = () => operationMatches(operation, { key: session.id ?? "", projectRoot: session.projectRoot, generation: session.generation }); + switch (message.type) { + case "selectProject": { + const root = await session.deps.chooseProject?.(); + if (root !== undefined && current()) { + session.projectRoot = root; session.generation++; session.result = undefined; + if (session.model) { paintModel(session, { ...session.model, generation: session.generation, projectRoot: root, projectState: undefined, installed: undefined, loading: true, blockedReason: t("Reading package details…"), result: undefined }); } + await render(session, session.id); + } + return; + } + case "showOutput": session.deps.output.show(true); return; + case "openManifest": + if (session.projectRoot) { await vscode.window.showTextDocument(vscode.Uri.file(path.join(session.projectRoot, "mcpp.toml"))); } + return; + case "retry": + if (session.result?.state === "error") { session.result = undefined; } + await render(session, session.id); return; + case "openDependency": + if (session.model?.dependencies.some((dependency) => dependency.key === message.key)) { await open(session, message.key); } + return; + case "openFile": { + if (!session.model?.snippets.some((snippet) => snippet.fileKey === message.key)) { return; } + if (message.key.startsWith(`${EXAMPLE_SCHEME}:`)) { await vscode.window.showTextDocument(vscode.Uri.parse(message.key)); } + return; + } + case "copy": { + let result: string; + try { await vscode.env.clipboard.writeText(message.text); result = t("Copied"); } + catch { result = t("Copy failed"); } + if (current()) { void session.panel?.webview.postMessage({ type: "copyResult", key: operation.key, generation: operation.generation, token: message.token, message: result }); } + return; + } + case "openUrl": { + // Only links in this package's model can ask the host to open a browser. + if (message.url !== session.model?.repo && message.url !== session.model?.indexUrl) { return; } + let opened = false; + try { opened = await vscode.env.openExternal(vscode.Uri.parse(message.url)); } catch { /* report below */ } + let copied = false; + if (!opened) { try { await vscode.env.clipboard.writeText(message.url); copied = true; } catch { /* accurate feedback below */ } } + if (current()) { void session.panel?.webview.postMessage({ type: "linkResult", key: operation.key, generation: operation.generation, + message: opened ? t("Opened in your browser.") : copied ? t("Could not open the browser. Link copied to clipboard.") : t("Could not open the browser or copy the link: {0}", message.url) }); } + return; + } + case "add": { + const model = session.model; + if (session.busy || !model || model.blockedReason || model.preview || !model.currentVersions.includes(message.version)) { return; } + const state = model.dependencyGroups?.[message.dev ? "dev" : "normal"] ?? model.projectState; + const other = model.dependencyGroups?.[message.dev ? "normal" : "dev"]; + if (state?.ambiguous || state?.declared && state.sourceType !== "registry" || !state?.declared && other?.declared) { return; } + session.busy = true; + session.result = { state: "pending", message: t("Adding dependency…") }; + paintModel(session, { ...model, busy: true, result: session.result }); + try { + const executable = session.deps.mcppExecutable(operation.projectRoot); + const snapshot = await loadSnapshot({ projectRoot: operation.projectRoot }); + const declarations = readProjectDeclarations(operation.projectRoot ? readTextTolerantly(path.join(operation.projectRoot, "mcpp.toml")) : ""); + const catalog = snapshot.entries.map((entry) => entry.id); + const fresh = projectDependencyState(model.id, declarations, [], catalog, message.dev ? "dev" : "normal"); + const otherGroup = projectDependencyState(model.id, declarations, [], catalog, message.dev ? "normal" : "dev"); + if (fresh.ambiguous || fresh.declared && fresh.sourceType !== "registry" || !fresh.declared && otherGroup.declared) { + if (current()) { session.result = { state: "error", message: t("The dependency declaration changed. Review mcpp.toml before adding it.") }; } + return; + } + const outcome = await addDependency({ + mcppExecutable: () => executable, + projectRoot: () => operation.projectRoot, + isTrusted: session.deps.isTrusted, + output: session.deps.output, + quiet: true, + }, { id: model.id, version: message.version, dev: message.dev }); + if (outcome.ok) { session.deps.onDependenciesChanged?.(); } + if (current() && session.panel) { + session.result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; + } else { + void (outcome.ok ? vscode.window.showInformationMessage(outcome.message) : vscode.window.showErrorMessage(outcome.message)); + } + } catch (error) { + session.deps.output.appendLine(String(error)); + if (current()) { session.result = { state: "error", message: t("Could not add the dependency. See output for details.") }; } + } finally { + session.busy = false; + if (session.panel && session.id) { await render(session, session.id); } + } + return; + } + } +} + +function paintModel(session: DetailSession, model: DetailModel, replay = false): void { + const panel = session.panel; + if (!panel) { return; } + session.model = model; + const html = renderDetailHtml(model, session.webviewDocument.assets(vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), panel.webview)); + if (session.lastHtml === html && !replay) { return; } + session.lastHtml = html; + if (!session.painted && !replay) { + session.webviewDocument.paint(panel.webview, html); session.painted = true; + } else { + void panel.webview.postMessage({ type: "detailUpdate", html, state: detailClientState(model) }); + } +} + +async function render(session: DetailSession, id: string): Promise { + const panel = session.panel; + const generation = session.generation; + const request = session.renderRequest = (session.renderRequest ?? 0) + 1; + const root = session.projectRoot; + if (!panel) { return; } + let model: DetailModel; + try { model = await buildModel(session, id, request); } + catch (error) { + session.deps.output.appendLine(String(error)); + model = emptyModel(path.basename(id, ".lua"), labels(), t("Could not read package details. See output for details.")); + model.blockedReason = model.parseNotice; + model.result = { state: "error", message: model.parseNotice ?? "" }; + } + if (session.panel !== panel || session.id !== id || generation !== session.generation || request !== session.renderRequest || root !== session.projectRoot) { return; } + paintModel(session, { ...model, key: id, generation, busy: session.busy, result: session.result ?? model.result }); +} + +async function buildModel(session: DetailSession, id: string, request: number): Promise { + const projectRoot = session.projectRoot; + const generation = session.generation; + const snapshot = await loadSnapshot(projectRoot === undefined ? {} : { projectRoot }); + const entry = snapshot.entries.find((candidate) => candidate.file === id || candidate.id === id); + // The root the descriptor lives under, matched by path: two roots may share a + // directory name, and the site link belongs to the one that holds the file. + const root = entry === undefined ? undefined : snapshot.roots.find((candidate) => { const relative = path.relative(candidate.path, entry.file); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }); + const ui = labels(); + if (entry === undefined) { + const preview = session.preview; + const model = emptyModel(preview?.id ?? path.basename(id, ".lua"), ui, preview ? t("This is a search preview. Set an index path containing this package to read its complete details.") : t("This package is not in the local index any more.")); + model.preview = preview !== undefined; + model.previewVersion = preview?.version; + model.description = preview?.description; + model.registry = preview?.source ?? ""; + model.projectRoot = projectRoot; + model.blockedReason = preview ? t("Search previews cannot be added. Open a complete local descriptor first.") : t("The package descriptor is missing. Reread the local index."); + return model; + } + const text = cachedDescriptorText(entry.file) ?? (await readDescriptorText(entry.file)) ?? ""; + const platform = platformKey(process.platform); + // Keyed by the index revision as well as the path, so a refreshed index is not + // answered from a parse of the previous one. + const cacheKey = `${snapshot.revision}\u0000${entry.file}\u0000${session.deps.isTrusted()}\u0000${session.deps.mcppExecutable(projectRoot)}\u0000${projectRoot ?? ""}`; + let info = parsed.get(cacheKey); + if (!parsed.has(cacheKey)) { + if (session.id === id && session.generation === generation && session.renderRequest === request && session.model?.loading) { + const lightweight: DetailModel = { + ...emptyModel(entry.id, ui, t("Reading package details…")), + key: id, generation, projectRoot, platform: platform ?? process.platform, + description: entry.description, registry: entry.registry, surface: entry.surface, surfaces: entry.surfaces, + licenses: entry.licenses, repo: entry.repo, badges: badgesOf(entry), + currentVersions: entry.versions[platform ?? ""] ?? [], latest: entry.version, + versions: versionGroups(entry.versions, platform).map((group) => ({ ...group, current: group.platform === platform })), + loading: true, blockedReason: t("Reading package details…"), dataSource: dataSource(entry, snapshot.roots.length), + }; + paintModel(session, lightweight); + } + info = await readXpkg(session, entry, projectRoot); + if (info !== undefined) { parsed.set(cacheKey, info); } + while (parsed.size > 100) { parsed.delete(parsed.keys().next().value!); } + } + + const versions = info === undefined ? entry.versions : info.versions; + const groups: DetailVersionGroup[] = versionGroups(versions, platform).map((group) => ({ + platform: group.platform, + versions: group.versions, + current: group.platform === platform, + })); + const currentVersions = platform === undefined ? [] : (versions[platform] ?? []); + const latest = currentVersions.length === 0 ? undefined : sortVersions(currentVersions)[0]; + + const localExamples = entry.example === undefined ? [] : await readExampleFiles(root ? [root] : [], entry.example); + const bundled = root?.kind === "official" && localExamples.length === 0 ? bundledExample(entry.id) : undefined; + const exampleFiles = localExamples.length ? localExamples : bundled?.files ?? []; + const snippets = exampleFiles.slice(0, 2).map((file) => ({ + file: file.path, startLine: 1, usageLine: 0, lines: file.text.split(/\r?\n/).slice(0, 80), + fileKey: session.exampleDocuments.capture(file), + })); + // Only verified lines from real example source are copyable. + const realUsage = usageLinesFor(exampleFiles, entry.id); + const usage = realUsage; + + // The lock answers what a build actually resolved for each declared edge — + // the same matching rule `installedFor` uses for this package itself. + const lockText = projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.lock")) : ""; + const manifestText = projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.toml")) : ""; + if (projectRoot) { session.deps.onProjectFilesRead?.(projectRoot, manifestText, lockText); } + const lock = lockPackageVersions(lockText); + const declarations = readProjectDeclarations(manifestText); + const catalog = snapshot.entries.map((entry) => entry.id); + const dependencyGroups = { + normal: projectDependencyState(entry.id, declarations, lock, catalog, "normal"), + dev: projectDependencyState(entry.id, declarations, [], catalog, "dev"), + build: projectDependencyState(entry.id, declarations, [], catalog, "build"), + }; + const dependencyKind = dependencyGroups.normal.declared ? "normal" : dependencyGroups.dev.declared ? "dev" : dependencyGroups.build.declared ? "build" : "normal"; + const state = dependencyGroups[dependencyKind]; + const dependencies = descriptorDependencies(text).map((dependency) => { + const resolved = lock.find((entry) => entry.id === dependency.id)?.version; + const local = snapshot.entries.find((entry) => entry.id === dependency.id); + return { ...dependency, ...(resolved ? { resolved } : {}), ...(local ? { key: local.file } : {}) }; + }); + const model: DetailModel = { + ui, + projectRoot, + manifestAvailable: !!projectRoot && existsSync(path.join(projectRoot, "mcpp.toml")), + platform: platform ?? process.platform, + projectState: state, + dependencyGroups, + dependencyKind, + blockedReason: !session.deps.isTrusted() ? t("Trust this workspace to run mcpp and add dependencies.") + : !projectRoot || !existsSync(path.join(projectRoot, "mcpp.toml")) ? t("Select an mcpp project before adding this library.") + : platform === undefined ? t("The current environment platform is not recognized by this index.") + : latest === undefined ? info === undefined ? t("Version availability could not be confirmed. Retry reading this descriptor.") : t("No version is published for the current environment platform.") + : undefined, + versionNotice: info === undefined ? t("Versions are from descriptor text; mcpp will confirm availability when adding.") : undefined, + id: entry.id, + name: entry.name, + ...(entry.description === undefined ? {} : { description: entry.description }), + licenses: entry.licenses, + ...(entry.repo === undefined ? {} : { repo: entry.repo }), + registry: entry.registry, + ...(mergeSurfaces(info, text)[0] === undefined ? {} : { surface: mergeSurfaces(info, text)[0] }), + surfaces: mergeSurfaces(info, text), + badges: badgesOf(entry), + versions: groups, + currentVersions: sortVersions(currentVersions), + ...(latest === undefined ? {} : { latest }), + ...(info?.standard === undefined ? {} : { standard: info.standard }), + dependencies, + includeDirs: info?.includeDirs ?? [], + targets: (info?.targets ?? []).map((target) => target.name ?? "").filter((name) => name.length > 0), + snippets, + ...(bundled ? { exampleProject: bundled.project, exampleProvenance: t("Bundled official index tests · commit {0}", bundled.commit.slice(0, 12)) } : entry.example ? { exampleProject: entry.example.project } : {}), + usage, + // One discreet link, and only when this root really is the index that + // publishes those package pages (see `IndexRoot.site`). + ...(root?.site === undefined ? {} : { indexUrl: `${root.site}/${entry.id}/` }), + commandTemplate: t("mcpp add {0}@{1}"), + commandDevTemplate: t("mcpp add {0}@{1} --dev"), + ...(info === undefined + ? { + parseNotice: session.deps.isTrusted() + ? t( + "mcpp xpkg parse could not read this descriptor; the versions below come from its text, which is less authoritative.", + ) + : t("This workspace is not trusted, so mcpp is not run; the versions below come from the descriptor's text."), + } + : {}), + dataSource: dataSource(entry, snapshot.roots.length), + }; + return model; +} + +/** Run the authoritative reader for one descriptor; never throws. */ +async function readXpkg(session: DetailSession, entry: LibraryEntry, projectRoot?: string): Promise { + // `mcpp.path` is resource-scoped, so an untrusted workspace must not name + // the program: the refusal degrades to the descriptor's own text. + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Reading {0}…", entry.id) }, + () => + runMcpp(session.deps.isTrusted(), session.deps.mcppExecutable(projectRoot), ["xpkg", "parse", entry.file, "--json"], projectRoot, { + timeoutMs: PARSE_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (result === undefined) { + return undefined; + } + if (result.exitCode !== 0) { + session.deps.output.appendLine(`mcpp xpkg parse ${entry.file} failed with exit code ${result.exitCode}`); + if (result.stderr.trim().length > 0) { + session.deps.output.appendLine(result.stderr.trimEnd()); + } + return undefined; + } + return parseXpkgJson(result.stdout); +} + +function dataSource(entry: LibraryEntry, roots: number): string { + return t( + "Read offline from {0} in the {1} index ({2} index folder(s) found).", + entry.file, + entry.registry, + roots, + ); +} + +/** A model that still renders: the page says why it is empty instead of going blank. */ +function emptyModel(id: string, ui: Record, notice: string): DetailModel { + return { + ui, + id, + name: id, + licenses: [], + registry: "", + surfaces: [], + badges: [], + versions: [], + currentVersions: [], + dependencies: [], + includeDirs: [], + targets: [], + snippets: [], + usage: [], + commandTemplate: t("mcpp add {0}@{1}"), + commandDevTemplate: t("mcpp add {0}@{1} --dev"), + parseNotice: notice, + dataSource: t("No descriptor was read."), + }; +} + +function readTextTolerantly(file: string): string { + try { return readFileSync(file, "utf8"); } catch { return ""; } +} + +/** Every visible string, resolved once per model. */ +function labels(): Record { + return { + [DETAIL_UI.htmlLang]: htmlLanguage(), + [DETAIL_UI.platforms]: t("Platforms and versions"), + [DETAIL_UI.kind]: t("Dependency type"), + [DETAIL_UI.kindNormal]: t("Project dependency"), + [DETAIL_UI.kindDev]: t("Development dependency"), + [DETAIL_UI.kindBuild]: t("Build dependency"), + [DETAIL_UI.addDevAction]: t("Add development dependency"), + [DETAIL_UI.buildHint]: t("Build dependencies are used by the build script. Copy this entry into mcpp.toml; mcpp add does not support this group yet."), + [DETAIL_UI.moveHint]: t("Changing this declaration requires a manifest edit. Copy the entry and update its dependency group in mcpp.toml."), + [DETAIL_UI.chooseProject]: t("Choose project"), + [DETAIL_UI.platform]: t("Environment platform"), + [DETAIL_UI.version]: t("Version"), + [DETAIL_UI.otherPlatforms]: t("Other platforms (read only)"), + [DETAIL_UI.declared]: t("Declared"), + [DETAIL_UI.locked]: t("Resolved in lockfile"), + [DETAIL_UI.sourceDependency]: t("Source dependency"), + [DETAIL_UI.unknownUsage]: t("No verified import or header path is available locally. See the repository or index site for usage."), + [DETAIL_UI.usageSource]: t("From the index's example source"), + [DETAIL_UI.source]: t("Source and diagnostics"), + [DETAIL_UI.loading]: t("Reading package details…"), + [DETAIL_UI.pending]: t("Adding dependency…"), + [DETAIL_UI.groupChange]: t("Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration."), + [DETAIL_UI.output]: t("Show output"), + [DETAIL_UI.retry]: t("Retry reading details"), + [DETAIL_UI.openManifest]: t("Open mcpp.toml"), + [DETAIL_UI.openFile]: t("Source"), + [DETAIL_UI.readOnlySource]: t("Open read-only source"), + [DETAIL_UI.preview]: t("Search preview: only the fields returned by mcpp search are available."), + [DETAIL_UI.title]: t("Library package {0}"), + [DETAIL_UI.overview]: t("Build shape"), + [DETAIL_UI.license]: t("License"), + [DETAIL_UI.repo]: t("Repository"), + [DETAIL_UI.openRepo]: t("Open the repository"), + [DETAIL_UI.registry]: t("Registry"), + [DETAIL_UI.surface]: t("Use"), + [DETAIL_UI.surfaceExternal]: t("upstream mcpp.toml"), + [DETAIL_UI.standard]: t("Standard"), + [DETAIL_UI.versions]: t("Versions ({0})"), + [DETAIL_UI.versionsAll]: t("Versions"), + [DETAIL_UI.versionsCurrent]: t("this platform"), + [DETAIL_UI.versionsNone]: t("This index publishes no version for any platform."), + [DETAIL_UI.versionsPick]: t("Click a version to aim the command above at it."), + [DETAIL_UI.dependencies]: t("Dependencies"), + [DETAIL_UI.dependenciesNone]: t("This descriptor declares no dependencies."), + [DETAIL_UI.dependenciesHint]: t( + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.", + ), + [DETAIL_UI.resolved]: t("resolved {0}"), + [DETAIL_UI.code]: t("Example code"), + [DETAIL_UI.codeNone]: t("This package has no test project in the index, so there is no example to show."), + [DETAIL_UI.codeSource]: t("{0} · line {1}"), + [DETAIL_UI.codeProject]: t("Example project: {0} — built and run by the index's CI, not written for this page."), + [DETAIL_UI.add]: t("Add dependency"), + [DETAIL_UI.addDev]: t("dev dependency"), + [DETAIL_UI.addLatest]: t("The version is required: mcpp accepts an exact version only."), + [DETAIL_UI.switchTo]: t("Switch to {0}"), + [DETAIL_UI.alreadyAdded]: t("Already declared"), + [DETAIL_UI.installed]: t("added"), + [DETAIL_UI.opening]: t("Opening {0}…"), + [DETAIL_UI.addNoVersion]: t("This index publishes no version for this platform, so there is nothing to add."), + [DETAIL_UI.command]: t("Command"), + [DETAIL_UI.usage]: t("Usage"), + [DETAIL_UI.copy]: t("Copy"), + [DETAIL_UI.copied]: t("Copied to the clipboard."), + [DETAIL_UI.copying]: t("Copying…"), + [DETAIL_UI.indexLink]: t("Open on the index site"), + [DETAIL_UI.badgeExamples]: t("✓ Has examples"), + [DETAIL_UI.badgeCn]: t("China mirror"), + [DETAIL_UI.badgeOpenkalEcosystem]: t("openkal-ecosystem"), + [DETAIL_UI.badgeOpenkalCompat]: t("openkal-compat"), + [DETAIL_UI.badgeOpenkalPosix]: t("POSIX environment"), + [DETAIL_UI.badgeOpenkalPlatform]: t("uses platform interfaces"), + [DETAIL_UI.targets]: t("Targets"), + [DETAIL_UI.includeDirs]: t("Include directories"), + }; +} + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} + +/** Kept next to the model so the surface vocabulary has one import site. */ +export { surfaceLabel }; +export type { Surface }; + +/** Greatest first — the order `xpkg.latestVersion` picks from. */ +function sortVersions(versions: readonly string[]): string[] { + return [...versions].sort((a, b) => compareVersions(b, a)); +} diff --git a/src/library/examples.ts b/src/library/examples.ts new file mode 100644 index 0000000..71a3c00 --- /dev/null +++ b/src/library/examples.ts @@ -0,0 +1,22 @@ +import snapshot from "../../data/library-examples.json"; +import { ReadOnlyDocuments } from "../webview/readOnlyDocuments"; +import { exampleCatalog, type CodeFile } from "./indexModel"; + +const catalog = exampleCatalog(snapshot.projects.map((project) => ({ + project: project.project, text: project.text, sources: project.files.map((file) => file.path), +}))); +const files = new Map(snapshot.projects.flatMap((project) => project.files.map((file) => [file.path, file.text] as const))); + +/** Official-index fallback only. Local checkouts use their own current tests. */ +export function bundledExample(id: string): { project: string; files: CodeFile[]; commit: string } | undefined { + const ref = catalog.get(id); + return ref ? { project: ref.project, commit: snapshot.commit, files: ref.paths.map((path) => ({ path, text: files.get(path)! })) } : undefined; +} + +export function bundledExampleText(path: string): string | undefined { return files.get(path.replace(/^\//, "")); } +export const EXAMPLE_SCHEME = "mcpp-example"; + +/** Immutable virtual documents keep local and bundled index sources equally read-only. */ +export class ExampleDocuments extends ReadOnlyDocuments { + constructor(captured?: (uri: string) => void) { super(EXAMPLE_SCHEME, captured); } +} diff --git a/src/library/indexLocator.ts b/src/library/indexLocator.ts new file mode 100644 index 0000000..afa6291 --- /dev/null +++ b/src/library/indexLocator.ts @@ -0,0 +1,439 @@ +/** Local package discovery. Official indexes are separate from the xlings tool registry. + * Descriptor contents are cached independently from the project's dependency state. + */ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { projectDependencyState, readProjectDeclarations } from "./projectState"; +import { + descriptorEntry, + descriptorId, + exampleCatalog, + openkalFacetFor, + parseDescriptorLua, + parseOpenkalJson, + parseSelfEnv, + platformKey, + type CodeFile, + type ExampleInput, + type ExampleRef, + type LibraryEntry, + type OpenkalIndex, +} from "./indexModel"; + +/** How many descriptor files a walk will read before it says the index is wrong. */ +const MAX_DESCRIPTORS = 20_000; + +/** + * How long `mcpp self env --format json` may take. It only runs when the cheap + * globs found nothing, and a hang there must not hold the view hostage. + */ +const SELF_ENV_TIMEOUT_MS = 15_000; + +/** The index site of the official C++ library index, and its package pages. */ +export const INDEX_SITE = "https://mcpplibs.github.io/mcpp-index/packages"; + +/** A directory that holds `pkgs/`, and the shared metadata beside it. */ +export interface IndexRoot { + /** The directory name, e.g. `mcpplibs`. */ + registry: string; + kind: "official" | "custom"; + updatedAt?: number; + /** The index root: it contains `pkgs`, and may contain `tests/` and `.xpkgindex/`. */ + path: string; + /** `/pkgs`. */ + pkgs: string; + /** `/tests/examples` exists, so the example code can be shown. */ + hasExamples: boolean; + /** `/.xpkgindex/openkal-compat.json` exists. */ + hasOpenkal: boolean; + /** + * The package page base URL of the index site, when this root is the official + * index. + * + * Two shapes of the same index occur: the packed copy mcpp installs into + * `/.mcpp/registry/data/mcpplibs`, and a git checkout of `mcpp-index` + * (whose directory is named after the repository). The generator itself — + * `.xpkgindex/plugins/mcpp.py`, which only that repository carries — is the + * signal that this root is that index, so a checkout gets the deep link too. + */ + site?: string; +} + +/** Everything the view renders, read once per revision. */ +export interface LibrarySnapshot { + entries: LibraryEntry[]; + roots: IndexRoot[]; + revision: string; +} + +/** `~` is what a user types; the file system wants the home directory. */ +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory(); + } catch { + return false; + } +} + +async function isFile(target: string): Promise { + try { + return (await fs.stat(target)).isFile(); + } catch { + return false; + } +} + +/** One index root, or `undefined` when the directory is not one. */ +async function rootAt(directory: string, official = false): Promise { + const pkgs = path.join(directory, "pkgs"); + if (!(await isDirectory(pkgs))) { + return undefined; + } + directory = await fs.realpath(directory); + const registry = path.basename(directory); + const updated = await statSafe(path.join(directory, ".mcpp-index-updated")); + const generator = await isFile(path.join(directory, ".xpkgindex", "plugins", "mcpp.py")); + return { + registry, + kind: official ? "official" : "custom", + ...(updated === undefined ? {} : { updatedAt: updated.mtimeMs }), + path: directory, + pkgs: path.join(directory, "pkgs"), + hasExamples: await isDirectory(path.join(directory, "tests", "examples")), + hasOpenkal: await isFile(path.join(directory, ".xpkgindex", "openkal-compat.json")), + ...(official || generator ? { site: INDEX_SITE } : {}), + }; +} + +/** Explicit paths never silently fall back. Only the official library source is auto-discovered. */ +export async function readIndexRoots(projectRoot?: string): Promise { + const configured = read("mcpp.library.indexPath"); + return discoverIndexRoots(configured, configured.trim() ? path.join(os.homedir(), ".mcpp") : await activeHome(projectRoot)); +} + +/** Filesystem seam used by the extension and fixture-based tests. */ +export async function discoverIndexRoots(configured: string, home: string): Promise { + if (configured.trim()) { + const target = path.resolve(expandHome(configured.trim())); + const own = await rootAt(path.basename(target) === "pkgs" ? path.dirname(target) : target); + const roots = own === undefined + ? (await Promise.all((await readdirSafe(target)).sort().map((name) => rootAt(path.join(target, name))))) + .filter((root): root is IndexRoot => root !== undefined) + : [own]; + if (roots.length === 0) { + throw new Error(`No package index (pkgs/) at ${target}`); + } + return roots.filter((root, index) => roots.findIndex((other) => other.path === root.path) === index); + } + for (const directory of [path.join(home, "indices", "mcpplibs"), path.join(home, "registry", "data", "mcpplibs")]) { + const root = await rootAt(directory, true); + if (root !== undefined) { return [root]; } + } + return []; +} + +let homeProbe: { key: string; promise: Promise } | undefined; + +/** CLI also supports self-contained installs: ask it once rather than merge unrelated homes. */ +async function activeHome(projectRoot?: string): Promise { + const explicit = process.env.MCPP_HOME?.trim(); + if (explicit) { return path.resolve(expandHome(explicit)); } + return (await mcppHomeFromCli(projectRoot)) ?? path.join(os.homedir(), ".mcpp"); +} + +export function invalidateIndexCache(): void { + cache = undefined; + homeProbe = undefined; +} + +/** Share concurrent probes, with separate answers for different configured executables/projects. */ +async function mcppHomeFromCli(projectRoot?: string): Promise { + const resource = projectRoot === undefined ? undefined : vscode.Uri.file(projectRoot); + const executable = read("mcpp.path", resource).trim() || "mcpp"; + const key = `${executable}\u0000${projectRoot ?? ""}\u0000${vscode.workspace.isTrusted}`; + if (homeProbe?.key === key) { return homeProbe.promise; } + const promise = (async () => { + const result = await runMcpp(vscode.workspace.isTrusted, executable, ["self", "env", "--format", "json"], projectRoot, + { timeoutMs: SELF_ENV_TIMEOUT_MS, maxBufferMiB: 1 }); + if (result === undefined || result.exitCode !== 0) { return undefined; } + const env = parseSelfEnv(result.stdout); + return env?.mcppHome ?? (env?.registry === undefined ? undefined : path.dirname(env.registry)); + })(); + homeProbe = { key, promise }; + return promise; +} + +export { readIndexRoots as locateIndexRoots }; + +/** A directory listing, or nothing when it cannot be read. */ +async function readdirSafe(directory: string): Promise { + try { + return await fs.readdir(directory); + } catch { + return []; + } +} + +/** Every `*.lua` under `/pkgs`, depth first, with a hard cap. */ +async function descriptorFiles(pkgs: string): Promise { + const out: string[] = []; + const walk = async (directory: string): Promise => { + if (out.length >= MAX_DESCRIPTORS) { + return; + } + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (out.length >= MAX_DESCRIPTORS) { + return; + } + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile() && entry.name.endsWith(".lua")) { + out.push(full); + } + } + }; + await walk(pkgs); + return out.sort(); +} + +/** Include source identity and every descriptor stat, including checkout edits without a marker. */ +async function revisionOf(roots: readonly IndexRoot[]): Promise { + const parts: string[] = []; + for (const root of roots) { + const files = await descriptorFiles(root.pkgs); + const stats = await Promise.all(files.map(async (file) => `${file}:${JSON.stringify(await statSafe(file))}`)); + parts.push(`${root.path}:${root.kind}:${root.updatedAt ?? ""}:${stats.join(";")}`); + } + return createHash("sha256").update(parts.join("|")).digest("hex"); +} + +async function statSafe(target: string): Promise<{ mtimeMs: number; size: number } | undefined> { + try { + const stat = await fs.stat(target); + return { mtimeMs: stat.mtimeMs, size: stat.size }; + } catch { + return undefined; + } +} + +/** + * The example projects of one index root, read as manifests plus the *names* of + * their test files. The file contents are read later, only for the package the + * reader actually opened. + */ +async function readExamples(root: IndexRoot): Promise { + if (!root.hasExamples) { + return []; + } + const base = path.join(root.path, "tests", "examples"); + const out: ExampleInput[] = []; + for (const project of await readdirSafe(base)) { + const directory = path.join(base, project); + const manifest = path.join(directory, "mcpp.toml"); + let text: string; + try { + text = await fs.readFile(manifest, "utf8"); + } catch { + continue; + } + const tests = path.join(directory, "tests"); + const sources: string[] = []; + for (const file of (await readdirSafe(tests)).sort()) { + if (file.endsWith(".cpp") || file.endsWith(".cppm") || file.endsWith(".cc")) { + sources.push(path.relative(root.path, path.join(tests, file)).split(path.sep).join("/")); + } + } + if (sources.length > 0) { + out.push({ project, text, sources }); + } + } + return out; +} + +/** The openkal measurement, merged across roots; the first one wins per member. */ +async function readOpenkal(roots: readonly IndexRoot[]): Promise { + const members: OpenkalIndex["members"] = {}; + let measured: string | undefined; + let found = false; + for (const root of roots) { + if (!root.hasOpenkal) { + continue; + } + let text: string; + try { + text = await fs.readFile(path.join(root.path, ".xpkgindex", "openkal-compat.json"), "utf8"); + } catch { + continue; + } + const parsed = parseOpenkalJson(text); + if (parsed === undefined) { + continue; + } + found = true; + measured = measured ?? parsed.measured; + for (const [name, member] of Object.entries(parsed.members)) { + if (members[name] === undefined) { + members[name] = member; + } + } + } + return found ? { members, ...(measured === undefined ? {} : { measured }) } : undefined; +} + +/** Project overlay is always fresh and never changes the descriptor cache. */ +async function withProject(snapshot: LibrarySnapshot, projectRoot?: string): Promise { + const text = projectRoot === undefined ? "" : await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8").catch(() => ""); + const declarations = readProjectDeclarations(text); + const ids = snapshot.entries.map((entry) => entry.id); + return { ...snapshot, entries: snapshot.entries.map((entry) => ({ + ...entry, added: (["normal", "dev", "build"] as const).some((kind) => projectDependencyState(entry.id, declarations, [], ids, kind).declared), + })) }; +} + +interface Cache { + revision: string; + snapshot: LibrarySnapshot; + /** Descriptor text, kept so the detail page does not re-read what the list did. */ + texts: Map; +} + +let cache: Cache | undefined; + +/** + * The list, read from disk only when the revision changed. + * + * Only descriptor contents are cached. Project declarations are reapplied on + * every read, so disk writes and project switches cannot reuse old badges. + */ +export async function loadSnapshot(options: { projectRoot?: string; platform?: string } = {}): Promise { + const roots = await readIndexRoots(options.projectRoot); + const revision = `${await revisionOf(roots)}|${options.platform ?? platformKey(process.platform)}`; + if (cache !== undefined && cache.revision === revision) { + return withProject(cache.snapshot, options.projectRoot); + } + if (roots.length === 0) { + const empty: LibrarySnapshot = { entries: [], roots, revision }; + cache = { revision, snapshot: empty, texts: new Map() }; + return empty; + } + + const platform = options.platform ?? platformKey(process.platform); + const entries: LibraryEntry[] = []; + const texts = new Map(); + /** Example refs, keyed by package id, from every root that has examples. */ + const exampleRefs = new Map(); + for (const root of roots) { + for (const [id, ref] of exampleCatalog(await readExamples(root))) { + exampleRefs.set(`${root.path}\u0000${id}`, ref); + } + } + + for (const root of roots) { + const openkal = await readOpenkal([root]); + for (const file of await descriptorFiles(root.pkgs)) { + let text: string; + try { + text = await fs.readFile(file, "utf8"); + } catch { + continue; + } + const fileName = path.basename(file); + const fields = parseDescriptorLua(text); + // `descriptorEntry` decides the identity; the example and openkal lookups + // below must use that same id, so it is computed the same way here. + const id = descriptorId(fields, fileName).id; + const example = exampleRefs.get(`${root.path}\u0000${id}`); + const facet = openkalFacetFor(openkal, id); + const entry = descriptorEntry({ + fileName, + registry: root.registry, + file, + text, + platform, + added: false, + ...(example === undefined ? {} : { example }), + ...(facet === undefined ? {} : { openkal: facet }), + }); + entries.push(entry); + texts.set(entry.file, text); + } + } + + entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const snapshot: LibrarySnapshot = { entries, roots, revision }; + cache = { revision, snapshot, texts }; + return withProject(snapshot, options.projectRoot); +} + +/** The descriptor text of one package, from the cache when possible. */ +export async function readDescriptorText(file: string): Promise { + try { + return await fs.readFile(file, "utf8"); + } catch { + return undefined; + } +} + +/** The cached text for a package id, when the last snapshot read it. */ +export function cachedDescriptorText(file: string): string | undefined { + return cache?.texts.get(file); +} + +/** + * The example files behind a package: real code from + * `tests/examples//tests`, which CI builds and runs. + * + * The ref's `paths` are relative to the index root, so the root is found by + * matching the prefix. Missing files are skipped, never invented. + */ +export async function readExampleFiles(roots: readonly IndexRoot[], example: ExampleRef): Promise { + const files: CodeFile[] = []; + for (const root of roots) { + if (!root.hasExamples) { + continue; + } + for (const relative of example.paths) { + const absolute = path.join(root.path, relative); + if (path.relative(root.path, absolute).startsWith("..") || path.isAbsolute(path.relative(root.path, absolute))) { + continue; + } + try { + files.push({ path: relative, text: await fs.readFile(absolute, "utf8") }); + } catch { + // A file that disappeared between the listing and the read. + } + } + if (files.length > 0) { + return files; + } + } + return files; +} + +/** Test seam: forget the cached snapshot. */ +export function resetSnapshotCache(): void { + cache = undefined; +} diff --git a/src/library/indexModel.ts b/src/library/indexModel.ts new file mode 100644 index 0000000..a0b486a --- /dev/null +++ b/src/library/indexModel.ts @@ -0,0 +1,1525 @@ +/** + * The library view's model: everything that can be decided without an editor. + * + * The index is a directory of Lua descriptors on disk + * (`/.mcpp/registry/data//pkgs//.lua`). The list + * phase has a hard budget: **no process per package** — 239 `mcpp` spawns would + * freeze the sidebar — so the catalog fields (`namespace`, `name`, + * `description`, `licenses`, `repo`, whether a `CN` mirror exists) and the + * version numbers are read out of the Lua **text**, tolerantly, and anything + * that does not fit a single-line shape is simply left out. Every authoritative + * value comes from `mcpp xpkg parse --json` instead (`src/library/xpkg.ts`), + * which runs only when a row is expanded or the detail page opens. + * + * The vocabulary is the index site generator's, not ours + * (`.xpkgindex/plugins/mcpp.py`): the usage label is one of the four `SURFACES`, + * the badges are the generator's own badges, and the openkal facets are + * **measurements** recorded in `.xpkgindex/openkal-compat.json` — never + * descriptor fields, so they are read from that file or not shown at all. + * + * This module is `vscode`-free on purpose (`test/architecture.test.ts`): the + * escaping, the tolerant parsing, the version ordering and the example lookup are + * all unit-testable without an editor host. + */ + +import { + SURFACE_TEXT, + SURFACES, + latestVersion, + parseXpkgJson, + platformKey, + surfacesOf, + textNamesBinaryTarget, + versionGroups, + versionsFor, + type Surface, + type XpkgInfo, +} from "./xpkg"; + +// ─────────────────────────────────────────────────────────────── vocabulary ── + +/** + * The packages that make up openkal — the site generator's `OPENKAL_FAMILY`, + * copied verbatim (it is a list of **names**, not prose, so it is not + * translated). `openkal-compat` needs no list: it is read from the measurement + * file. A rename upstream is a one-line change here. + */ +export const OPENKAL_FAMILY: readonly string[] = [ + "mcpplibs.openkal", + "mcpplibs.openkal-kit", + "mcpplibs.openkal-linux", + "mcpplibs.openkal-macos", + "mcpplibs.openkal-windows", + "mcpplibs.openkal-uefi", + "mcpplibs.openkal-opensbi", + "mcpplibs.openkal-emscripten", + "mcpplibs.openkal-libc", + "mcpplibs.openkal-musl", + "mcpplibs.openkal-llvm-runtime", + "mcpplibs.std-freestanding-alloc-kal", +]; + +/** Every badge the view may show, in the order it shows them (§12.5). */ +export type BadgeKey = + | "examples" + | "cn" + | "openkal-ecosystem" + | "openkal-compat" + | "openkal-posix" + | "openkal-platform"; + +/** The `ui` key and English fallback for each badge. */ +export const BADGE_UI: Readonly> = { + examples: { key: "library.badge.examples", fallback: "✓ Has examples" }, + cn: { key: "library.badge.cn", fallback: "China mirror" }, + "openkal-ecosystem": { key: "library.badge.openkalEcosystem", fallback: "openkal-ecosystem" }, + "openkal-compat": { key: "library.badge.openkalCompat", fallback: "openkal-compat" }, + "openkal-posix": { key: "library.badge.openkalPosix", fallback: "POSIX environment" }, + "openkal-platform": { key: "library.badge.openkalPlatform", fallback: "uses platform interfaces" }, +}; + +/** The current platform, as `mcpp` names it. Re-exported so callers need one import. */ +export { platformKey }; + +// ──────────────────────────────────────────────────────────────── Lua text ── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Drop Lua comments without touching string literals. + * + * It matters: descriptors are heavily commented, and a comment that mentions + * `kind = "bin"` or a `.cppm` path must not become a fact about the package. + * Line comments (`-- …`), long comments (`--[[ … ]]`, `--[=[ … ]=]`) and the + * long strings they can be confused with are all handled; `"` and `'` literals + * are copied through with their escapes. + */ +export function stripLuaComments(text: string): string { + let out = ""; + let index = 0; + while (index < text.length) { + const char = text[index]; + if (char === '"' || char === "'") { + const quote = char; + out += char; + index += 1; + while (index < text.length) { + const inner = text[index]; + out += inner; + index += 1; + if (inner === "\\") { + if (index < text.length) { + out += text[index]; + index += 1; + } + continue; + } + if (inner === quote) { + break; + } + } + continue; + } + if (char === "-" && text[index + 1] === "-") { + const long = /^\[(=*)\[/.exec(text.slice(index + 2)); + if (long !== null) { + const close = `]${long[1]}]`; + const end = text.indexOf(close, index + 2 + long[0].length); + index = end === -1 ? text.length : end + close.length; + continue; + } + const newline = text.indexOf("\n", index); + index = newline === -1 ? text.length : newline; + continue; + } + out += char; + index += 1; + } + return out; +} + +/** + * The body of the `{ … }` that starts at `open` (the index of the brace), or + * `undefined` when the braces do not balance. String literals are skipped so a + * `}` inside a URL cannot close the block early. + */ +export function bracedBody(text: string, open: number): { body: string; end: number } | undefined { + if (text[open] !== "{") { + return undefined; + } + let depth = 0; + let index = open; + while (index < text.length) { + const char = text[index]; + if (char === '"' || char === "'") { + const quote = char; + index += 1; + while (index < text.length && text[index] !== quote) { + index += text[index] === "\\" ? 2 : 1; + } + index += 1; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return { body: text.slice(open + 1, index), end: index + 1 }; + } + } + index += 1; + } + return undefined; +} + +/** The `{ … }` assigned to `key`, searching the whole text. */ +export function keyBlock(text: string, key: string): string | undefined { + const pattern = new RegExp(`(?:^|[^\\w.])${key}\\s*=\\s*\\{`, "m"); + const match = pattern.exec(text); + if (match === null) { + return undefined; + } + const open = match.index + match[0].length - 1; + return bracedBody(text, open)?.body; +} + +/** One `key = value` pair at the top level of a Lua table body. */ +export interface LuaEntry { + key: string; + /** A string literal's contents, or the raw body of a `{ … }` value. */ + value?: string; + body?: string; +} + +/** + * The assignments at the **top level** of a table body, in source order. + * + * Only the top level, deliberately: `package` contains the whole `xpm` table, + * and a `name = "x"` nested inside a manifest target is not the package name. + * A value that is neither a one-line string nor a brace block is skipped — the + * caller then omits the field rather than guessing at it. + */ +export function topLevelEntries(body: string): LuaEntry[] { + const entries: LuaEntry[] = []; + let depth = 0; + let index = 0; + while (index < body.length) { + const char = body[index]; + if (char === '"' || char === "'") { + const quote = char; + index += 1; + while (index < body.length && body[index] !== quote) { + index += body[index] === "\\" ? 2 : 1; + } + index += 1; + continue; + } + if (char === "{" || char === "(") { + depth += 1; + index += 1; + continue; + } + if (char === "}" || char === ")") { + depth -= 1; + index += 1; + continue; + } + if (depth === 0) { + const quoted = /^\[["']([^"']*)["']\]\s*=\s*/.exec(body.slice(index)); + const bare = quoted === null ? /^([A-Za-z_]\w*)\s*=\s*/.exec(body.slice(index)) : null; + const match = quoted ?? bare; + if (match !== null) { + const key = quoted === null ? bare![1] : quoted[1]; + let cursor = index + match[0].length; + while (cursor < body.length && /\s/.test(body[cursor])) { + cursor += 1; + } + if (body[cursor] === "{") { + const block = bracedBody(body, cursor); + if (block === undefined) { + return entries; + } + entries.push({ key, body: block.body }); + index = block.end; + continue; + } + if (body[cursor] === '"' || body[cursor] === "'") { + const quote = body[cursor]; + let end = cursor + 1; + let value = ""; + while (end < body.length && body[end] !== quote) { + if (body[end] === "\\") { + value += body[end + 1] ?? ""; + end += 2; + continue; + } + value += body[end]; + end += 1; + } + // A value that spans a line break is not the single-line shape the + // catalog fields are written in; it is omitted rather than guessed at. + if (!value.includes("\n") && !value.includes("\r")) { + entries.push({ key, value }); + } + index = end + 1; + continue; + } + // A number, a boolean or an expression: not a catalog field. + const lineEnd = body.indexOf("\n", cursor); + index = lineEnd === -1 ? body.length : lineEnd; + continue; + } + } + index += 1; + } + return entries; +} + +/** Every `"…"` literal in a table body, in order — the shape of `licenses`. */ +export function stringLiterals(body: string): string[] { + const out: string[] = []; + for (const match of body.matchAll(/"((?:[^"\\]|\\.)*)"/g)) { + out.push(match[1].replace(/\\(.)/g, "$1")); + } + return out; +} + +/** The catalog fields of one descriptor, as far as its text states them. */ +export interface DescriptorFields { + namespace?: string; + name?: string; + description?: string; + licenses: string[]; + repo?: string; +} + +/** + * Parse a descriptor's Lua text tolerantly. Never throws, and never invents a + * value: a field written across lines, escaped unusually, or nested somewhere + * unexpected is left out, and the caller falls back to the file name and says + * the descriptor could not be read. + */ +export function parseDescriptorLua(text: string): DescriptorFields { + const clean = stripLuaComments(text); + const body = keyBlock(clean, "package") ?? clean; + const entries = topLevelEntries(body); + const fields: DescriptorFields = { licenses: [] }; + for (const entry of entries) { + if (entry.body !== undefined) { + if (entry.key === "licenses") { + fields.licenses = stringLiterals(entry.body).filter((value) => value.length > 0); + } + continue; + } + if (entry.value === undefined || entry.value.length === 0) { + continue; + } + if (entry.key === "namespace") { + fields.namespace = entry.value; + } else if (entry.key === "name") { + fields.name = entry.value; + } else if (entry.key === "description") { + fields.description = entry.value; + } else if (entry.key === "repo") { + fields.repo = entry.value; + } + } + return fields; +} + +/** A `CN` mirror url — the badge the site derives from the descriptor's urls. */ +export function hasCnMirror(text: string): boolean { + const clean = stripLuaComments(text); + // Both shapes occur: `CN = "…"` and `["CN"] = "…"`. + return /\bCN\b\s*=/.test(clean) || /\[\s*["']CN["']\s*\]\s*=/.test(clean); +} + +/** `name` and `namespace` as the file name encodes them (`ns.name.lua`, or `name.lua`). */ +export function idFromFileName(fileName: string): { namespace?: string; name: string } { + const base = fileName.replace(/\.lua$/i, ""); + // The separator is the *last* dot: `boost-ext.ut` is `boost-ext` + `ut`, and + // the one descriptor whose own fields are unreadable + // (`huxerui.huxerui.lua`) is `huxerui` + `huxerui`, not `huxerui` + `huxerui.huxerui`. + const dot = base.lastIndexOf("."); + if (dot <= 0) { + return { name: base }; + } + return { namespace: base.slice(0, dot), name: base.slice(dot + 1) }; +} + +/** + * The package id, `ns.name`, with the file name as the fallback (§9.3). + * + * `name` may be written **fully qualified**: `huxerui.huxerui.lua` declares + * `namespace = "huxerui"` and `name = "huxerui.huxerui"` on purpose (mcpp#278 + * INV-NAME — the split form "parses but can never be installed"), and `mcpp` + * itself reports the two halves separately. The prefix is therefore stripped + * again here, which is the difference between a usable id and `huxerui` three + * times over. + */ +export function descriptorId(fields: DescriptorFields, fileName: string): { id: string; namespace?: string; name: string } { + const fallback = idFromFileName(fileName); + let name = fields.name !== undefined && fields.name.length > 0 ? fields.name : fallback.name; + let namespace = fields.namespace !== undefined && fields.namespace.length > 0 ? fields.namespace : fallback.namespace; + const dot = name.lastIndexOf("."); + if (dot > 0) { + const head = name.slice(0, dot); + const tail = name.slice(dot + 1); + if (namespace === undefined || namespace === head) { + namespace = head; + } + name = tail; + } + return { id: namespace === undefined ? name : `${namespace}.${name}`, ...(namespace === undefined ? {} : { namespace }), name }; +} + +/** + * The list's cheap surface guess, from the descriptor text alone. + * + * The authoritative answer is `surfacesOf(xpkg parse --json)`; this exists + * because the list may not spawn a process per package. It asks the same + * questions in the same order as the site generator's `_surfaces`: a `.cppm` + * anywhere makes it a module, a `kind = "bin"` target makes it a tool, an + * inline `sources`/`include_dirs` manifest makes it a header package, and a + * descriptor with no inline manifest at all is a Form A `external`. + * + * `undefined` means the text does not say — the row then shows no usage label, + * and opening the package gets the authoritative answer. + */ +export function surfaceFromDescriptorText(text: string): Surface | undefined { + const clean = stripLuaComments(text); + if (/\.cppm"/.test(clean)) { + return "module"; + } + if (textNamesBinaryTarget(clean)) { + return "tool"; + } + if (/\b(?:sources|include_dirs)\s*=\s*\{/.test(clean)) { + return "header"; + } + if (/\btargets\s*=\s*\{/.test(clean)) { + // Targets but no sources and no headers: the text does not say how the + // package is consumed. The parse's own answer is used when it is available. + return undefined; + } + // No inline manifest at all is what a Form A descriptor looks like. + return /\b(?:package|xpm)\s*=/.test(clean) ? "external" : undefined; +} + +/** + * Merge what the parse says with what the text says, without ever letting the + * cheap guess override the authoritative reader. + * + * The **only** thing the text may add is a `tool` surface: `xpkg parse` prints + * target names, so a `kind = "bin"` is visible in the descriptor's own inline + * manifest but not in the command's output. Everything else comes from the parse + * when it succeeded — which is what keeps a feature-gated `.cppm` (ftxui) from + * being reported as a module just because the text mentions one. + */ +export function mergeSurfaces(info: XpkgInfo | undefined, text: string): Surface[] { + const fromParse = info === undefined ? [] : surfacesOf(info); + const out: Surface[] = [...fromParse]; + if (textNamesBinaryTarget(stripLuaComments(text)) && !out.includes("tool")) { + out.push("tool"); + } + return SURFACES.filter((surface) => out.includes(surface)); +} + +/** + * The version numbers the descriptor's own `xpm` table lists, per platform. + * + * A cheap list-phase stand-in for the authoritative `versions` in + * `mcpp xpkg parse --json`, which the detail page uses. It reads the + * `[""] =` keys of each platform block and, for the xlings-style + * `["latest"] = { ref = "1.6.43" }` alias, the `ref` it points at — but never + * returns `latest` itself as a version, because `mcpp add` accepts only an exact + * version. + */ +export function versionsFromDescriptorText(text: string): Record { + const clean = stripLuaComments(text); + const xpm = keyBlock(clean, "xpm"); + if (xpm === undefined) { + return {}; + } + const out: Record = {}; + for (const platform of topLevelEntries(xpm)) { + if (platform.body === undefined) { + continue; + } + const found: string[] = []; + const aliases: string[] = []; + for (const entry of topLevelEntries(platform.body)) { + // An entry with a `ref` is an alias (`["latest"] = { ref = "1.6.43" }`, + // and the xlings-style named variants): `mcpp add` accepts an exact + // version only, so the ref is the version and the alias name is dropped. + const ref = entry.body === undefined ? null : /\bref\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(entry.body); + if (ref !== null) { + aliases.push(ref[1]); + continue; + } + // A version entry names an archive: a numeric key, or one whose body + // carries a `url`/`sha256`. That is what keeps structural keys — `deps`, + // `cxxflags`, `runtime` — out of the version list, while non-semver + // revisions (`b10069.2`, the llama.cpp checkpoint tags) stay in it. + if (/^\d/.test(entry.key) || (entry.body !== undefined && /\b(?:url|sha256)\s*=/.test(entry.body))) { + found.push(entry.key); + } + } + const versions = [...new Set([...found, ...aliases])]; + if (versions.length > 0) { + out[platform.key] = versions; + } + } + return out; +} + +// ───────────────────────────────────────────────────────────── the entries ── + +/** Where one example project demonstrates a package. */ +export interface ExampleRef { + project: string; + /** The first test file, relative to the index root; the site calls this `path`. */ + path: string; + paths: string[]; + count: number; +} + +/** One source file of an example project, already read. */ +export interface CodeFile { + path: string; + text: string; +} + +/** One index descriptor, as the view needs it. */ +export interface LibraryEntry { + id: string; + namespace?: string; + name: string; + description?: string; + licenses: string[]; + repo?: string; + /** The registry directory name, e.g. `mcpplibs`. */ + registry: string; + /** Absolute path of the descriptor. */ + file: string; + /** The list's cheap guess; the detail page replaces it with the authoritative list. */ + surface?: Surface; + surfaces: Surface[]; + hasCnMirror: boolean; + hasExamples: boolean; + openkal?: OpenkalFacet; + /** The example project that demonstrates this package, when the index has one. */ + example?: ExampleRef; + /** The greatest version this platform has, from the descriptor text. */ + version?: string; + versions: Record; + /** Declared in the workspace's `mcpp.toml`. */ + added: boolean; + /** Set when the descriptor text could not be read at all. */ + unreadable?: boolean; +} + +export interface DescriptorInput { + /** The descriptor's base name, used as the fallback identity. */ + fileName: string; + registry: string; + file: string; + text: string; + platform?: string; + example?: ExampleRef; + openkal?: OpenkalFacet; + added?: boolean; +} + +/** + * Assemble one entry from its text. The only place a descriptor becomes a row: + * the identity falls back to the file name, the surface to the cheap text + * guess, and every optional field is omitted rather than filled with a guess. + */ +export function descriptorEntry(input: DescriptorInput): LibraryEntry { + const fields = parseDescriptorLua(input.text); + const identity = descriptorId(fields, input.fileName); + const versions = versionsFromDescriptorText(input.text); + const surface = surfaceFromDescriptorText(input.text); + const entry: LibraryEntry = { + id: identity.id, + ...(identity.namespace === undefined ? {} : { namespace: identity.namespace }), + name: identity.name, + ...(fields.description === undefined ? {} : { description: fields.description }), + licenses: [...fields.licenses], + ...(fields.repo === undefined ? {} : { repo: fields.repo }), + registry: input.registry, + file: input.file, + ...(surface === undefined ? {} : { surface }), + surfaces: surface === undefined ? [] : [surface], + hasCnMirror: hasCnMirror(input.text), + hasExamples: input.example !== undefined, + version: latestVersion(versions, input.platform), + versions, + added: input.added === true, + }; + if (input.example !== undefined) { + entry.example = input.example; + } + const openkal = input.openkal; + if (openkal !== undefined && (openkal.level !== undefined || openkal.kind !== undefined)) { + entry.openkal = openkal; + } + if (fields.namespace === undefined && fields.name === undefined) { + // Nothing at all was readable: the row still exists, from its file name, + // and the UI says the descriptor could not be read. + entry.unreadable = true; + } + return entry; +} + +/** Replace the cheap text guess with the authoritative parse's answer. */ +export function withAuthoritative( + entry: LibraryEntry, + info: XpkgInfo | undefined, + text: string, + platform: string | undefined, +): LibraryEntry { + if (info === undefined) { + return entry; + } + const surfaces = mergeSurfaces(info, text); + const next: LibraryEntry = { + ...entry, + surfaces, + versions: info.versions, + version: latestVersion(info.versions, platform), + }; + if (surfaces[0] === undefined) { + delete next.surface; + } else { + next.surface = surfaces[0]; + } + return next; +} + +// ─────────────────────────────────────────────────────────────── examples ── + +/** + * The dependency ids a manifest declares, at any nesting depth. + * + * Mirrors the site generator's `_collect_dependencies`: a bare + * `tinyhttps = "…"` resolves in the index's default namespace, so it names both + * `tinyhttps` and `mcpplibs.tinyhttps`; `[dependencies.compat]` with + * `argparse = "3.2"` names `compat.argparse`; `[target.'cfg(linux)'.dependencies…]` + * is found too, because a dependency a platform gate hides is still the package + * that example demonstrates. + * + * The same reader serves two callers: the example index (one `mcpp.toml` per + * project under `tests/examples`) and the workspace's own `mcpp.toml`, which is + * how the "already declared" filter knows what is added. + */ +export function declaredDependencies(tomlText: string): string[] { + const out = new Set(); + const bare = new Set(); + const header = /^\s*\[([^\]]+)\]\s*$/; + let prefix = ""; + let collecting = false; + const lines = tomlText.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].replace(/#.*$/, "").trim(); + const section = header.exec(line); + if (section !== null) { + const path = section[1] + .split(".") + .map((part) => part.trim().replace(/^['"]|['"]$/g, "")) + .filter((part) => part.length > 0); + const at = path.findIndex((part) => part === "dependencies" || part === "dev-dependencies"); + collecting = at !== -1; + prefix = at !== -1 && path.length > at + 1 ? path[at + 1] : ""; + continue; + } + if (!collecting) { + continue; + } + // A dependency table can be written inline (`compat = { argparse = "3.2" }`) + // or across several lines; both are accumulated before the keys are read. + let statement = line; + while (bracesOpen(statement) > 0 && index + 1 < lines.length) { + index += 1; + statement += ` ${lines[index].replace(/#.*$/, "").trim()}`; + } + if (statement.length === 0) { + continue; + } + const assignment = /^([A-Za-z0-9_.\-]+)\s*=\s*(.*)$/.exec(statement); + if (assignment === null) { + continue; + } + const key = assignment[1]; + const value = assignment[2].trim(); + if (value.startsWith("{")) { + // A namespace table: every key inside names a package in it. + for (const inner of value.matchAll(/([A-Za-z0-9_.\-]+)\s*=/g)) { + addDependency(out, bare, prefix.length > 0 ? `${prefix}.${key}.${inner[1]}` : `${key}.${inner[1]}`, false); + } + continue; + } + if (!/^[A-Za-z_][\w\-]*$/.test(key)) { + // `ns.name = "1.0"`: already a fully-qualified id. + addDependency(out, bare, key, false); + continue; + } + // Under `[dependencies.compat]` the key is a bare name in that namespace. + addDependency(out, bare, prefix.length > 0 ? `${prefix}.${key}` : key, prefix.length === 0); + } + for (const name of bare) { + out.add(name); + out.add(`mcpplibs.${name}`); + } + // Sorted: the answer is a set, and a stable order keeps callers and tests honest. + return [...out].sort(); +} + +/** One dependency name, plus the unqualified form when it may resolve by default. */ +function addDependency(out: Set, bare: Set, id: string, unqualified: boolean): void { + if (unqualified) { + bare.add(id); + } + out.add(id); +} + +/** How many `{` are still open on one (possibly concatenated) TOML line. */ +function bracesOpen(text: string): number { + let depth = 0; + let index = 0; + while (index < text.length) { + const char = text[index]; + if (char === '"' || char === "'") { + const quote = char; + index += 1; + while (index < text.length && text[index] !== quote) { + index += text[index] === "\\" ? 2 : 1; + } + index += 1; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + } + index += 1; + } + return depth; +} + +// ─────────────────────────────────────────────── what the project already has ── + +/** One dependency a workspace manifest declares, with the constraint it names. */ +export interface DeclaredDependencyEntry { + id: string; + /** + * The constraint text (`3.2`, `^1.0`) when the value is a plain string. + * A table value — `{ path = "…" }`, a git source — names no version, and the + * project's lock file is the next place to look. + */ + version?: string; + /** Declared under `[dev-dependencies]` (or a feature only tests pull in). */ + dev: boolean; +} + +/** + * The version-aware sibling of `declaredDependencies` (§22): the detail page + * asks "which version does this project already ask for", which an id-only + * list cannot answer. Same walk, same shapes — bare keys stay bare here + * (`argparse` is not expanded to `compat.argparse`), because the matching rule + * of the only caller (`installedFor`) compares against both forms anyway. + */ +export function declaredDependencyEntries(tomlText: string): DeclaredDependencyEntry[] { + const out: DeclaredDependencyEntry[] = []; + const seen = new Set(); + const push = (id: string, version: string | undefined, dev: boolean): void => { + if (id.length === 0 || seen.has(`${dev ? "d" : "r"}:${id}`)) { + return; + } + seen.add(`${dev ? "d" : "r"}:${id}`); + out.push(version === undefined ? { id, dev } : { id, version, dev }); + }; + const header = /^\s*\[([^\]]+)\]\s*$/; + let dev = false; + let prefix = ""; + let collecting = false; + const lines = tomlText.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].replace(/#.*$/, "").trim(); + const section = header.exec(line); + if (section !== null) { + const path = section[1] + .split(".") + .map((part) => part.trim().replace(/^['"]|['"]$/g, "")) + .filter((part) => part.length > 0); + const at = path.findIndex((part) => part === "dependencies" || part === "dev-dependencies"); + collecting = at !== -1; + dev = path[at] === "dev-dependencies"; + prefix = at !== -1 && path.length > at + 1 ? path[at + 1] : ""; + continue; + } + if (!collecting) { + continue; + } + let statement = line; + while (bracesOpen(statement) > 0 && index + 1 < lines.length) { + index += 1; + statement += ` ${lines[index].replace(/#.*$/, "").trim()}`; + } + if (statement.length === 0) { + continue; + } + const assignment = /^([A-Za-z0-9_.\-]+)\s*=\s*(.*)$/.exec(statement); + if (assignment === null) { + continue; + } + const key = assignment[1]; + const value = assignment[2].trim(); + const version = /^"([^"]*)"$/.exec(value); + if (version !== null) { + push(prefix.length > 0 ? `${prefix}.${key}` : key, version[1], dev); + continue; + } + if (value.startsWith("{")) { + // Three shapes live inside braces. A namespace table + // (`compat = { argparse = "3.2" }`) names `compat.argparse` with that + // version. A source table (`counters = { path = "../counters" }`, git + // tables) names `counters` with no version at all; a `version` key + // inside one is honoured, the other source fields are not facts about + // the package. + const SOURCE_KEYS = new Set(["path", "git", "url", "branch", "rev", "tag"]); + const pairs = [...value.matchAll(/([A-Za-z0-9_.\-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^,}]+))/g)] + .map((match) => ({ key: match[1], value: (match[2] ?? match[3] ?? match[4] ?? "").trim() })) + .filter((pair) => pair.key !== undefined); + const named = pairs.filter((pair) => !SOURCE_KEYS.has(pair.key) && pair.key !== "version"); + if (named.length > 0) { + for (const pair of named) { + push( + prefix.length > 0 ? `${prefix}.${key}.${pair.key}` : `${key}.${pair.key}`, + pair.value.length > 0 ? pair.value : undefined, + dev, + ); + } + } else { + const tableVersion = pairs.find((pair) => pair.key === "version" && pair.value.length > 0)?.value; + push(prefix.length > 0 ? `${prefix}.${key}` : key, tableVersion, dev); + } + continue; + } + push(prefix.length > 0 ? `${prefix}.${key}` : key, undefined, dev); + } + return out; +} + +/** One package a build resolved, straight out of `mcpp.lock`. */ +export interface LockPackage { + id: string; + version: string; +} + +/** + * `mcpp.lock` (format `version = 2`): one `[package.""]` table per + * resolved package, with `namespace` and `version` beside it — measured, not + * assumed, against a real lock written by `mcpp 2026.9.30.2`. The file's own + * header says the rest: dev-dependencies are excluded, and only index-resolved + * packages appear, so a path or git dependency is never an answer here. + */ +export function lockPackageVersions(lockText: string): LockPackage[] { + const out: LockPackage[] = []; + const header = /^\s*\[package\."?([A-Za-z0-9_.\-]+)"?\]\s*$/; + let namespace: string | undefined; + let version: string | undefined; + let name: string | undefined; + const flush = (): void => { + if (name !== undefined && version !== undefined) { + // The key is the short name (`[package."openkal"]` + namespace). A + // fully-qualified key also occurs (`[package."mcpplibs.cmdline"]`): when + // the namespace is only the name's own head, the name already is the id. + let id = + namespace === undefined || namespace.length === 0 ? name : `${namespace}.${name}`; + const dot = name.lastIndexOf("."); + if (namespace !== undefined && dot > 0 && namespace === name.slice(0, dot)) { + id = name; + } + out.push({ id, version }); + } + namespace = undefined; + version = undefined; + name = undefined; + }; + for (const raw of lockText.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, "").trim(); + if (line.length === 0) { + continue; + } + const section = header.exec(line); + if (section !== null) { + flush(); + name = section[1]; + continue; + } + if (/^\s*\[/.test(line)) { + // Some other table: whatever half-read package there was is over. + flush(); + continue; + } + if (name === undefined) { + continue; + } + const assignment = /^([A-Za-z_][\w]*)\s*=\s*"([^"]*)"/.exec(line); + if (assignment === null) { + continue; + } + if (assignment[1] === "namespace") { + namespace = assignment[2]; + } else if (assignment[1] === "version") { + version = assignment[2]; + } + } + flush(); + return out; +} + +/** The project's own answer about one package (§20.1, wired in §22). */ +export interface InstalledDependency { + /** The constraint the manifest asks for, or the version a build resolved. */ + version: string; + /** Declared under `[dev-dependencies]`. */ + dev: boolean; +} + +/** + * Whether the project already depends on `id`, and on which version: + * `mcpp.toml` first — the matching rule is §20.1's, the key equals the id or + * is its last segment, because `mcpp add compat.argparse` writes the short + * `argparse = "3.2"` — then `mcpp.lock`, for a dependency whose manifest value + * named no version. `undefined` is a real answer: the project does not have it. + */ +export function installedFor( + id: string, + tomlText: string, + lockText: string, +): InstalledDependency | undefined { + const short = id.slice(id.lastIndexOf(".") + 1); + for (const entry of declaredDependencyEntries(tomlText)) { + if ((entry.id === id || entry.id === short) && entry.version !== undefined) { + return { version: entry.version, dev: entry.dev }; + } + } + for (const locked of lockPackageVersions(lockText)) { + if (locked.id === id || locked.id === short) { + return { version: locked.version, dev: false }; + } + } + return undefined; +} + +/** One example project's manifest and its test files, already read. */ +export interface ExampleInput { + project: string; + text: string; + /** Paths relative to the index root, in the order the caller found them. */ + sources: string[]; +} + +/** + * package id → the example project that consumes it. + * + * The site generator's `_scan_examples` contract: `{project, path, paths[], + * count}`, keyed by every dependency id the project's manifest names. Several + * projects may consume one package; prefer its own named test project over + * incidental consumers, then use deterministic alphabetical order. + */ +export function exampleCatalog(inputs: readonly ExampleInput[]): Map { + const catalog = new Map(); + const priorities = new Map(); + const sorted = [...inputs].sort((a, b) => (a.project < b.project ? -1 : a.project > b.project ? 1 : 0)); + for (const input of sorted) { + if (input.sources.length === 0) { + continue; + } + const ref: ExampleRef = { + project: input.project, + path: input.sources[0], + paths: [...input.sources], + count: input.sources.length, + }; + for (const id of declaredDependencies(input.text)) { + const short = id.slice(id.lastIndexOf(".") + 1).replace(/[-_]/g, "").toLowerCase(); + const projectName = input.project.replace(/[-_]/g, "").toLowerCase(); + const priority = projectName === short ? 0 : projectName.startsWith(short) ? 1 : 2; + if (!catalog.has(id) || priority < priorities.get(id)!) { + catalog.set(id, ref); + priorities.set(id, priority); + } + } + } + return catalog; +} + +/** The `import x.y;` / `#include ` lines, in file order. */ +export interface UsageLine { + file: string; + /** 1-based, so it can be shown beside the code. */ + line: number; + text: string; +} + +const IMPORT_LINE = /^\s*import\s+[A-Za-z_][\w.]*\s*;/; +const INCLUDE_LINE = /^\s*#include\s*[<"][^>"]+[>"]/; + +/** The interface lines the site files a package under a `SURFACE` by. */ +export function usageLines(files: readonly CodeFile[]): UsageLine[] { + const out: UsageLine[] = []; + for (const file of files) { + file.text.split(/\r?\n/).forEach((text, index) => { + if (IMPORT_LINE.test(text) || INCLUDE_LINE.test(text)) { + out.push({ file: file.path, line: index + 1, text: text.replace(/\s+$/, "") }); + } + }); + } + return out; +} + +/** Regex metacharacters are literal in a package name, never a pattern. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * The `import` / `#include` lines a reader of **this** package writes, out of + * an example project's real code (§22): a line counts when it names the + * package — the dotted id or its short name as a word — so `import std;` and + * the example's own other dependencies stay out. Deduplicated, capped at + * four: this is a hint beside the command, not a second code section. + */ +export function usageLinesFor(files: readonly CodeFile[], id: string): string[] { + const short = id.slice(id.lastIndexOf(".") + 1); + const dotted = new RegExp(`\\b${escapeRegExp(id)}\\b`); + const bare = new RegExp(`\\b${escapeRegExp(short)}\\b`); + const out: string[] = []; + for (const line of usageLines(files)) { + if ((dotted.test(line.text) || bare.test(line.text)) && !out.includes(line.text)) { + out.push(line.text); + if (out.length >= 4) { + break; + } + } + } + return out; +} + +/** + * The usage lines to show when no example project states the real ones (§22). + * + * The module name follows the **dotted id** — `import nlohmann.json;`, in that + * descriptor's own words (`export module nlohmann.json;`) — except the default + * `mcpplibs.` namespace, whose packages export their short name: measured in a + * real project (`openkal = "0.12.0"` in the manifest, `import openkal.types;` + * in the sources) and in the cmdline descriptor. A header package's real path + * lives inside the upstream archive and is unknowable offline, so the index + * site's own muted placeholder answers (`#include `) rather than an + * invented path. `tool` and `external` packages are not imported at all; their + * surface badge already says what they are. + */ +export function syntheticUsageLines(surfaces: readonly Surface[], id: string): string[] { + if (surfaces.includes("module")) { + const short = id.slice(id.lastIndexOf(".") + 1); + const moduleName = id.startsWith("mcpplibs.") ? short : id; + return [`import ${moduleName};`]; + } + if (surfaces.includes("header")) { + return [SURFACE_TEXT.header.usage]; + } + return []; +} + +/** A window of an example file around one interface line. */ +export interface CodeSnippet { + file: string; + /** 1-based line number of the first line of `lines`. */ + startLine: number; + lines: string[]; + /** The 1-based line the interface line is on. */ + usageLine: number; +} + +export interface SnippetOptions { + /** Lines of context before and after the interface line. */ + context?: number; + maxSnippets?: number; + maxLines?: number; +} + +/** + * The example code to show: one window per interface line, with overlapping + * windows merged, capped so a package with many test files cannot push the + * detail page's buttons off the screen. This is the *real* code from + * `tests/examples//tests/*.cpp` — CI builds and runs it. + */ +export function codeSnippets(files: readonly CodeFile[], options: SnippetOptions = {}): CodeSnippet[] { + const context = Math.max(0, options.context ?? 2); + const maxSnippets = Math.max(1, options.maxSnippets ?? 3); + const maxLines = Math.max(1, options.maxLines ?? 24); + const snippets: CodeSnippet[] = []; + for (const file of files) { + const lines = file.text.split(/\r?\n/); + const anchors: number[] = []; + lines.forEach((text, index) => { + if (IMPORT_LINE.test(text) || INCLUDE_LINE.test(text)) { + anchors.push(index); + } + }); + for (const anchor of anchors) { + const from = Math.max(0, anchor - context); + const to = Math.min(lines.length, anchor + context + 1); + const last = snippets[snippets.length - 1]; + if (last !== undefined && last.file === file.path && from <= last.startLine - 1 + last.lines.length) { + // Overlaps or touches the previous window: extend it instead of + // repeating its lines. + const merged = lines.slice(last.startLine - 1, to); + last.lines = merged.slice(0, maxLines); + continue; + } + snippets.push({ + file: file.path, + startLine: from + 1, + lines: lines.slice(from, Math.min(to, from + maxLines)), + usageLine: anchor + 1, + }); + if (snippets.length >= maxSnippets) { + return snippets; + } + } + } + return snippets; +} + +// ─────────────────────────────────────────────────────────── highlighting ── + +export type TokenKind = "plain" | "comment" | "string" | "preprocessor" | "keyword" | "punctuation"; + +export interface Token { + kind: TokenKind; + text: string; +} + +/** + * A deliberately small C++ tokenizer for the example block: enough for token + * *colours*, not a parser. Comments, string and character literals, the + * preprocessor directive, a short keyword list and punctuation; everything else + * is plain text. The renderer escapes each token, so no token can become markup. + */ +const KEYWORDS = new Set([ + "import", "export", "module", "include", "define", "ifdef", "ifndef", "endif", + "namespace", "using", "template", "typename", "class", "struct", "enum", "public", + "private", "protected", "static", "inline", "const", "constexpr", "consteval", + "auto", "void", "bool", "char", "int", "long", "short", "unsigned", "signed", + "float", "double", "return", "if", "else", "for", "while", "switch", "case", + "break", "continue", "true", "false", "nullptr", "new", "delete", "try", "catch", + "throw", "this", "operator", "noexcept", "override", "final", "co_await", + "co_return", "co_yield", "requires", "concept", "friend", "virtual", "explicit", +]); + +const PUNCTUATION = new Set([";", "(", ")", "{", "}", "[", "]", ",", "<", ">", ":", "*", "&", "=", "#", ".", "+", "-", "/", "!", "|", "%", "^", "~", "?"]); + +export function tokenizeCppLine(line: string): Token[] { + const tokens: Token[] = []; + let plain = ""; + const flush = (): void => { + if (plain.length > 0) { + tokens.push({ kind: "plain", text: plain }); + plain = ""; + } + }; + const push = (kind: TokenKind, text: string): void => { + flush(); + tokens.push({ kind, text }); + }; + let index = 0; + while (index < line.length) { + const char = line[index]; + if (char === "/" && line[index + 1] === "/") { + push("comment", line.slice(index)); + break; + } + if (char === '"' || char === "'") { + const quote = char; + let end = index + 1; + while (end < line.length) { + if (line[end] === "\\") { + end += 2; + continue; + } + if (line[end] === quote) { + end += 1; + break; + } + end += 1; + } + push("string", line.slice(index, Math.min(end, line.length))); + index = end; + continue; + } + if (/[A-Za-z_]/.test(char)) { + let end = index; + while (end < line.length && /[\w]/.test(line[end])) { + end += 1; + } + const word = line.slice(index, end); + if (KEYWORDS.has(word)) { + push("keyword", word); + } else { + plain += word; + } + index = end; + continue; + } + if (PUNCTUATION.has(char)) { + push("punctuation", char); + index += 1; + continue; + } + plain += char; + index += 1; + } + flush(); + return tokens; +} + +// ────────────────────────────────────────────────────────────── openkal ── + +export interface OpenkalTarget { + kind?: string; + status?: string; +} + +export interface OpenkalMember { + packages: string[]; + portable: boolean; + targets: Record; +} + +export interface OpenkalIndex { + members: Record; + measured?: string; +} + +/** + * `.xpkgindex/openkal-compat.json`, decoded. The file is a *measurement* log + * (`tests/openkal/compat.py` writes it), not a descriptor field, which is why + * nothing about it is inferred from the Lua text. A missing or malformed file + * yields `undefined`, and the view then shows no openkal badge at all. + */ +export function parseOpenkalJson(text: string): OpenkalIndex | undefined { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if (!isRecord(value) || !isRecord(value.members)) { + return undefined; + } + const members: Record = {}; + for (const [name, raw] of Object.entries(value.members)) { + if (!isRecord(raw)) { + continue; + } + const targets: Record = {}; + if (isRecord(raw.targets)) { + for (const [target, entry] of Object.entries(raw.targets)) { + if (!isRecord(entry)) { + continue; + } + targets[target] = { + ...(typeof entry.kind === "string" ? { kind: entry.kind } : {}), + ...(typeof entry.status === "string" ? { status: entry.status } : {}), + }; + } + } + members[name] = { + packages: Array.isArray(raw.packages) ? raw.packages.filter((id): id is string => typeof id === "string") : [], + portable: raw.portable !== false, + targets, + }; + } + return { + members, + ...(typeof value.measured === "string" ? { measured: value.measured } : {}), + }; +} + +const OPENKAL_RANK: Readonly> = { fails: 0, builds: 1, runs: 2 }; + +/** The openkal facet a package is filed under, or nothing. */ +export interface OpenkalFacet { + level?: "ecosystem" | "compat"; + kind?: "posix" | "platform"; +} + +/** + * The facet for one package id, mirroring the generator's `_openkal_level` / + * `_openkal_kind`: + * + * - `openkal-ecosystem` from the family list; + * - `openkal-compat` only when some measured target **runs** (a package measured + * only to build, or to fail, is filed under neither — the site says so in as + * many words); + * - `platform` wins over `posix`, because the package-level kind is the + * strictest measured target's answer, not a claim about every target. + */ +export function openkalFacetFor(index: OpenkalIndex | undefined, id: string): OpenkalFacet | undefined { + if (index === undefined) { + return undefined; + } + const facet: OpenkalFacet = {}; + if (OPENKAL_FAMILY.includes(id)) { + facet.level = "ecosystem"; + } + const targets: OpenkalTarget[] = []; + let best = -1; + for (const member of Object.values(index.members)) { + if (!member.packages.includes(id)) { + continue; + } + for (const target of Object.values(member.targets)) { + targets.push(target); + const rank = OPENKAL_RANK[target.status ?? ""] ?? -1; + if (rank > best) { + best = rank; + } + } + } + if (facet.level === undefined && best === 2) { + facet.level = "compat"; + } + if (targets.some((target) => target.kind === "platform")) { + facet.kind = "platform"; + } else if (targets.some((target) => target.kind === "posix")) { + facet.kind = "posix"; + } + if (facet.level === undefined && facet.kind === undefined) { + return undefined; + } + return facet; +} + +/** The badges one entry earns, in the site's order (§12.5). */ +export function badgesOf(input: { + hasExamples?: boolean; + hasCnMirror?: boolean; + openkal?: OpenkalFacet; +}): BadgeKey[] { + const out: BadgeKey[] = []; + if (input.hasExamples === true) { + out.push("examples"); + } + if (input.hasCnMirror === true) { + out.push("cn"); + } + if (input.openkal?.level === "ecosystem") { + out.push("openkal-ecosystem"); + } else if (input.openkal?.level === "compat") { + out.push("openkal-compat"); + } + if (input.openkal?.kind === "posix") { + out.push("openkal-posix"); + } else if (input.openkal?.kind === "platform") { + out.push("openkal-platform"); + } + return out; +} + +/** One dependency a descriptor declares for itself. */ +export interface DescriptorDependency { + id: string; + version?: string; +} + +/** + * The dependencies a descriptor's own inline manifest names. + * + * Two shapes occur in this index: a table keyed by package id + * (`deps = { ["compat.vulkan"] = "1.4.357.3" }`) and, in the xlings-style + * descriptors, a list of `ns:name@version` strings. Only the **declared** edge is + * read — the resolved version lives in a project's `mcpp.lock`, which is not this + * package's file, so it is never invented here. + */ +export function descriptorDependencies(text: string): DescriptorDependency[] { + const clean = stripLuaComments(text); + const body = keyBlock(clean, "deps"); + if (body === undefined) { + return []; + } + const out: DescriptorDependency[] = []; + const seen = new Set(); + const push = (id: string, version: string | undefined): void => { + const key = id.trim(); + if (key.length === 0 || seen.has(key) || out.length >= 100) { + return; + } + seen.add(key); + out.push(version === undefined || version.length === 0 ? { id: key } : { id: key, version }); + }; + for (const entry of topLevelEntries(body)) { + if (entry.value !== undefined) { + push(entry.key, entry.value); + continue; + } + const version = entry.body === undefined ? null : /\bversion\s*=\s*"([^"]*)"/.exec(entry.body); + push(entry.key, version === null ? undefined : version[1]); + } + if (out.length === 0) { + // The xlings-style list form: `deps = { "xim:gtk4@4.16.13" }`. Only entries + // that carry the `ns:name@version` shape count; a bare string is not a + // dependency id in this index. + for (const literal of stringLiterals(body)) { + const at = literal.lastIndexOf("@"); + if (at > 0 && literal.slice(0, at).includes(":")) { + push(literal.slice(0, at), literal.slice(at + 1)); + } else if (literal.includes(":")) { + push(literal, undefined); + } + } + } + return out; +} + +// ──────────────────────────────────────────────────── cross-registry search ── + +/** One hit from `mcpp search `. */ +export interface SearchHit { + id: string; + description?: string; + version?: string; +} + +/** + * The one place this extension reads **human** output, and it is behind a + * setting that is off by default (plan §10.2, risk table "第二处解析人类输出", + * upstream request U.8 `mcpp search --format json`). + * + * `mcpp search argparse` prints one hit per line: + * + * ```text + * compat:argparse argparse — header-only argument parser for modern C++ (3.2) + * ``` + * + * The reader is deliberately forgiving — a line that does not match is dropped, + * and an empty result is an empty list, never an error. The caller keeps the + * local index results either way, so a change in this format degrades to "no + * extra hits" instead of to a broken view. + */ +export function parseSearchOutput(text: string): SearchHit[] { + const hits: SearchHit[] = []; + const seen = new Set(); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.length === 0) { + continue; + } + const match = /^([A-Za-z0-9_.\-]+)[:.]([A-Za-z0-9_.\-]+)\s+(.*)$/.exec(line); + if (match === null) { + continue; + } + const id = `${match[1]}.${match[2]}`; + if (seen.has(id)) { + continue; + } + seen.add(id); + const tail = match[3].trim(); + const version = /\(([^()]*)\)\s*$/.exec(tail); + const description = (version === null ? tail : tail.slice(0, version.index)).trim(); + hits.push({ + id, + ...(description.length === 0 ? {} : { description }), + ...(version === null || version[1].trim().length === 0 ? {} : { version: version[1].trim() }), + }); + if (hits.length >= 100) { + break; + } + } + return hits; +} + +// ─────────────────────────────────────────────────────── mcpp self env ── + +/** The shape of `mcpp self env --format json`, as far as this extension reads it. */ +export interface SelfEnv { + /** `$MCPP_HOME`, the directory everything else hangs off. */ + mcppHome?: string; + /** `/registry`, when mcpp states it. */ + registry?: string; +} + +/** + * `mcpp self env --format json`, read for the one field that matters. + * + * This is a **machine** shape, not human text, so it is held to the detection + * rule `src/cli/protocol.ts` states — presence *and* type of `schemaVersion`, + * plus the `kind` this reader owns — and anything else is refused rather than + * guessed at. The library view falls back to its own globs when this returns + * `undefined`, so a version of mcpp that renames a field costs a fallback and + * never a crash. + */ +export function parseSelfEnv(text: string): SelfEnv | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return undefined; + } + if (!isRecord(parsed) || typeof parsed.schemaVersion !== "number" || parsed.kind !== "mcpp.env") { + return undefined; + } + const data = parsed.data; + if (!isRecord(data)) { + return undefined; + } + const home = data.mcppHome; + const registry = data.registry; + return { + ...(typeof home === "string" && home.trim().length > 0 ? { mcppHome: home.trim() } : {}), + ...(typeof registry === "string" && registry.trim().length > 0 ? { registry: registry.trim() } : {}), + }; +} + +// ───────────────────────────────────────────────────────────────── search ── + +/** The fields a search looks at — never the whole Lua text. */ +export function searchText(entry: LibraryEntry): string { + return [entry.id, entry.description ?? "", entry.licenses.join(" "), entry.repo ?? "", entry.registry].join(" ").toLowerCase(); +} + +export function matchesQuery(entry: LibraryEntry, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return true; + } + const haystack = searchText(entry); + return needle.split(/\s+/).every((part) => haystack.includes(part)); +} + +/** The surface's label, localized by the caller and falling back to the vocabulary. */ +export function surfaceLabel(surface: Surface, label: (key: string) => string): string { + const text = SURFACE_TEXT[surface]; + if (text.uiKey === undefined) { + return text.label; + } + const localized = label(text.uiKey); + return localized === undefined || localized.length === 0 || localized === text.uiKey ? text.label : localized; +} + +/** + * Decode `mcpp xpkg parse --json` output for a caller that has the descriptor's + * text too. Kept here so the vscode halves import one library module; the + * parsing itself lives in `xpkg.ts`. + */ +export { parseXpkgJson, versionGroups, versionsFor, surfacesOf, SURFACE_TEXT, SURFACES }; +export type { Surface, XpkgInfo }; diff --git a/src/library/libraryHtml.ts b/src/library/libraryHtml.ts new file mode 100644 index 0000000..58df6d8 --- /dev/null +++ b/src/library/libraryHtml.ts @@ -0,0 +1,488 @@ +/** + * The library sidebar's document, as a pure function (plan §9.3, §10.2, §12). + * + * `src/library/libraryView.ts` resolves the index into a `LibraryModel`; this + * module turns that into one self-contained HTML document. No `vscode`, no file + * system, no network — which is what makes the interesting parts (escaping, the + * strict CSP, the filter/search behaviour, the clamped description) unit + * testable. + * + * House rules, the same ones `src/config/panelHtml.ts` pins down: + * + * - **Strict CSP.** `default-src 'none'` plus exactly three sources: the + * stylesheet, the nonced inline script and the webview's own image source. + * There are no external resources and no inline `style=` attributes. + * - **No hard-coded copy.** Every user-visible label is looked up in + * `LibraryModel.ui`, which the caller has already localized; a key the caller + * forgot degrades to the English fallback beside it, not to a blank control. + * - **No unescaped interpolation.** Every value that reaches the markup goes + * through `escapeHtml`; the client script only ever writes through + * `textContent` and `setAttribute`, never `innerHTML`. + * - **State is text plus shape, not colour.** "已添加" is a labelled badge and a + * `data-added` attribute; the stylesheet may colour it, but the row reads the + * same in a high-contrast theme. + * + * The one deliberate performance choice: the search box + * are applied **in the client**, on rows that are already in the document, so + * typing never round-trips through the extension host and never moves the caret. + * The messages still go back (`search`, `filter`) so the host knows what the + * reader is looking at; the host re-renders only when the *data* changes — + * a refresh, the cross-registry toggle, or a new index revision. + */ + +import { BADGE_UI, SURFACE_TEXT, surfaceLabel, type BadgeKey, type Surface } from "./indexModel"; + +/** One result row, already resolved for display. */ +export interface LibraryRow { + id: string; + key?: string; + source?: string; + namespace?: string; + name: string; + /** The greatest version this platform has, or `undefined`. */ + version?: string; + surface?: Surface; + /** Every surface the descriptor supports, not only the leading one. */ + surfaces: Surface[]; + badges: BadgeKey[]; + /** + * The lower-cased text a query is matched against, built by the host through + * `indexModel.searchText`. It travels in the row so the client's filter and the + * host's `visibleEntries` cannot drift apart on what "matches" means. + */ + haystack: string; + description?: string; + /** Declared in the workspace's `mcpp.toml`. */ + added: boolean; + /** The descriptor's own fields could not be read; identity comes from the file name. */ + unreadable: boolean; + /** Came from `mcpp search`, not from the local index. */ + crossRegistry?: boolean; +} + +export interface LibraryModel { + /** Everything is already localized by the caller. */ + ui: Record; + rows: LibraryRow[]; + selectedKey?: string; + query: string; + networkSearch: boolean; + /** + * The setting the toggle writes. Carried in the model so the client and the + * host cannot disagree about which key the control drives. + */ + networkSearchSetting: string; + /** The data source, in plain words, for the footer. */ + dataSource: string; + sourceDetails?: string; + status?: string; + projectNotice?: string; + /** `{0}` visible of `{1}` known, already localized; the header count uses it. */ + countTemplate: string; + /** Rows before filtering; the count line shows it against what is visible. */ + total: number; + /** Set when there is nothing to show, already localized. */ + notice?: string; +} + +export interface LibraryAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** + * The `ui` keys the renderer reads, so the caller and the renderer cannot drift + * apart on a string. `libraryView.ts` fills all of them through `t()`. + */ +export const LIBRARY_UI = { + htmlLang: "library.htmlLang", + title: "library.title", + search: "library.search", + networkSearch: "library.networkSearch", + networkSearchHint: "library.networkSearch.hint", + versionLatest: "library.version.latest", + surfaceExternal: SURFACE_TEXT.external.uiKey ?? "library.surface.external", + badgeExamples: BADGE_UI.examples.key, + badgeCn: BADGE_UI.cn.key, + badgeOpenkalEcosystem: BADGE_UI["openkal-ecosystem"].key, + badgeOpenkalCompat: BADGE_UI["openkal-compat"].key, + badgeOpenkalPosix: BADGE_UI["openkal-posix"].key, + badgeOpenkalPlatform: BADGE_UI["openkal-platform"].key, + added: "library.added", + unreadable: "library.unreadable", + crossRegistry: "library.crossRegistry", + noResults: "library.noResults", + noIndex: "library.noIndex", + dataSource: "library.dataSource", + count: "library.count", + open: "library.open", + refresh: "library.refresh", + sources: "library.sources", + other: "library.other", + settings: "library.settings", + update: "library.update", + output: "library.output", + clear: "library.clear", +} as const; + +/** One badge key's `ui` key, so the renderer has a single lookup table. */ +const BADGE_KEY_UI: Readonly> = { + examples: LIBRARY_UI.badgeExamples, + cn: LIBRARY_UI.badgeCn, + "openkal-ecosystem": LIBRARY_UI.badgeOpenkalEcosystem, + "openkal-compat": LIBRARY_UI.badgeOpenkalCompat, + "openkal-posix": LIBRARY_UI.badgeOpenkalPosix, + "openkal-platform": LIBRARY_UI.badgeOpenkalPlatform, +}; + +/** A mounted handshake replays data in place after the initial document loads. + * The host never assigns webview.html in response, so it cannot create a reload loop. + */ +export type LibraryMessage = + | { type: "refresh" | "settings" | "update" | "output" | "mounted" } + | { type: "search"; query: string } + | { type: "networkSearch"; enabled: boolean; token?: string } + | { type: "open"; id: string }; + +export type UiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * The CSP is an attribute value, but its single quotes are syntax: escaping + * them would make the document state a different policy than it means. Only the + * characters that could end the attribute or start a tag are escaped. + */ +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** ` name="value"`, or nothing when the value is absent. */ +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +/** ` name`, or nothing. Boolean HTML attributes only. */ +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** `{0}`-style substitution into an already-localized ui string. */ +function fill(label: UiLabel, key: string, args: readonly (string | number)[]): string { + return label(key).replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number(index)]; + return value === undefined ? whole : String(value); + }); +} + +/** The badge labels, in `BADGE_UI` order. */ +export function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string[] { + return badges.map((badge) => label(BADGE_KEY_UI[badge])); +} + +function renderRow(row: LibraryRow, label: UiLabel): string { + const badges: string[] = []; + const openTitle = fill(label, LIBRARY_UI.open, [row.id]); + const surface = row.surface === undefined ? undefined : surfaceLabel(row.surface, label); + const version = + row.version === undefined ? "" : `${escapeHtml(fill(label, LIBRARY_UI.versionLatest, [row.version]))}`; + const secondary = [ + surface === undefined ? "" : `${escapeHtml(surface)}`, + ...badges.map((badge) => `${escapeHtml(badge)}`), + row.added ? `${escapeHtml(label(LIBRARY_UI.added))}` : "", + row.unreadable ? `${escapeHtml(label(LIBRARY_UI.unreadable))}` : "", + row.crossRegistry === true || row.source ? `${escapeHtml(row.source ?? label(LIBRARY_UI.crossRegistry))}` : "", + ] + .filter((part) => part.length > 0) + .join(""); + const description = row.description === undefined ? "" : row.description; + return [ + `
  • `, + ` `, + `
  • `, + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +/** + * The search box, and the network toggle under it. + * + * There used to be a wrapped row of filter chips between them — one per + * namespace, one per surface, plus "Added" — which on a real index is three + * lines of buttons above the list, and the list is the view. The search box + * already matches the namespace (it is part of a row's haystack), so the + * taxonomy the chips offered is still one keystroke away; the "Added" state is + * on the row itself, as a badge. + */ +function renderToolbar(model: LibraryModel, label: UiLabel): string { + return [ + `
    `, + ` `, + ` `, + ` `, + ` `, + ` ${escapeHtml(fill(label, LIBRARY_UI.count, [model.rows.filter((row) => !row.crossRegistry && rowMatchesClient(model, row)).length, model.rows.filter((row) => !row.crossRegistry).length]))}`, + `
    `, + ].join("\n"); +} + +function renderList(model: LibraryModel, label: UiLabel): string { + const local = model.rows.filter((row) => !row.crossRegistry); + const extra = model.rows.filter((row) => row.crossRegistry); + return `
    + ${model.projectNotice ? `

    ${escapeHtml(model.projectNotice)}

    ` : ""} +
      ${local.map((row) => renderRow(row, label)).join("\n")}
    +
    +

    ${escapeHtml(label(LIBRARY_UI.other))} (${extra.length})

    +
      ${extra.map((row) => renderRow(row, label)).join("\n")}
    +
    +

    ${escapeHtml(model.notice ?? label(LIBRARY_UI.noResults))}

    +

    ${escapeHtml(model.status ?? "")}

    +
    `; +} + +/** + * The client. Dependency-free, no template literals of its own, and it only + * writes through `textContent`/`setAttribute`. + * + * Search is local: a row carries the text the search box matches against as a + * `data-haystack` attribute, and the query decides its `hidden` flag. The host is + * told the query, and answers with a whole new document only when the data + * behind it changed. + */ +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var saved = api && api.getState ? api.getState() || {} : {}; + var searchInput = document.getElementById("library-search"); + var networkInput = document.getElementById("library-network"); + var networkToken; + var networkSequence = 0; + var selected = state.selectedKey || saved.selected || ""; + if (searchInput && typeof saved.query === "string") { searchInput.value = saved.query; } + function post(message) { if (api) { api.postMessage(message); } } + function persist() { + if (api && api.setState) { api.setState({ query: searchInput.value, selected: selected, scroll: window.scrollY }); } + } + function apply() { + document.querySelector('[data-action="clear"]').hidden = searchInput.value.length === 0; + var query = searchInput.value.trim().toLowerCase(); + var parts = query.split(/\\s+/); + var rows = Array.from(document.querySelectorAll("[data-row]")); + var local = 0; + var extra = 0; + rows.forEach(function (row) { + var haystack = row.getAttribute("data-haystack") || ""; + var shown = parts.every(function (part) { return !part || haystack.indexOf(part) >= 0; }); + row.parentNode.hidden = !shown; + if (shown) { if (row.getAttribute("data-cross") === "true") { extra++; } else { local++; } } + if (row.getAttribute("data-row") === selected) { row.setAttribute("aria-current", "true"); } + else { row.removeAttribute("aria-current"); } + }); + var list = document.getElementById("library-list"); + if (list) { + Array.from(list.children).sort(function (a, b) { + function rank(item) { + var id = item.firstElementChild.getAttribute("data-id").toLowerCase(); + var name = id.slice(id.lastIndexOf(".") + 1); + return !query ? 2 : id === query || name === query ? 0 : id.indexOf(query) === 0 || name.indexOf(query) === 0 ? 1 : 2; + } + return rank(a) - rank(b) || a.firstElementChild.getAttribute("data-id").localeCompare(b.firstElementChild.getAttribute("data-id")); + }).forEach(function (item) { list.appendChild(item); }); + } + var empty = document.getElementById("library-empty"); + if (empty) { empty.hidden = local + extra > 0; } + var group = document.getElementById("library-extra"); + if (group) { group.hidden = extra === 0; } + document.getElementById("library-count").textContent = state.countTemplate.split("{0}").join(String(local)).split("{1}").join(String(state.localTotal)); + persist(); + } + searchInput.addEventListener("input", function () { apply(); post({ type: "search", query: searchInput.value }); }); + networkInput.addEventListener("change", function () { networkToken = Date.now() + "-" + (++networkSequence); post({ type: "networkSearch", enabled: networkInput.checked, token: networkToken }); }); + document.addEventListener("click", function (event) { + var target = event.target.closest && event.target.closest("[data-action], [data-row]"); + if (!target) { return; } + var action = target.getAttribute("data-action"); + if (action === "clear") { searchInput.value = ""; searchInput.focus(); apply(); post({ type: "search", query: "" }); return; } + if (action) { post({ type: action }); return; } + selected = target.getAttribute("data-row"); apply(); post({ type: "open", id: selected }); + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && event.target === searchInput) { + searchInput.value = ""; apply(); post({ type: "search", query: "" }); + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } + var rows = Array.from(document.querySelectorAll("[data-row]")).filter(function (row) { return !row.parentNode.hidden; }); + if (!rows.length || (event.target !== searchInput && !event.target.hasAttribute("data-row"))) { return; } + event.preventDefault(); + var index = rows.indexOf(document.activeElement); + var next = event.key === "ArrowDown" ? Math.min(rows.length - 1, index + 1) : Math.max(0, index - 1); + rows[next].focus(); + }); + window.addEventListener("scroll", persist); + window.addEventListener("message", function (event) { + var payload = event.data; + if (payload && payload.type === "networkSearchSaved") { if (payload.token === networkToken) { networkToken = undefined; networkInput.checked = payload.enabled; } return; } + if (!payload || payload.type !== "libraryUpdate") { return; } + var scroll = window.scrollY; + var focused = document.activeElement && document.activeElement.getAttribute("data-row"); + var details = document.querySelector("footer details"); + var expanded = details && details.open; + var doc = new DOMParser().parseFromString(payload.html, "text/html"); + ["library-results", "library-footer"].forEach(function (id) { + var old = document.getElementById(id); var next = doc.getElementById(id); + if (old && next) { old.replaceWith(next); } + }); + state = payload.state; + if (state.selectedKey) { selected = state.selectedKey; } + if (!networkToken) { networkInput.checked = payload.networkSearch; } + apply(); + var nextDetails = document.querySelector("footer details"); if (nextDetails) { nextDetails.open = expanded; } + if (focused) { Array.from(document.querySelectorAll("[data-row]")).some(function (row) { if (row.getAttribute("data-row") === focused) { row.focus(); return true; } return false; }); } + window.scrollTo(0, scroll); + }); + apply(); + window.scrollTo(0, saved.scroll || 0); + post({ type: "mounted" }); + post({ type: "search", query: searchInput.value }); +})();`; +} + +/** + * The little state the client needs, not the whole model. + * + * The rows are already in the document and the badges are already resolved, so + * embedding the model again would double the page for nothing. `<` is escaped in + * `embedJson`, so even this cannot close the script element. + */ +export function libraryClientState(model: LibraryModel): Record { + return { + countTemplate: model.countTemplate, + selectedKey: model.selectedKey, + total: model.total, + localTotal: model.rows.filter((row) => !row.crossRegistry).length, + query: model.query, + }; +} + +/** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const visible = model.rows.filter((row) => rowMatchesClient(model, row)).length; + const count = fill(label, LIBRARY_UI.count, [visible, model.total]); + return ` + + + + + + +${escapeHtml(label(LIBRARY_UI.title))} + + +
    +

    ${escapeHtml(label(LIBRARY_UI.title))}

    +
    +${renderToolbar(model, label)} +
    +${renderList(model, label)} +
    +
    +
    ${escapeHtml(model.dataSource)}

    ${escapeHtml(model.sourceDetails ?? "")}

    + +
    + + + +`; +} + +/** + * The rows the initial query keeps. The live search is the client's + * `rowMatches`; this mirrors it for the header count, so the count the reader + * first sees matches the list beneath it. + */ +function rowMatchesClient(model: LibraryModel, row: LibraryRow): boolean { + const query = model.query.trim().toLowerCase(); + if (query.length === 0) { + return true; + } + return query.split(/\s+/).every((part) => part.length === 0 || row.haystack.includes(part)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not exactly one of the four shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + */ +export function decodeLibraryMessage(raw: unknown): LibraryMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "mounted": + case "refresh": + case "settings": + case "update": + case "output": + return { type: raw.type }; + case "search": + return typeof raw.query === "string" ? { type: "search", query: raw.query } : undefined; + case "networkSearch": + return typeof raw.enabled === "boolean" ? { type: "networkSearch", enabled: raw.enabled, ...(typeof raw.token === "string" && raw.token.length <= 80 ? { token: raw.token } : {}) } : undefined; + case "open": + return nonEmptyString(raw.id) ? { type: "open", id: raw.id } : undefined; + default: + return undefined; + } +} diff --git a/src/library/libraryView.ts b/src/library/libraryView.ts new file mode 100644 index 0000000..043705e --- /dev/null +++ b/src/library/libraryView.ts @@ -0,0 +1,535 @@ +/** + * The library sidebar: the `mcpp.library` view. + * + * The *model* is `src/library/libraryHtml.ts` plus `src/library/indexModel.ts`, + * both pure and unit-tested; the *reading* is `src/library/indexLocator.ts`, + * which caches per index revision. This file is the seam: it owns the webview's + * lifetime, the `t()` literals, the settings and the messages. + * + * Three deliberate boundaries: + * + * - **The list never spawns a process per package and never touches the + * network.** The descriptors are read as text; `mcpp xpkg parse --json` runs + * only from the detail page. The one exception is `mcpp search`, behind + * `mcpp.library.networkSearch`, which is **off by default** and whose output + * reader is the tolerant best-effort one in `indexModel.parseSearchOutput` + * (plan §10.2; upstream request U.8). + * - **One render, applied in place.** Filtering happens in the document; the + * host re-renders only when the data changed. A `webview.html` assignment + * would otherwise steal the caret on every keystroke. + * - **A failure still renders.** No index, no descriptors or a failed read all + * produce a document with a sentence in it, never a blank sidebar. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { effective, read, write } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { WebviewDocument } from "../webview/document"; +import { badgesOf, searchText, parseSearchOutput, type LibraryEntry } from "./indexModel"; +import { loadSnapshot, invalidateIndexCache, type IndexRoot, type LibrarySnapshot } from "./indexLocator"; +import { + LIBRARY_UI, + decodeLibraryMessage, + renderLibraryHtml, + libraryClientState, + type LibraryModel, + type LibraryRow, +} from "./libraryHtml"; + +/** The view id `package.json` declares. */ +export const LIBRARY_VIEW_ID = "mcpp.library"; + +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "library.css"; + +/** The delay before a cross-registry search runs: one per pause, not per keystroke. */ +const SEARCH_DEBOUNCE_MS = 400; +const SEARCH_TIMEOUT_MS = 20_000; + +export interface LibraryViewDeps { + /** + * The workspace folder whose `mcpp.toml` decides which packages are "already + * declared". `undefined` is a normal state: the list then simply has none. + */ + projectRoot: () => string | undefined; + /** The configured executable, for the cross-registry search only. */ + mcppExecutable: () => string; + /** Opens the editor-area detail page for one package id. */ + openDetail: (id: string, preview?: LibraryRow) => Promise | void; + /** Where `mcpp search` is logged; the same channel the other commands use. */ + output?: vscode.OutputChannel; + /** `vscode.workspace.isTrusted`; `mcpp search` never runs without it. */ + isTrusted: () => boolean; +} + +/** + * Register the view: the provider, its settings/save listeners and the + * `registerWebviewViewProvider` call that makes VS Code ask it for a document. + * + * That last call is the whole point — without it the view has no document at + * all, and every `refresh()` below is a no-op against `this.view === undefined`. + * It lives here, next to the provider it hands over, exactly like + * `registerCachePanel`; `extension.ts` only wires the two entry-point commands. + * + * `retainContextWhenHidden` is not supported by a webview view, which is why the + * render is idempotent: the document is rebuilt from the snapshot every time the + * view becomes visible again. + */ +export function registerLibraryView(context: vscode.ExtensionContext, deps: LibraryViewDeps): LibraryViewHandle { + const provider = new LibraryViewProvider(context, deps); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(LIBRARY_VIEW_ID, provider, { + webviewOptions: { retainContextWhenHidden: false }, + }), + provider, + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.library") || event.affectsConfiguration("mcpp.path")) { + invalidateIndexCache(); + void provider.refresh(); + } + }), + // `mcpp.toml` decides the "已添加" state, so saving it re-renders the list. + vscode.workspace.onDidSaveTextDocument((document) => { + if (document.uri.path.endsWith("/mcpp.toml") || document.uri.path.endsWith("\\mcpp.toml")) { + void provider.refresh(); + } + }), + ); + return { + refresh: () => provider.refresh(), + select: (key: string) => provider.select(key), + setUpdateStatus: (status: string) => provider.setUpdateStatus(status), + dispose: () => provider.dispose(), + }; +} + +/** + * What `extension.ts` keeps a reference to. + * + * `refresh()` is how a dependency change made **outside the editor** — `mcpp add` + * run by the detail page, or `mcpp index update` — reaches the list, because no + * document is saved in that path and therefore no save event fires. The provider + * itself is not handed back: `registerLibraryView` has already registered it, + * and a caller that wants to reach into the view would be reaching past the two + * methods below. + */ +export interface LibraryViewHandle { + refresh(): Promise; + select(key: string): void; + setUpdateStatus(status: string): void; + dispose(): void; +} + +class LibraryViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + private view: vscode.WebviewView | undefined; + private snapshot: LibrarySnapshot | undefined; + private query = ""; + private selectedKey = ""; + /** Cross-registry hits for the current query, from `mcpp search`. */ + private extra: LibraryRow[] = []; + private searchNote: string | undefined; + private searchTimer: NodeJS.Timeout | undefined; + private rendering: Promise | undefined; + private refreshAgain = false; + private requestGeneration = 0; + private error: string | undefined; + private updateNote: string | undefined; + private networkWrites: Promise = Promise.resolve(); + private networkChange = 0; + private painted = false; + private lastHtml = ""; + /** + * The document on screen and the one CSP nonce it may be built with. Both + * live in `WebviewDocument`: the nonce is per view (a nonce per render would + * make every render a *different* document and defeat the comparison, which + * is how the reload loop started), and `paint()` refuses to re-assign a + * document that has not changed, because an assignment reloads the view. + */ + private readonly webviewDocument = new WebviewDocument(STYLESHEET); + + public constructor( + private readonly context: vscode.ExtensionContext, + private readonly deps: LibraryViewDeps, + ) {} + + public setUpdateStatus(status: string): void { this.updateNote = status; this.paint(); } + + public select(key: string): void { this.selectedKey = key; this.paint(); } + + public dispose(): void { + this.requestGeneration++; + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + this.searchTimer = undefined; + } + } + + public resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + // A resolved view is a fresh, empty webview; see the note in `onDidDispose`. + this.webviewDocument.invalidate(); + this.painted = false; + this.lastHtml = ""; + view.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY)], + }; + view.webview.onDidReceiveMessage((raw: unknown) => { + void this.handle(raw); + }); + view.onDidDispose(() => { + if (this.view === view) { + this.view = undefined; + // The next `resolveWebviewView` gets a brand-new, empty webview: what was + // pushed to the old one says nothing about it, so the comparison in + // `paint()` must start from nothing again. + this.webviewDocument.invalidate(); + } + }); + // First paint from the cached snapshot, so the view is never blank while the + // index is being read. + this.paint(); + void this.refresh(); + } + + /** Re-read the index (from the cache when the revision is unchanged) and paint. */ + public refresh(): Promise { + this.requestGeneration++; + this.extra = []; + this.searchNote = undefined; + if (this.rendering !== undefined) { + this.refreshAgain = true; + return this.rendering; + } + this.rendering = (async () => { + do { + this.refreshAgain = false; + const projectRoot = this.deps.projectRoot(); + try { + const snapshot = await loadSnapshot({ projectRoot }); + if (projectRoot !== this.deps.projectRoot()) { this.refreshAgain = true; continue; } + this.snapshot = snapshot; + this.error = undefined; + } catch (error) { + this.error = t("Could not read the index: {0}", error instanceof Error ? error.message : String(error)); + this.deps.output?.appendLine(this.error); + } + this.paint(); + } while (this.refreshAgain); + })().finally(() => { this.rendering = undefined; }); + return this.rendering; + } + + private async handle(raw: unknown): Promise { + const message = decodeLibraryMessage(raw); + if (message === undefined) { + return; + } + switch (message.type) { + case "mounted": + this.lastHtml = ""; + this.paint(); + return; + case "settings": + await vscode.commands.executeCommand("workbench.action.openSettings", "mcpp.library.indexPath"); + return; + case "update": + await vscode.commands.executeCommand("mcpp.library.updateIndex"); + return; + case "output": + this.deps.output?.show(true); + return; + case "refresh": + invalidateIndexCache(); + await this.refresh(); + return; + case "search": + this.query = message.query; + this.scheduleSearch(); + return; + case "networkSearch": + await this.setNetworkSearch(message.enabled, message.token); + return; + case "open": + await this.deps.openDetail(message.id, this.extra.find((row) => (row.key ?? row.id) === message.id)); + return; + default: + return; + } + } + + /** `mcpp.library.networkSearch`: persisted, then honoured on the next query. */ + private async setNetworkSearch(enabled: boolean, token?: string): Promise { + const change = ++this.networkChange; + this.requestGeneration++; + this.extra = []; + this.searchNote = undefined; + try { + const source = effective("mcpp.library.networkSearch").source; + const queued = this.networkWrites.then(() => write("mcpp.library.networkSearch", enabled, source === "workspace" || source === "workspaceFolder" ? "workspace" : "user")); + this.networkWrites = queued.catch(() => {}); + await queued; + } catch (error) { + this.deps.output?.appendLine( + `mcpp library: could not write mcpp.library.networkSearch: ${error instanceof Error ? error.message : String(error)}`, + ); + this.searchNote = t("The setting could not be written; the toggle was not saved."); + } + if (change !== this.networkChange) { return; } + void this.view?.webview.postMessage({ type: "networkSearchSaved", token, enabled: this.networkSearch() }); + if (enabled && this.networkSearch()) { + await this.runCrossRegistrySearch(); + } else { + this.paint(); + } + } + + private scheduleSearch(): void { + this.requestGeneration++; + const hadExtra = this.extra.length > 0 || this.searchNote !== undefined; + this.extra = []; + this.searchNote = undefined; + if (hadExtra) { this.paint(); } + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + } + if (!this.networkSearch() || this.query.trim().length < 2) { + this.extra = []; + this.searchNote = undefined; + return; + } + this.searchTimer = setTimeout(() => { + this.searchTimer = undefined; + void this.runCrossRegistrySearch(); + }, SEARCH_DEBOUNCE_MS); + } + + private networkSearch(): boolean { + return read("mcpp.library.networkSearch") === true; + } + + /** + * The cross-registry tier: `mcpp search `. + * + * Best effort by design (§10.2): it may use the network, its output is human + * text (upstream request U.8 asks for `--format json`), and any failure keeps + * the local results and *says so* rather than failing silently. Only ids the + * local index does not already have are added. + */ + private async runCrossRegistrySearch(): Promise { + const query = this.query.trim(); + const generation = ++this.requestGeneration; + const projectRoot = this.deps.projectRoot(); + if (!this.networkSearch() || query.length < 2) { + this.extra = []; + this.searchNote = undefined; + this.paint(); + return; + } + // `mcpp search` runs the configured executable and `mcpp.path` is + // resource-scoped: an untrusted workspace must not name the program, so the + // tier refuses and says so instead of silently showing nothing extra. + this.searchNote = t("Searching other registries…"); + this.paint(); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Searching all registries…") }, + () => + runMcpp(this.deps.isTrusted(), this.deps.mcppExecutable(), ["search", query], this.deps.projectRoot(), { + timeoutMs: SEARCH_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (generation !== this.requestGeneration || !this.networkSearch() || this.query.trim() !== query || this.deps.projectRoot() !== projectRoot) { return; } + if (result === undefined) { + this.extra = []; + this.searchNote = t("The workspace is not trusted; the other registries were not searched."); + this.paint(); + return; + } + if (result.exitCode !== 0) { + this.extra = []; + this.searchNote = t( + "Searching all registries failed (exit {0}); showing the local index only.", + result.exitCode, + ); + this.paint(); + return; + } + const known = new Set((this.snapshot?.entries ?? []).map((entry) => entry.id)); + this.extra = parseSearchOutput(result.stdout) + .filter((hit) => !known.has(hit.id)) + .map((hit) => ({ + id: hit.id, + key: `search:${hit.id}`, + source: t("Other registry"), + name: hit.id.includes(".") ? hit.id.slice(hit.id.indexOf(".") + 1) : hit.id, + ...(hit.id.includes(".") ? { namespace: hit.id.slice(0, hit.id.indexOf(".")) } : {}), + ...(hit.version === undefined ? {} : { version: hit.version }), + surfaces: [], + badges: [], + haystack: `${hit.id} ${hit.description ?? ""}`.toLowerCase(), + ...(hit.description === undefined ? {} : { description: hit.description }), + added: false, + unreadable: false, + crossRegistry: true, + })); + this.searchNote = + this.extra.length === 0 + ? t("No further packages were found in the other registries.") + : t("{0} further package(s) came from the other registries.", this.extra.length); + this.paint(); + } + + private labels(): Record { + return { + [LIBRARY_UI.htmlLang]: htmlLanguage(), + [LIBRARY_UI.title]: t("Libraries"), + [LIBRARY_UI.sources]: t("Source details"), + [LIBRARY_UI.other]: t("Other registries"), + [LIBRARY_UI.settings]: t("Set index path"), + [LIBRARY_UI.update]: t("Update mcpp index"), + [LIBRARY_UI.output]: t("Show output"), + [LIBRARY_UI.clear]: t("Clear search"), + [LIBRARY_UI.search]: t("Search packages"), + [LIBRARY_UI.networkSearch]: t("Search other registries"), + [LIBRARY_UI.networkSearchHint]: t( + "Off by default: this tier runs mcpp search, which may use the network and is read from human output on a best-effort basis. Failures fall back to the local index.", + ), + [LIBRARY_UI.versionLatest]: t("{0}"), + [LIBRARY_UI.surfaceExternal]: t("upstream mcpp.toml"), + [LIBRARY_UI.badgeExamples]: t("✓ Has examples"), + [LIBRARY_UI.badgeCn]: t("China mirror"), + [LIBRARY_UI.badgeOpenkalEcosystem]: t("openkal-ecosystem"), + [LIBRARY_UI.badgeOpenkalCompat]: t("openkal-compat"), + [LIBRARY_UI.badgeOpenkalPosix]: t("POSIX environment"), + [LIBRARY_UI.badgeOpenkalPlatform]: t("uses platform interfaces"), + [LIBRARY_UI.added]: t("Declared"), + [LIBRARY_UI.unreadable]: t("Descriptor not readable"), + [LIBRARY_UI.noResults]: t("No package matches this search."), + [LIBRARY_UI.noIndex]: t( + "No mcpp index was found. Run mcpp: Refresh the mcpp Package Index once, or set mcpp.library.indexPath to an index checkout.", + ), + [LIBRARY_UI.dataSource]: t("Data source"), + [LIBRARY_UI.count]: t("{0} of {1} packages"), + [LIBRARY_UI.open]: t("Open the detail page for {0}"), + [LIBRARY_UI.crossRegistry]: t("other registry"), + [LIBRARY_UI.refresh]: t("Reread locally"), + }; + } + + /** + * Put the current model on screen — but only when it says something new. + * + * `webview.html = …` reloads the document, so pushing an identical one would + * throw away the scroll position and the half-typed query for nothing. With + * a per-view nonce, "identical" means identical: the same model renders byte + * for byte the same document, and a render that produced it is dropped in + * `WebviewDocument.paint()`. + */ + private paint(): void { + const view = this.view; + if (view === undefined) { + return; + } + const document = renderLibraryHtml( + this.model(), + this.webviewDocument.assets(vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY), view.webview), + ); + if (document === this.lastHtml) { return; } + this.lastHtml = document; + if (!this.painted) { + this.webviewDocument.paint(view.webview, document); + this.painted = true; + } else { + const model = this.model(); + void view.webview.postMessage({ type: "libraryUpdate", html: document, state: libraryClientState(model), networkSearch: model.networkSearch }); + } + } + + private model(): LibraryModel { + const snapshot = this.snapshot; + const ui = this.labels(); + if (snapshot === undefined) { + return this.emptyModel(ui, this.error ?? t("Reading the index…"), t("Reading the index…")); + } + const entries = snapshot.entries; + const rows = [...entries.map((entry) => rowOf(entry, snapshot.roots.some((root) => root.kind === "custom"))), ...this.extra]; + const notice = + snapshot.roots.length === 0 + ? ui[LIBRARY_UI.noIndex] + : entries.length === 0 + ? t("The index folders contain no descriptors.") + : ui[LIBRARY_UI.noResults]; + return { + ui, + rows, + selectedKey: this.selectedKey, + query: this.query, + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource: this.dataSource(snapshot.roots, entries.length), + sourceDetails: snapshot.roots.map((root) => `${root.kind === "official" ? t("Official index") : t("Custom index")}: ${root.path}${root.updatedAt === undefined ? "" : ` · ${new Date(root.updatedAt).toLocaleString()}`}`).join("\n"), + status: [this.error, this.searchNote, this.updateNote].filter(Boolean).join(" ") || undefined, + projectNotice: this.deps.projectRoot() === undefined ? t("Browse libraries; select an mcpp project in the detail page to add dependencies.") : undefined, + countTemplate: ui[LIBRARY_UI.count], + total: rows.length, + ...(rows.length === 0 ? { notice } : {}), + }; + } + + private emptyModel(ui: Record, notice: string, dataSource: string): LibraryModel { + return { + ui, + rows: [], + query: "", + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource, + countTemplate: ui[LIBRARY_UI.count], + total: 0, + notice, + }; + } + + /** The footer, in plain words: where the rows came from and whether it is offline. */ + private dataSource(roots: readonly IndexRoot[], total: number): string { + if (roots.length === 0) { return t("No local mcpp-index found"); } + const name = roots.length === 1 && roots[0].kind === "official" ? "mcpp-index" : t("Custom index"); + return t("{0} · {1} packages · local", name, total); + } +} + +/** One entry as the row the document renders. */ +function rowOf(entry: LibraryEntry, showSource = false): LibraryRow { + return { + id: entry.id, + key: entry.file, + ...(showSource ? { source: entry.registry } : {}), + haystack: searchText(entry), + ...(entry.namespace === undefined ? {} : { namespace: entry.namespace }), + name: entry.name, + ...(entry.version === undefined ? {} : { version: entry.version }), + ...(entry.surface === undefined ? {} : { surface: entry.surface }), + surfaces: entry.surfaces, + badges: badgesOf(entry), + ...(entry.description === undefined ? {} : { description: entry.description }), + added: entry.added, + unreadable: entry.unreadable === true, + }; +} + + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} diff --git a/src/library/projectState.ts b/src/library/projectState.ts new file mode 100644 index 0000000..ec16bac --- /dev/null +++ b/src/library/projectState.ts @@ -0,0 +1,105 @@ +import type { DeclaredDependencyEntry, LockPackage } from "./indexModel"; +import { parseMcppToml, type TomlValueNode, type TomlKeyValueNode } from "../toml/parser"; + +export type DependencyKind = "normal" | "dev" | "build"; + +export interface ProjectDeclaration extends DeclaredDependencyEntry { + kind?: DependencyKind; + source?: boolean; + conditional?: boolean; + path?: string; + git?: string; +} + +/** Reuse the editor's TOML parser so quoted keys, comments and source+version tables keep their meaning. */ +export function readProjectDeclarations(text: string): ProjectDeclaration[] { + const out: ProjectDeclaration[] = []; + const collect = (id: string, value: TomlValueNode | undefined, kind: DependencyKind, conditional: boolean): void => { + const dev = kind === "dev"; + if (!value) { return; } + if (value.kind === "string") { out.push({ id, version: value.text, dev, kind, conditional }); return; } + const entries = value.entries ?? []; + const source = entries.some((entry) => ["path", "git", "url"].includes(entry.keyPath[0]?.name)); + const version = entries.find((entry) => entry.keyPath[0]?.name === "version")?.value?.text; + const specKeys = new Set(["version", "path", "git", "url", "branch", "rev", "tag", "features", "backend", "tools", "host-module", "reexport", "workspace"]); + if (source || entries.some((entry) => specKeys.has(entry.keyPath[0]?.name))) { + const localPath = entries.find((entry) => entry.keyPath[0]?.name === "path")?.value?.text; + const git = entries.find((entry) => entry.keyPath[0]?.name === "git")?.value?.text; + out.push({ id, version, dev, kind, source, conditional, ...(localPath ? { path: localPath } : {}), ...(git ? { git } : {}) }); + } else if (entries.length) { + for (const entry of entries) { collect(`${id}.${entry.keyPath.map((key) => key.name).join(".")}`, entry.value, kind, conditional); } + } else { out.push({ id, dev, kind, conditional }); } + }; + const groups: Array<{ section: string[]; entries: TomlKeyValueNode[] }> = [{ section: [], entries: [] }]; + for (const node of parseMcppToml(text.split(/\r?\n/)).nodes) { + if (node.type === "section") { groups.push({ section: node.segments.map((segment) => segment.name), entries: [] }); } + else { groups[groups.length - 1].entries.push(node); } + } + for (const { section, entries } of groups) { + const index = section.findIndex((part) => part === "dependencies" || part === "dev-dependencies" || part === "build-dependencies"); + if (index < 0 || section[0] === "workspace") { continue; } + const kind = section[index] === "dev-dependencies" ? "dev" : section[index] === "build-dependencies" ? "build" : "normal"; + if (section.length > index + 1 && entries.some((entry) => entry.keyPath.length === 1 && ["version", "path", "git", "url", "workspace"].includes(entry.keyPath[0].name))) { + collect(section.slice(index + 1).join("."), { kind: "inlineTable", open: false, range: entries[0].range, entries }, kind, index !== 0); + } else { + for (const node of entries) { + const id = [...section.slice(index + 1), ...node.keyPath.map((key) => key.name)].join("."); + collect(id, node.value, kind, index !== 0); + } + } + } + return out; +} + +export interface ProjectDependencyState { + declared: boolean; + declaredVersion?: string; + resolvedVersion?: string; + dev: boolean; + sourceType: "registry" | "path-or-git" | "none"; + ambiguous: boolean; +} + +/** Qualified coordinates win. A short name is usable only when the catalog is unambiguous. */ +export function projectDependencyState( + id: string, + declarations: readonly ProjectDeclaration[], + locked: readonly LockPackage[], + catalog: readonly string[], + kind?: DependencyKind, +): ProjectDependencyState { + declarations = declarations.filter((entry) => kind === undefined ? entry.kind !== "build" : (entry.kind ?? (entry.dev ? "dev" : "normal")) === kind); + const short = id.slice(id.lastIndexOf(".") + 1); + const unique = new Set(catalog.filter((candidate) => candidate.slice(candidate.lastIndexOf(".") + 1) === short)).size === 1; + const exact = declarations.filter((entry) => entry.id === id); + const matches = exact.length ? exact : unique ? declarations.filter((entry) => entry.id === short) : []; + const entry = matches[0]; + const resolved = locked.find((entry) => entry.id === id) ?? (unique ? locked.find((entry) => entry.id === short) : undefined); + return { + declared: entry !== undefined, + ...(entry?.version === undefined ? {} : { declaredVersion: entry.version }), + ...(resolved === undefined ? {} : { resolvedVersion: resolved.version }), + dev: entry?.dev ?? false, + sourceType: entry === undefined ? "none" : entry.source || entry.version === undefined ? "path-or-git" : "registry", + ambiguous: matches.length > 1 || !!entry?.conditional || (!unique && exact.length === 0 && declarations.some((entry) => entry.id === short)), + }; +} + +/** Every asynchronous result belongs to both an operation and a rendered page. */ +export function operationMatches( + operation: { key: string; projectRoot?: string; generation: number }, + page: { key: string; projectRoot?: string; generation: number }, +): boolean { + return operation.key === page.key && operation.projectRoot === page.projectRoot && operation.generation === page.generation; +} + + +/** TOML preview only. The CLI currently cannot add build dependencies or migrate groups. */ +export function dependencyLocation(id: string, kind: DependencyKind): { table: string; name: string } { + const at = id.lastIndexOf("."); + const namespace = at < 0 ? "" : id.slice(0, at); + const name = at < 0 ? id : id.slice(at + 1); + const quote = (value: string) => /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value); + const section = kind === "build" ? "build-dependencies" : kind === "dev" ? "dev-dependencies" : "dependencies"; + return { table: !namespace || namespace === "mcpplibs" ? section : `${section}.${quote(namespace)}`, name: quote(name) }; +} diff --git a/src/library/projectWatcher.ts b/src/library/projectWatcher.ts new file mode 100644 index 0000000..5a463e7 --- /dev/null +++ b/src/library/projectWatcher.ts @@ -0,0 +1,34 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +/** Compare the two small project files, including states observed by a rendered detail. + * Native events may be unavailable and a file can appear/disappear between ticks. + */ +export function watchProjectFiles(root: string, changed: () => void, interval = 1000): { dispose(): void; check(): Promise; observe(manifest: string, lock: string): void } { + const files = ["mcpp.toml", "mcpp.lock"].map((name) => path.join(root, name)); + let previous: string | undefined; + let disposed = false; + let checking: Promise | undefined; + let again = false; + const fingerprint = (texts: readonly string[]) => createHash("sha256").update(JSON.stringify(texts)).digest("hex"); + const check = (): Promise => { + if (disposed) { return Promise.resolve(); } + if (checking) { again = true; return checking; } + checking = (async () => { + do { + again = false; + const texts = await Promise.all(files.map((file) => fs.readFile(file, "utf8").catch(() => ""))); + if (disposed) { return; } + const next = fingerprint(texts); + const different = previous !== undefined && next !== previous; + previous = next; + if (different) { changed(); } + } while (again && !disposed); + })().finally(() => { checking = undefined; }); + return checking; + }; + const timer = setInterval(() => { void check(); }, interval); + void check(); + return { check, observe: (manifest, lock) => { if (!disposed) { previous = fingerprint([manifest, lock]); } }, dispose: () => { disposed = true; clearInterval(timer); } }; +} diff --git a/src/library/xpkg.ts b/src/library/xpkg.ts new file mode 100644 index 0000000..f0355e6 --- /dev/null +++ b/src/library/xpkg.ts @@ -0,0 +1,434 @@ +/** + * `mcpp xpkg parse --json`, decoded — as a pure module. + * + * The index's Lua descriptors are read cheaply and tolerantly by + * `src/library/indexModel.ts` (a few single-line catalog fields, no process). + * Everything **authoritative** comes from here instead: `mcpp`'s own resolver + * is the only thing that knows which platform a version belongs to, whether a + * descriptor is Form A, and what a target's `kind` is. That command is run on + * demand — when a row is expanded or the detail page opens — never once per + * package (233 spawns would freeze the view). + * + * The document is the flat shape `mcpp` prints, verified against + * `mcpp 2026.9.30.2` and all 239 `mcpplibs` descriptors: + * + * ```json + * {"namespace":"compat","name":"argparse", + * "versions":{"linux":["3.2"],"macosx":["3.2"],"windows":["3.2"]}, + * "standard":"c++23","import_std":false, + * "sources":["mcpp_generated/argparse_anchor.c"], + * "include_dirs":["<archive>/include"], + * "generated_files":[{"path":"…","bytes":52}], + * "generated_contents":{"mcpp_generated/argparse_anchor.c":"…"}, + * "targets":["argparse"],"unknown_keys":[]} + * ``` + * + * Two facts this module is built around, both measured rather than assumed: + * + * - a **Form A** descriptor answers with only `namespace`, `name`, `versions` + * and `form: "A"` — it deliberately carries no build information, because the + * upstream archive ships its own manifest. That is the index's `external` + * surface ("上游 mcpp.toml"), and it is the only honest answer offline. + * - `targets` is an **array of names** in every descriptor here (239/239). The + * site generator's `targets` *kinds* come from the upstream manifest, which + * `xpkg parse` does not expose. So a `kind` is honoured when the JSON happens + * to carry one and is not invented when it does not. + */ + +/** + * The consumer-facing usage label, exactly the index site's `SURFACES` table + * (`.xpkgindex/plugins/mcpp.py`): what a reader does with the package, not how + * it is built. An earlier design invented shape letters here; those were wrong. + */ +export type Surface = "module" | "header" | "tool" | "external"; + +/** `SURFACES` order, which is also the order the chips and badges are shown in. */ +export const SURFACES: readonly Surface[] = ["module", "header", "tool", "external"]; + +/** + * The label and the example line for each surface. + * + * `import` / `#include` / `tool` are code, not prose — the site generator emits + * them untranslated in every language, so they are constants here. Only + * `external` is a sentence, and it is looked up through `uiKey` so the caller + * can localize it. `usage` is the muted placeholder the site shows when a + * descriptor names no module or header of its own. + */ +export const SURFACE_TEXT: Readonly< + Record +> = { + module: { label: "import", usage: "import x.y;" }, + header: { label: "#include", usage: "#include " }, + tool: { label: "tool", usage: "$ tool" }, + external: { label: "upstream mcpp.toml", usage: "import x.y;", uiKey: "library.surface.external" }, +}; + +/** One `targets[]` entry. `mcpp` prints bare names; the site's kinds are optional. */ +export interface XpkgTarget { + name?: string; + kind?: string; +} + +/** The decoded `mcpp xpkg parse --json` document. Every list is present. */ +export interface XpkgInfo { + namespace: string; + name: string; + /** `form: "A"` means the descriptor names no build shape of its own. */ + form?: string; + versions: Record; + standard?: string; + importStd?: boolean; + sources: string[]; + includeDirs: string[]; + generatedFiles: string[]; + /** Only the paths; the contents themselves are of no use to a reader. */ + generatedContents: string[]; + targets: XpkgTarget[]; + unknownKeys: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +/** A `versions` object; anything else becomes an empty table. */ +function versionTable(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const out: Record = {}; + for (const [platform, list] of Object.entries(value)) { + const versions = stringList(list); + if (versions.length > 0) { + out[platform] = versions; + } + } + return out; +} + +function targetList(value: unknown): XpkgTarget[] { + if (!Array.isArray(value)) { + return []; + } + const out: XpkgTarget[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + out.push({ name: entry }); + continue; + } + if (isRecord(entry)) { + const name = typeof entry.name === "string" ? entry.name : undefined; + const kind = typeof entry.kind === "string" ? entry.kind : undefined; + out.push({ ...(name === undefined ? {} : { name }), ...(kind === undefined ? {} : { kind }) }); + } + } + return out; +} + +/** + * Decode one already-parsed JSON value. Anything that is not the documented + * document — a different `kind`, a resolver error envelope, a hand-edited file — + * returns `undefined`, which the callers render as "cannot read this descriptor" + * rather than as a package with no versions. + */ +export function parseXpkgJsonValue(value: unknown): XpkgInfo | undefined { + if (!isRecord(value)) { + return undefined; + } + const name = value.name; + if (typeof name !== "string" || name.length === 0) { + return undefined; + } + const namespace = typeof value.namespace === "string" ? value.namespace : ""; + const generatedContents = isRecord(value.generated_contents) ? Object.keys(value.generated_contents) : []; + return { + namespace, + name, + ...(typeof value.form === "string" && value.form.length > 0 ? { form: value.form } : {}), + versions: versionTable(value.versions), + ...(typeof value.standard === "string" && value.standard.length > 0 ? { standard: value.standard } : {}), + ...(typeof value.import_std === "boolean" ? { importStd: value.import_std } : {}), + sources: stringList(value.sources), + includeDirs: stringList(value.include_dirs), + generatedFiles: generatedFilePaths(value.generated_files), + generatedContents, + targets: targetList(value.targets), + unknownKeys: stringList(value.unknown_keys), + }; +} + +/** `generated_files` is either a list of paths or a list of `{path, bytes}`. */ +function generatedFilePaths(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const out: string[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + out.push(entry); + } else if (isRecord(entry) && typeof entry.path === "string" && entry.path.length > 0) { + out.push(entry.path); + } + } + return out; +} + +/** + * Decode the command's stdout. A non-JSON answer (an error message on stdout, a + * partial write) returns `undefined`; the caller keeps the descriptor's own + * fields and says the authoritative read failed. + */ +export function parseXpkgJson(text: string): XpkgInfo | undefined { + const trimmed = text.trim(); + if (trimmed.length === 0) { + return undefined; + } + try { + return parseXpkgJsonValue(JSON.parse(trimmed)); + } catch { + return undefined; + } +} + +/** `.cppm` anywhere in a source or a generated file makes the package modular. */ +function isModular(info: XpkgInfo): boolean { + return ( + info.generatedContents.some((path) => path.endsWith(".cppm")) || + info.sources.some((source) => source.includes(".cppm")) || + info.generatedFiles.some((path) => path.endsWith(".cppm")) + ); +} + +/** A target the site files under the `tool` surface. */ +function hasBinaryTarget(info: XpkgInfo): boolean { + return info.targets.some((target) => /^(bin|binary)$/i.test(target.kind ?? "")); +} + +/** + * Every surface a descriptor's **parsed fields** support, in the order the site + * generator emits them (`_surfaces`): a module package may also offer its + * header, a package may ship both a library and a binary. + * + * A Form A descriptor answers `["external"]` and nothing else: the fields that + * would justify a module or header claim are not in the descriptor, and the + * index site only fills them in by fetching the upstream manifest — which this + * extension deliberately never does. + * + * An empty array is a real answer, not a failure, and it happens when a + * descriptor's fields name targets but neither a module nor a header. The site's + * generator degrades exactly that case to `external`; saying nothing is more + * honest than calling an inline Form B descriptor "upstream mcpp.toml". The row + * then shows no usage label, and the detail page still shows everything known. + * (The seven `compat.*-runtime` anchor packages are *not* that case: they carry a + * generated `.c` source, which is the header surface the generator gives a Form B + * package whose interfaces no module wraps.) + */ +export function surfacesOf(info: XpkgInfo): Surface[] { + if (info.form !== undefined) { + return ["external"]; + } + const out: Surface[] = []; + const modular = isModular(info); + if (modular) { + out.push("module"); + } + // `include_dirs` is not by itself a public header surface: on a module package + // it can exist only so the wrapper's own `#include` resolves. The site + // generator therefore keeps the header surface only for a package that is not + // modular (or that demonstrates an include line, which needs the example scan). + if (!modular && (info.includeDirs.length > 0 || info.sources.length > 0)) { + out.push("header"); + } + if (hasBinaryTarget(info)) { + out.push("tool"); + } + return out; +} + +/** The surface a row leads with, or `undefined` when nothing is claimed. */ +export function surfaceOf(info: XpkgInfo): Surface | undefined { + return surfacesOf(info)[0]; +} + +/** Does the descriptor's own text name a `kind = "bin"` target? */ +export function textNamesBinaryTarget(text: string): boolean { + return /\bkind\s*=\s*"(?:bin|binary)"/.test(text); +} + +/** + * The site's per-platform version groups, in a stable order: the current + * platform first, then the rest as `mcpp` printed them. Used by the detail + * page's version matrix, where the current platform must be the one a reader + * finds first. + */ +export function versionGroups( + versions: Record, + currentPlatform: string | undefined, +): Array<{ platform: string; versions: string[] }> { + const platforms = Object.keys(versions); + const ordered = [ + ...(currentPlatform !== undefined && platforms.includes(currentPlatform) ? [currentPlatform] : []), + ...platforms.filter((platform) => platform !== currentPlatform), + ]; + return ordered.map((platform) => ({ platform, versions: [...versions[platform]] })); +} + +/** + * The current platform as `mcpp` names it: `linux` / `macosx` / `windows`. + * `undefined` for a platform the index has no vocabulary for, so the caller + * shows the matrix rather than guessing which column is "mine". + */ +export function platformKey(nodePlatform: string): string | undefined { + switch (nodePlatform) { + case "linux": + return "linux"; + case "darwin": + return "macosx"; + case "win32": + return "windows"; + default: + return undefined; + } +} + +/** + * Version order, total and deterministic. + * + * mcpp's versions are dotted numbers (`3.2`, `1.6.43`, `0.2.4`), sometimes with + * a packaging revision (`22.1.8.1`), a prerelease tail (`1.0.0-rc1`) or a + * non-semver tag the upstream project publishes (`b10069.2`, the llama.cpp + * checkpoint revisions). The comparison follows the shape a reader expects: + * + * - the dotted core first, segment by segment, numerically when both segments + * are numbers (`1.10` is after `1.9`); + * - build metadata (`+…`) is ignored; + * - a prerelease (`-rc1`) sorts **below** its release, as semver says; + * - a numeric segment outranks a non-numeric one, which is what makes the + * `"latest"` alias some xlings descriptors publish lose to the concrete + * version beside it — the only answer `mcpp add` accepts. + */ +export function compareVersions(a: string, b: string): number { + const left = splitVersion(a); + const right = splitVersion(b); + const core = compareSegments(left.core, right.core); + if (core !== 0) { + return core; + } + if (left.pre === undefined && right.pre === undefined) { + return 0; + } + if (left.pre === undefined) { + return 1; + } + if (right.pre === undefined) { + return -1; + } + return compareSegments(left.pre, right.pre); +} + +function splitVersion(version: string): { core: string[]; pre?: string[] } { + const withoutBuild = version.split("+")[0]; + const dash = withoutBuild.indexOf("-"); + if (dash === -1) { + return { core: withoutBuild.split(".") }; + } + return { core: withoutBuild.slice(0, dash).split("."), pre: withoutBuild.slice(dash + 1).split(".") }; +} + +function compareSegments(left: readonly string[], right: readonly string[]): number { + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const one = left[index]; + const two = right[index]; + if (one === undefined) { + return two === undefined ? 0 : -1; + } + if (two === undefined) { + return 1; + } + const oneNumber = /^\d+$/.test(one); + const twoNumber = /^\d+$/.test(two); + if (oneNumber && twoNumber) { + const difference = Number(one) - Number(two); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + continue; + } + if (oneNumber !== twoNumber) { + return oneNumber ? 1 : -1; + } + const natural = compareNatural(one, two); + if (natural !== 0) { + return natural; + } + } + return 0; +} + +/** `rc2` before `rc10`: within an alphanumeric identifier, digit runs are numbers. */ +function compareNatural(left: string, right: string): number { + const one = left.match(/\d+|\D+/g) ?? []; + const two = right.match(/\d+|\D+/g) ?? []; + const length = Math.max(one.length, two.length); + for (let index = 0; index < length; index += 1) { + const a = one[index]; + const b = two[index]; + if (a === undefined) { + return b === undefined ? 0 : -1; + } + if (b === undefined) { + return 1; + } + const aNumber = /^\d+$/.test(a); + const bNumber = /^\d+$/.test(b); + if (aNumber && bNumber) { + const difference = Number(a) - Number(b); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + continue; + } + if (a !== b) { + return a < b ? -1 : 1; + } + } + return 0; +} + +/** The list's versions, greatest first. */ +export function sortVersions(versions: readonly string[]): string[] { + return [...versions].sort((a, b) => compareVersions(b, a)); +} + +/** + * The version `mcpp add` must be given: the greatest one this platform has. + * + * `undefined` means the index knows no version for the current platform, and the + * UI says so instead of running a command mcpp would reject. + */ +export function latestVersion(versions: Record, platform: string | undefined): string | undefined { + if (platform === undefined) { + return undefined; + } + const list = versions[platform]; + if (list === undefined || list.length === 0) { + return undefined; + } + return sortVersions(list)[0]; +} + +/** Every version this platform has, greatest first; `[]` when it has none. */ +export function versionsFor(versions: Record, platform: string | undefined): string[] { + if (platform === undefined) { + return []; + } + return sortVersions(versions[platform] ?? []); +} diff --git a/src/llvmTools.ts b/src/llvmTools.ts deleted file mode 100644 index be064f9..0000000 --- a/src/llvmTools.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { existsSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; - -import type { ToolIdentity } from "./analysis"; -import { runProcess, type ProcessResult } from "./process"; - -export function llvmToolsVersionSpec(identity: ToolIdentity): string { - return `${identity.major}.${identity.minor}.${identity.patch}`; -} - -const COMPILER_PATH_VERSION_REGEX = /[\\/]xim-x-llvm[\\/](\d+(?:\.\d+){1,3})[\\/]bin[\\/]clang(?:\+\+|-cl)?(?:\.exe)?$/i; - -export function extractVersionFromCompilerPath(compilerPath: string): string | undefined { - const match = compilerPath.match(COMPILER_PATH_VERSION_REGEX); - return match?.[1]; -} - -function isWindowsPath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\"); -} - -export function deriveInstalledClangdPath( - compilerPath: string, - version: string, -): string[] { - const windows = isWindowsPath(compilerPath); - const pathApi = windows ? path.win32 : path.posix; - const candidateMatch = compilerPath.match( - /^(.*?[\\/])xim-x-llvm[\\/]\d+(?:\.\d+){1,3}[\\/]bin[\\/]clang(?:\+\+|-cl)?(?:\.exe)?$/i, - ); - const basePath = candidateMatch?.[1]; - const extension = /\.exe$/i.test(compilerPath) ? ".exe" : ""; - - const candidates: string[] = []; - if (basePath !== undefined) { - candidates.push( - pathApi.join(basePath, "xim-x-llvm-tools", version, "bin", `clangd${extension}`), - ); - } - - // Also try user xlings registry path if compiler is in mcpp registry - const userMatch = compilerPath.match( - /^(.*?)([\\/])\.mcpp[\\/]registry[\\/]data[\\/]xpkgs[\\/]xim-x-llvm[\\/]\d+(?:\.\d+){1,3}[\\/]bin[\\/]clang(?:\+\+|-cl)?(?:\.exe)?$/i, - ); - if (userMatch !== null) { - candidates.push( - `${userMatch[1]}${userMatch[2]}.xlings${userMatch[2]}data${userMatch[2]}xpkgs${userMatch[2]}xim-x-llvm-tools${userMatch[2]}${version}${userMatch[2]}bin${userMatch[2]}clangd${extension}`, - ); - } - - return candidates; -} - -export function xlingsInstallArgs(version?: string): string[] { - if (version !== undefined) { - return ["install", `xim:llvm-tools@${version}`]; - } - return ["update", "llvm-tools"]; -} - -export function findXlingsExecutable(): string | undefined { - const home = os.homedir(); - const knownPaths = [ - path.join(home, ".xlings", "subos", "current", "bin", "xlings"), - path.join(home, ".xlings", "bin", "xlings"), - ]; - if (process.platform === "win32") { - knownPaths.push( - path.join(home, ".xlings", "subos", "current", "bin", "xlings.exe"), - ); - } - - // Check known install paths first - for (const candidate of knownPaths) { - if (existsSync(candidate)) { - return candidate; - } - } - - // 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; -} - -function xlingsResolvableOnPath(): 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)) { - if (dir.length === 0) { - continue; - } - for (const name of names) { - if (existsSync(path.join(dir, name))) { - return true; - } - } - } - return false; -} - -export async function runXlingsCommand( - xlingsPath: string, - args: string[], - cwd?: string, -): Promise { - return runProcess(xlingsPath, args, cwd); -} diff --git a/src/mcppls/bridge.ts b/src/mcppls/bridge.ts new file mode 100644 index 0000000..8ff8369 --- /dev/null +++ b/src/mcppls/bridge.ts @@ -0,0 +1,106 @@ +/** + * The one place this extension talks to the C++ Modules extension. + * + * It forwards commands and nothing else: no LSP client, no reading of + * `mcppls.*` settings, no writing of them, no parsing of its logs. The + * capability table (`./contract.ts`) says what may be forwarded and how much + * confirmation each action needs; `./capabilities.ts` decides whether it exists. + * + * Results are **structured, not phrased**: a caller that has a user in front of + * it turns them into words through `./messages.ts`. That keeps this module free + * of `vscode` and therefore unit-testable, and it keeps the strings translatable + * without threading a translator through the probe logic. + */ + +import { CapabilityRegistry, type CapabilityEnvironment, type InvokeResult } from "./capabilities"; +import { capability } from "./contract"; + +export { MCPPLS_EXTENSION_ID } from "./contract"; + +export type LanguageServerCommandState = InvokeResult["state"]; +export type LanguageServerCommandResult = InvokeResult; +export { CapabilityRegistry } from "./capabilities"; +export type { CapabilityEnvironment } from "./capabilities"; + +export interface LanguageServerBridge { + /** The probe, exposed so the environment self-check can print the whole table. */ + readonly capabilities: CapabilityRegistry; + /** Generic entry point for the view and the menu. */ + invoke(key: string, ...args: unknown[]): Promise; + /** True when the user should not be offered this capability. */ + isGone(key: string): boolean; + /** True when it exists but the static read could not confirm it. */ + isUnconfirmed(key: string): boolean; + /** How much confirmation the action needs. */ + dangerOf(key: string): "none" | "confirm" | "destructive"; + + refreshLanguageServerAfterBuild(): Promise; + restartLanguageServer(): Promise; + restartEngine(): Promise; + resetWorkspaceCache(): Promise; + selectContext(): Promise; + showModuleGraph(): Promise; + showLanguageServerLogs(): Promise; + collectReport(): Promise; + exportDiagnosticBundle(): Promise; + runBuildToolInTerminal(): Promise; + manageConflicts(restore?: boolean): Promise; + toggleInWorkspace(enable: boolean): Promise; + installCommandLineTools(): Promise; + reviewChanges(clear?: boolean): Promise; +} + +/** Methods that map one-to-one onto a capability, so the bridge stays a table. */ +const FORWARDED = { + restartLanguageServer: "restartServer", + restartEngine: "restartEngine", + resetWorkspaceCache: "resetCache", + selectContext: "selectContext", + showModuleGraph: "moduleGraph", + showLanguageServerLogs: "logs", + collectReport: "report", + exportDiagnosticBundle: "diagnosticBundle", + runBuildToolInTerminal: "runBuildTool", + installCommandLineTools: "installTools", +} as const; + +export function createLanguageServerBridge(environment: CapabilityEnvironment): LanguageServerBridge { + const capabilities = new CapabilityRegistry(environment); + let refreshInFlight: Promise | undefined; + + const invoke = (key: string, ...args: unknown[]): Promise => + capabilities.invoke(key, ...args); + + /** + * Builds are frequent; a second refresh while one is in flight would restart + * the language server twice for one edit. The single-flight promise is shared, + * so both callers see the same outcome. + */ + function refreshLanguageServerAfterBuild(): Promise { + if (refreshInFlight !== undefined) { + return refreshInFlight; + } + refreshInFlight = invoke("refresh").finally(() => { + refreshInFlight = undefined; + }); + return refreshInFlight; + } + + const forwarded = Object.fromEntries( + Object.entries(FORWARDED).map(([method, key]) => [method, () => invoke(key)]), + ) as Record Promise>; + + return { + capabilities, + invoke, + isGone: (key) => capabilities.isGone(key), + isUnconfirmed: (key) => capabilities.isUnconfirmed(key), + dangerOf: (key) => capability(key)?.danger ?? "none", + refreshLanguageServerAfterBuild, + ...forwarded, + // The two commands whose *argument* selects the direction. + manageConflicts: (restore = false) => invoke(restore ? "restoreOtherCppFeatures" : "turnOffOtherCppFeatures"), + toggleInWorkspace: (enable) => invoke("toggleInWorkspace", enable), + reviewChanges: (clear = false) => invoke("review", clear), + }; +} diff --git a/src/mcppls/capabilities.ts b/src/mcppls/capabilities.ts new file mode 100644 index 0000000..2a9f64f --- /dev/null +++ b/src/mcppls/capabilities.ts @@ -0,0 +1,212 @@ +/** + * Capability probing and invocation for the C++ Modules extension. + * + * Pure: the only VS Code-shaped things it needs arrive through + * `CapabilityEnvironment`, which is exactly what the extension host supplies and + * what the tests fake. + * + * Why probing is two-level: + * + * - **Static** — `packageJSON.contributes.commands` says which ids mcppls + * declares. It costs nothing and never runs anything. A command the static + * read cannot see is *greyed out*, not hidden, so a wrong read cannot remove a + * feature. + * - **Runtime** — the first real use calls the command once and classifies the + * failure. `command '…' not found` means the capability is gone for this + * session; anything else is a failure of that one call and does not remove the + * feature. + * + * We deliberately do **not** probe by calling every command at activation: + * `selectContext`, `showModuleGraph` and `showLogs` are UI commands with side + * effects, and probing them would open pickers and panels. `commands.getCommands()` + * is not used either — whether it lists a contributed-but-unactivated command is + * not guaranteed across VS Code versions. + */ + +import { CAPABILITIES, MCPPLS_EXTENSION_ID, capability } from "./contract"; + +export interface CapabilityEnvironment { + /** `vscode.extensions.getExtension(id) !== undefined`. */ + extensionInstalled(id: string): boolean; + /** Declared command ids, from the extension's `package.json`. */ + declaredCommands?(id: string): readonly string[] | undefined; + /** Activate the dependency before the first forward, when VS Code has not. */ + activateExtension?(id: string): Thenable; + executeCommand(command: string, ...args: unknown[]): Thenable; +} + +export type CapabilityState = "available" | "declared" | "undeclared" | "missing" | "unavailable"; + +export interface CapabilityStatus { + key: string; + state: CapabilityState; + /** The command chosen for this capability, when there is one. */ + command?: string; +} + +export type InvokeState = "completed" | "unavailable" | "missing" | "failed"; + +export interface InvokeResult { + state: InvokeState; + capabilityKey: string; + /** The command that ran, when one ran. */ + command?: string; + /** Error text from the failed call, verbatim. */ + error?: string; + /** + * Whatever the command answered with, when it answered with anything. + * + * A few mcppls commands return a path — `mcppls.exportDiagnosticBundle` + * resolves to the zip it wrote (`exportDiagnosticBundle(): Promise` upstream) — and that is the only reliable way to point at the + * file afterwards. Most commands return nothing, and the caller must treat this + * as unknown: it is passed on untouched, never parsed. + */ + value?: unknown; +} + +/** `command 'x' not found` in the several spellings VS Code has used. */ +const NOT_FOUND = /command\s+(?:['"`][^'"`]+['"`]\s+)?not found|not registered|no such command/i; + +export function classifyCommandError(error: unknown): "missing" | "failed" { + const message = error instanceof Error ? error.message : String(error); + return NOT_FOUND.test(message) ? "missing" : "failed"; +} + +export class CapabilityRegistry { + private readonly statuses = new Map(); + + public constructor(private readonly environment: CapabilityEnvironment) { + this.probe(); + } + + /** Re-read the static declaration; called when extensions are installed, enabled or updated. */ + public invalidate(): void { + this.statuses.clear(); + this.probe(); + } + + public status(key: string): CapabilityStatus { + return this.statuses.get(key) ?? { key, state: "unavailable" }; + } + + /** Capabilities whose first command is believed usable right now. */ + public availableKeys(): string[] { + return [...this.statuses.values()] + .filter((status) => status.state === "available" || status.state === "declared") + .map((status) => status.key); + } + + /** True when the user should not be offered the capability. */ + public isGone(key: string): boolean { + const state = this.status(key).state; + return state === "unavailable" || state === "missing"; + } + + /** True when the capability exists but the static read could not confirm it: grey out, do not hide. */ + public isUnconfirmed(key: string): boolean { + return this.status(key).state === "undeclared"; + } + + private probe(): void { + const installed = this.environment.extensionInstalled(MCPPLS_EXTENSION_ID); + const declared = installed ? this.environment.declaredCommands?.(MCPPLS_EXTENSION_ID) : undefined; + for (const entry of CAPABILITIES) { + if (entry.kind === "readState") { + // State comes from `extension.exports`, which cannot be inspected without + // activating; the reader decides and reports for itself. + this.statuses.set(entry.key, { key: entry.key, state: installed ? "declared" : "unavailable" }); + continue; + } + if (!installed) { + this.statuses.set(entry.key, { key: entry.key, state: "unavailable" }); + continue; + } + if (declared === undefined) { + // No static information: assume the first command works until a call says otherwise. + this.statuses.set(entry.key, { key: entry.key, state: "declared", command: entry.commands[0] }); + continue; + } + const command = entry.commands.find((candidate) => declared.includes(candidate)); + this.statuses.set( + entry.key, + command === undefined + ? { key: entry.key, state: "undeclared" } + : { key: entry.key, state: "declared", command }, + ); + } + } + + /** + * Run a capability, activating the dependency first. Returns the command that + * ran so the caller can name it in a log line. + */ + public async invoke(key: string, ...args: unknown[]): Promise { + const entry = capability(key); + if (entry === undefined) { + return { state: "failed", capabilityKey: key, error: `unknown capability ${key}` }; + } + if (!this.environment.extensionInstalled(MCPPLS_EXTENSION_ID)) { + return { state: "unavailable", capabilityKey: key }; + } + const known = this.status(key); + if (known.state === "missing") { + // Remembered from an earlier call: do not ask VS Code again. + return { state: "missing", capabilityKey: key }; + } + if (known.state === "unavailable") { + return { state: "unavailable", capabilityKey: key }; + } + + // Candidate chain: prefer the first command the probe could confirm. + const status = known; + const candidate = status.command ?? entry.commands[0]; + if (candidate === undefined) { + return { state: "failed", capabilityKey: key, error: `capability ${key} has no command` }; + } + + try { + await this.environment.activateExtension?.(MCPPLS_EXTENSION_ID); + const value = await this.environment.executeCommand(candidate, ...args); + this.statuses.set(key, { key, state: "available", command: candidate }); + return { + state: "completed", + capabilityKey: key, + command: candidate, + ...(value === undefined ? {} : { value }), + }; + } catch (error) { + const failure = classifyCommandError(error); + if (failure === "missing") { + // This command does not exist in the installed version: try the next + // candidate once, then remember. + const next = entry.commands.find((other) => other !== candidate); + if (next !== undefined) { + try { + const value = await this.environment.executeCommand(next, ...args); + this.statuses.set(key, { key, state: "available", command: next }); + return { + state: "completed", + capabilityKey: key, + command: next, + ...(value === undefined ? {} : { value }), + }; + } catch (second) { + if (classifyCommandError(second) === "missing") { + this.statuses.set(key, { key, state: "missing" }); + return { state: "missing", capabilityKey: key, command: candidate }; + } + return { state: "failed", capabilityKey: key, command: next, error: messageOf(second) }; + } + } + this.statuses.set(key, { key, state: "missing" }); + return { state: "missing", capabilityKey: key, command: candidate }; + } + return { state: "failed", capabilityKey: key, command: candidate, error: messageOf(error) }; + } + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/mcppls/contract.ts b/src/mcppls/contract.ts new file mode 100644 index 0000000..923b682 --- /dev/null +++ b/src/mcppls/contract.ts @@ -0,0 +1,273 @@ +/** + * The C++ Modules language service we depend on, as **data**. + * + * Everything this extension knows about `sunrisepeak.mcpp-language-server` + * (mcppls) lives here: its extension id, the commands we forward, and how badly + * each one needs confirming. No `vscode` import, so the table is unit-testable + * and `tools/` can read it. + * + * Two rules this file exists to keep: + * + * 1. **Our own commands never use the `mcppls.` prefix.** mcppls's S3 spec + * (S3-5.6-3) forbids a client from registering a command id the server + * advertises: `vscode-languageclient` registers those itself, and a collision + * stops the language client from starting. See `.agents/docs/mcppls-integration.md`. + * 2. **Nothing here writes an `mcppls.*` setting.** The only settings we ever + * touch are our own `mcpp.*`. The two commands that change mcppls state + * (`turnOn/turnOffInWorkspace`) are mcppls's own, and the user triggers them. + */ + +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; + +/** + * The version range this build was written against. Used for a **notice** only: + * a command that disappears is detected by calling it, not by reading a version, + * because a version number cannot predict a rename. + * + * The minimum is a fact in its own right (the comparison in `extension.ts` + * reads it through `compareVersions`), and the range string is its display + * form — one source of truth, two spellings (external review P1-3). + */ +export const VERIFIED_MCPPLS_MINIMUM = "0.0.4"; +export const VERIFIED_MCPPLS_RANGE = `>=${VERIFIED_MCPPLS_MINIMUM}`; + +export type CapabilityKind = "forward" | "readState"; + +/** How much ceremony an action needs before it runs. */ +export type Danger = "none" | "confirm" | "destructive"; + +export interface Capability { + key: string; + kind: CapabilityKind; + /** English; the UI resolves it through `src/i18n/t.ts`. */ + title: string; + /** Candidate command ids, most preferred first. Empty for `readState`. */ + commands: readonly string[]; + /** Always false: losing mcppls must never disable mcpp's own features. */ + required: boolean; + danger: Danger; + /** Shown when the capability is gone; says what still works. */ + degradedHint: string; + /** Extra sentence for a destructive action, spelling out what it does *not* touch. */ + confirmHint?: string; +} + +export const CAPABILITIES: readonly Capability[] = [ + { + key: "refresh", + kind: "forward", + title: "Refresh the C++ Modules build description", + commands: ["mcppls.reloadBuildDescription", "mcppls.restartServer"], + required: false, + danger: "none", + degradedHint: + "The build finished, but the language service could not be refreshed. Run \"C++ Modules: Restart Language Server\" from the Command Palette.", + }, + { + key: "selectContext", + kind: "forward", + title: "Select the C++ Modules analysis context", + commands: ["mcppls.selectContext"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a context picker; analysis uses the default context.", + }, + { + key: "moduleGraph", + kind: "forward", + title: "Show the module graph", + commands: ["mcppls.showModuleGraph"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a module graph.", + }, + { + key: "logs", + kind: "forward", + title: "Show the C++ Modules log", + commands: ["mcppls.showLogs"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a log command; look for its output channel in the Output view.", + }, + { + key: "readState", + kind: "readState", + title: "C++ Modules state", + commands: [], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not expose its state; only the actions below are available.", + }, + { + key: "restartServer", + kind: "forward", + title: "Restart the C++ Modules language server", + commands: ["mcppls.restartServer"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a restart command.", + }, + { + key: "restartEngine", + kind: "forward", + title: "Restart the C++ semantic engine", + commands: ["mcppls.restartClangd"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer an engine restart.", + confirmHint: "The semantic engine restarts; module preparation starts over and may take a while.", + }, + { + key: "resetCache", + kind: "forward", + title: "Reset this workspace's C++ Modules cache", + commands: ["mcppls.resetWorkspaceCache"], + required: false, + danger: "destructive", + degradedHint: "Installed C++ Modules does not offer a cache reset.", + confirmHint: + "This discards the language server's model cache for this workspace and prepares it again, which can take minutes. mcpp's own build cache and the project's target/ directory are not touched.", + }, + { + key: "logsDirectory", + // Verified upstream: `mcppls.revealCacheDirectory(which)` takes `'logs'` for + // `paths.logDirectory` and anything else for the workspace cache root + // (`editors/vscode/src/commands.ts` -> `revealCacheDirectory`), and the log + // directory is `/logs` (`src/orchestrator/workspace.cpp`). + // The argument is passed by the caller, so the chain stays one command. + kind: "forward", + title: "Open the C++ Modules log folder", + commands: ["mcppls.revealCacheDirectory"], + required: false, + danger: "none", + degradedHint: + "Installed C++ Modules does not offer a reveal command; its log lives in the mcppls cache directory.", + }, + { + key: "report", + kind: "forward", + title: "Collect a C++ Modules diagnostic report", + commands: ["mcppls.collectReport"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a report command.", + }, + { + key: "diagnosticBundle", + kind: "forward", + title: "Export a C++ Modules diagnostic bundle", + commands: ["mcppls.exportDiagnosticBundle"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a diagnostic bundle.", + }, + { + key: "runBuildTool", + kind: "forward", + title: "Run the build tool in a terminal", + commands: ["mcppls.runBuildToolInTerminal"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer this action.", + confirmHint: "The build tool runs in an integrated terminal, with its normal side effects.", + }, + // Two capabilities, not one with a candidate chain: a chain is for a renamed + // command, and "turn off" falling back to "restore" would do the opposite. + // `danger: "none"` because mcppls's own command asks where to act (this + // workspace or user settings) and does nothing when that question is closed; + // the settings it writes are the other extensions' own keys, never `mcppls.*`. + { + key: "turnOffOtherCppFeatures", + kind: "forward", + title: "Turn off other C++ language services", + commands: ["mcppls.turnOffOtherCppFeatures"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer this; turn the other extension's language features off in its own settings.", + }, + { + key: "restoreOtherCppFeatures", + kind: "forward", + title: "Restore other C++ language services", + commands: ["mcppls.restoreOtherCppFeatures"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer this; turn the other extension's language features on in its own settings.", + }, + { + key: "toggleInWorkspace", + kind: "forward", + title: "Enable or disable C++ Modules in this workspace", + commands: ["mcppls.turnOffInWorkspace", "mcppls.turnOnInWorkspace"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a per-workspace switch.", + confirmHint: "This writes mcppls.enable, a setting owned by the C++ Modules extension.", + }, + { + key: "installTools", + kind: "forward", + title: "Install the C++ Modules command line tools", + commands: ["mcppls.installCommandLineTools"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a command line tools installer.", + confirmHint: "This may install system packages the language server needs.", + }, + { + key: "review", + kind: "forward", + title: "Review workspace changes", + commands: ["mcppls.review.run", "mcppls.review.clear"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer its review commands here.", + }, +]; + +const BY_KEY = new Map(CAPABILITIES.map((capability) => [capability.key, capability])); + +export function capability(key: string): Capability | undefined { + return BY_KEY.get(key); +} + +/** The commands mcppls must declare for a capability to be usable at all. */ +export function commandsOf(key: string): readonly string[] { + return BY_KEY.get(key)?.commands ?? []; +} + +/** + * The capability table's own invariants (must be empty). + * + * The "our ids never use the mcppls prefix" rule is checked where our ids live + * (`src/commands/ids.ts`); this covers the forward table itself. + */ +export function capabilityProblems(): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const entry of CAPABILITIES) { + if (seen.has(entry.key)) { + problems.push(`duplicate capability ${entry.key}`); + } + seen.add(entry.key); + if (entry.required) { + problems.push(`${entry.key} is marked required; losing the language service must not disable mcpp features`); + } + if (entry.kind === "forward" && entry.commands.length === 0) { + problems.push(`forward capability ${entry.key} has no command`); + } + if (entry.kind === "readState" && entry.commands.length > 0) { + problems.push(`readState capability ${entry.key} must not name a command`); + } + for (const command of entry.commands) { + if (!command.startsWith("mcppls.")) { + problems.push(`${entry.key} forwards ${command}, which is not an mcppls. command`); + } + } + if (entry.danger !== "none" && entry.confirmHint === undefined) { + problems.push(`${entry.key} needs confirmation but has no confirmHint`); + } + } + return problems; +} diff --git a/src/mcppls/messages.ts b/src/mcppls/messages.ts new file mode 100644 index 0000000..7d0282e --- /dev/null +++ b/src/mcppls/messages.ts @@ -0,0 +1,61 @@ +/** + * Turning a structured bridge result into something a person reads. + * + * Split out of `./bridge.ts` so the bridge stays free of `vscode` (and testable) + * while these strings still go through `src/i18n/t.ts`. Every English string here + * is a key: `tools/l10n-check.mjs` fails the build if one has no Chinese entry. + */ + +import { t } from "../i18n/t"; +import type { LanguageServerCommandResult } from "./bridge"; +import { MCPPLS_EXTENSION_ID, capability } from "./contract"; + +export type ResultSeverity = "info" | "warning" | "error"; + +export interface FormattedResult { + severity: ResultSeverity; + message: string; + /** The capability's own advice, when it is gone. */ + hint?: string; +} + +function titleOf(key: string): string { + return capability(key)?.title ?? key; +} + +export function formatResult(result: LanguageServerCommandResult): FormattedResult { + const title = titleOf(result.capabilityKey); + switch (result.state) { + case "completed": + return { severity: "info", message: t("{0}: done.", title) }; + case "unavailable": + return { + severity: "warning", + message: t("The C++ Modules extension ({0}) is not installed or is disabled.", MCPPLS_EXTENSION_ID), + }; + case "missing": + return { + severity: "warning", + message: t("{0}: the installed C++ Modules does not offer this action.", title), + hint: capability(result.capabilityKey)?.degradedHint, + }; + default: + return { + severity: "error", + message: t("{0} failed: {1}", title, result.error ?? t("unknown error")), + }; + } +} + +/** Confirmation text for an action that needs one, or `undefined` when it does not. */ +export function confirmationFor(key: string, value?: unknown): string | undefined { + const entry = capability(key); + if (entry === undefined || entry.danger === "none") { + return undefined; + } + const parts = [entry.confirmHint ?? entry.title]; + if (typeof value === "boolean") { + parts.push(value ? t("Direction: enable.") : t("Direction: disable.")); + } + return parts.join(" "); +} diff --git a/src/mcppls/otherCppFeatures.ts b/src/mcppls/otherCppFeatures.ts new file mode 100644 index 0000000..827b2e6 --- /dev/null +++ b/src/mcppls/otherCppFeatures.ts @@ -0,0 +1,107 @@ +/** + * Which other C++ language services are serving next to C++ Modules, normalised. + * + * Pure, like `./state.ts`: no `vscode`, nothing here throws. Two sources: + * + * - **mcppls's own answer** (0.0.16+): `otherCppFeatures()` on the object its + * `activate()` returned. It uses the same candidate table and the same "is it + * active" decision as mcppls's own turn-off command, and it knows whether that + * command has something to restore. Preferred whenever it is there. + * - **A local, read-only fallback** for an older mcppls: the same three + * candidates, read from the installed extensions and their settings. It can + * never know what mcppls remembered, so `canRestore` is `false`. + * + * Either way this extension only *reads*. Turning a service off or restoring it + * is mcppls's command (`mcppls.turnOffOtherCppFeatures`, which asks where), and + * the settings it writes are the other extensions' own, never `mcppls.*`. + */ + +export interface OtherCppService { + extensionId: string; + displayName: string; + /** `true` when a setting turns its language features off; `false` (ccls) means disabling the extension. */ + settable: boolean; +} + +export interface OtherCppFeatures { + active: OtherCppService[]; + /** mcppls remembers settings it changed and can put them back. */ + canRestore: boolean; + /** Who answered: mcppls itself, or this extension's local read. */ + source: "mcppls" | "local"; +} + +/** A candidate the local read knows; `section`/`key`/`off` is how its language features are turned off. */ +interface Candidate { + extensionId: string; + displayName: string; + setting?: { section: string; key: string; off: unknown }; +} + +/** The same three mcppls checks (its `conflictCandidates.ts`), for when it cannot be asked. */ +export const OTHER_CPP_CANDIDATES: readonly Candidate[] = [ + { extensionId: "ms-vscode.cpptools", displayName: "C/C++", setting: { section: "C_Cpp", key: "intelliSenseEngine", off: "disabled" } }, + { extensionId: "llvm-vs-code-extensions.vscode-clangd", displayName: "clangd", setting: { section: "clangd", key: "enable", off: false } }, + { extensionId: "ccls-project.ccls", displayName: "ccls" }, +]; + +/** The settings whose change can make a candidate active or inactive. */ +export const OTHER_CPP_SETTINGS: readonly string[] = OTHER_CPP_CANDIDATES.flatMap((candidate) => + candidate.setting === undefined ? [] : [`${candidate.setting.section}.${candidate.setting.key}`], +); + +/** + * The local read. `installed` answers for installed *and enabled* extensions + * (`vscode.extensions.getExtension`); `value` is the effective setting. + */ +export function localOtherCppFeatures( + installed: (extensionId: string) => boolean, + value: (section: string, key: string) => unknown, +): OtherCppFeatures { + const active = OTHER_CPP_CANDIDATES.filter( + (candidate) => + installed(candidate.extensionId) && + (candidate.setting === undefined || value(candidate.setting.section, candidate.setting.key) !== candidate.setting.off), + ).map((candidate) => ({ + extensionId: candidate.extensionId, + displayName: candidate.displayName, + settable: candidate.setting !== undefined, + })); + return { active, canRestore: false, source: "local" }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function service(value: unknown): OtherCppService | undefined { + if (!isRecord(value) || typeof value.extensionId !== "string" || value.extensionId.length === 0) { + return undefined; + } + return { + extensionId: value.extensionId, + displayName: typeof value.displayName === "string" && value.displayName.length > 0 ? value.displayName : value.extensionId, + settable: value.settable !== false, + }; +} + +/** + * mcppls's answer, or `undefined` when the object has no such function, it + * throws, or what it returns has the wrong shape — the caller then reads locally. + */ +export function otherCppFeaturesFromExports(exportsValue: unknown): OtherCppFeatures | undefined { + if (!isRecord(exportsValue) || typeof exportsValue.otherCppFeatures !== "function") { + return undefined; + } + let answer: unknown; + try { + answer = (exportsValue.otherCppFeatures as () => unknown).call(exportsValue); + } catch { + return undefined; + } + if (!isRecord(answer) || !Array.isArray(answer.active)) { + return undefined; + } + const active = answer.active.map(service).filter((entry): entry is OtherCppService => entry !== undefined); + return { active, canRestore: answer.canRestore === true, source: "mcppls" }; +} diff --git a/src/mcppls/state.ts b/src/mcppls/state.ts new file mode 100644 index 0000000..a931e62 --- /dev/null +++ b/src/mcppls/state.ts @@ -0,0 +1,229 @@ +/** + * What the C++ Modules extension reports about itself, normalised. + * + * Pure: it takes the object mcppls's `activate()` returned and answers with a + * shape this extension can render, or with `available: false`. + * + * **This is a best-effort channel, not a contract.** mcppls's own name for that + * object is `TestApi`; the *contents* we read (`CxxModulesStatus`) are specified + * by S3, but whether the object is exposed at all is not promised. So every read + * is defensive: a missing function, a thrown error, an unknown `state` or a + * field of the wrong type all end in `available: false` and the view simply + * shows less. Nothing here may throw. + * + * The one thing worth calling out: an issue carries an optional `command` — S3's + * own fix for it. We surface that rather than guessing a remedy. + */ + +export interface McpplsIssue { + code: string; + message: string; + /** S3's own remedy, when it offers one. */ + command?: { command: string; arguments?: unknown[]; title?: string }; +} + +export interface McpplsEngineStatus { + name: string; + version: string; + role: string; + state: string; +} + +export interface McpplsStateView { + available: boolean; + /** Why it is unavailable, for the log; never shown as an error. */ + reason?: string; + version?: string; + active?: boolean; + enabled?: boolean; + state?: McpplsState; + project?: { root: string; source: string; level?: number; tier?: number }; + profile?: { kind: string; compiler?: string; stdlib: string; target: string; standard?: string }; + engine?: { name: string; version: string }; + engines?: McpplsEngineStatus[]; + progress?: { done: number; total: number }; + issues?: McpplsIssue[]; + notices?: McpplsIssue[]; + onlineRun?: { outcome: string; message: string; at: string }; +} + +export const MCPPLS_STATES = ["starting", "loading", "preparing", "ready", "degraded", "error"] as const; +export type McpplsState = (typeof MCPPLS_STATES)[number]; + +export interface McpplsMeta { + version?: string; + active?: boolean; + enabled?: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function knownState(value: unknown): McpplsState | undefined { + return typeof value === "string" && (MCPPLS_STATES as readonly string[]).includes(value) + ? (value as McpplsState) + : undefined; +} + +function issue(value: unknown): McpplsIssue | undefined { + if (!isRecord(value)) { + return undefined; + } + const code = text(value.code); + const message = text(value.message); + if (code === undefined || message === undefined) { + return undefined; + } + const rawCommand = value.command; + const command = + isRecord(rawCommand) && text(rawCommand.command) !== undefined + ? { + command: text(rawCommand.command) as string, + arguments: Array.isArray(rawCommand.arguments) ? rawCommand.arguments : undefined, + title: text(rawCommand.title), + } + : undefined; + return { code, message, command }; +} + +function issues(value: unknown): McpplsIssue[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const parsed = value.map(issue).filter((entry): entry is McpplsIssue => entry !== undefined); + return parsed.length === 0 ? undefined : parsed; +} + +/** + * Normalise the object mcppls's `activate()` returned. + * + * `lastStatus()` is read through `Function.prototype.call` on the object itself + * so a getter or a prototype method behaves the same way. + */ +export function readStateFromExports(exports: unknown, meta: McpplsMeta = {}): McpplsStateView { + const base: McpplsStateView = { + available: false, + version: meta.version, + active: meta.active, + enabled: meta.enabled, + }; + if (!isRecord(exports)) { + return { ...base, reason: "mcppls exposes no API object" }; + } + const reader = (exports as Record).lastStatus; + if (typeof reader !== "function") { + return { ...base, reason: "mcppls's API object has no lastStatus()" }; + } + let raw: unknown; + try { + raw = (reader as () => unknown).call(exports); + } catch (error) { + return { ...base, reason: `lastStatus() threw: ${error instanceof Error ? error.message : String(error)}` }; + } + if (!isRecord(raw)) { + // `undefined` is normal before the first status arrives; say so, do not complain. + return { ...base, reason: raw === undefined ? "no status yet" : "lastStatus() returned an unexpected shape" }; + } + const state = knownState(raw.state); + if (state === undefined) { + return { ...base, reason: `unknown state ${JSON.stringify(raw.state)}` }; + } + + const view: McpplsStateView = { ...base, available: true, state }; + + const project = raw.project; + if (isRecord(project)) { + const root = text(project.root); + const source = text(project.source); + if (root !== undefined && source !== undefined) { + view.project = { + root, + source, + level: finiteNumber(project.level), + tier: finiteNumber(project.tier), + }; + } + } + + const profile = raw.profile; + if (isRecord(profile)) { + const kind = text(profile.kind); + const stdlib = text(profile.stdlib); + const target = text(profile.target); + if (kind !== undefined && stdlib !== undefined && target !== undefined) { + view.profile = { kind, stdlib, target, compiler: text(profile.compiler), standard: text(profile.standard) }; + } + } + + const engine = raw.engine; + if (isRecord(engine)) { + const name = text(engine.name); + const version = text(engine.version); + if (name !== undefined && version !== undefined) { + view.engine = { name, version }; + } + } + + if (Array.isArray(raw.engines)) { + const engines = raw.engines + .map((entry): McpplsEngineStatus | undefined => { + if (!isRecord(entry)) return undefined; + const name = text(entry.name); + const version = text(entry.version); + const role = text(entry.role); + const state = text(entry.state); + return name !== undefined && version !== undefined && role !== undefined && state !== undefined + ? { name, version, role, state } + : undefined; + }) + .filter((entry): entry is McpplsEngineStatus => entry !== undefined); + if (engines.length > 0) { + view.engines = engines; + } + } + + const progress = raw.progress; + if (isRecord(progress)) { + const done = finiteNumber(progress.done); + const total = finiteNumber(progress.total); + if (done !== undefined && total !== undefined) { + view.progress = { done, total }; + } + } + + view.issues = issues(raw.issues); + view.notices = issues(raw.notices); + + const onlineRun = raw.onlineRun; + if (isRecord(onlineRun)) { + const outcome = text(onlineRun.outcome); + const message = text(onlineRun.message); + const at = text(onlineRun.at); + if (outcome !== undefined && message !== undefined && at !== undefined) { + view.onlineRun = { outcome, message, at }; + } + } + + return view; +} + +/** One line for the environment self-check; never throws. */ +export function describeState(view: McpplsStateView): string { + if (!view.available) { + return `unavailable (${view.reason ?? "unknown reason"})`; + } + const parts = [view.state ?? "unknown"]; + if (view.project !== undefined) parts.push(`project ${view.project.source}`); + if (view.engine !== undefined) parts.push(`engine ${view.engine.name} ${view.engine.version}`); + if (view.issues !== undefined) parts.push(`${view.issues.length} issue(s)`); + return parts.join(" · "); +} diff --git a/src/mcppls/stateSource.ts b/src/mcppls/stateSource.ts new file mode 100644 index 0000000..7dd74b6 --- /dev/null +++ b/src/mcppls/stateSource.ts @@ -0,0 +1,100 @@ +/** + * The `vscode`-facing half of the C++ Modules state read. + * + * `state.ts` normalises what it is given; this file fetches it. It is the only + * place that touches `extension.exports`, and it is deliberately defensive: the + * object is mcppls's test API, not a contract, so every failure becomes + * `available: false` with a reason and never an exception. + * + * `mcpp.languageService.readState` can switch the whole read off. When it is off + * the gate below returns before `extension.exports` is touched at all — not a + * partial read, no read — and the view falls back to the degraded content it + * always has for an installed-but-unreadable mcppls. + */ + +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { MCPPLS_EXTENSION_ID } from "./contract"; +import { localOtherCppFeatures, otherCppFeaturesFromExports, type OtherCppFeatures } from "./otherCppFeatures"; +import { readStateFromExports, type McpplsStateView } from "./state"; + +/** Why a state read did not happen. The view prints it verbatim. */ +export function stateUnavailableReason(reason: "disabled" | "missing"): string { + return reason === "disabled" + ? "reading the C++ Modules state is turned off (mcpp.languageService.readState)" + : `mcppls ${MCPPLS_EXTENSION_ID} is not installed`; +} + +function dependency(): vscode.Extension | undefined { + return vscode.extensions.getExtension(MCPPLS_EXTENSION_ID); +} + +export function languageServerInstalled(): boolean { + return dependency() !== undefined; +} + +export function languageServerVersion(): string | undefined { + const version = (dependency()?.packageJSON as { version?: string } | undefined)?.version; + return typeof version === "string" ? version : undefined; +} + +/** `mcppls.enable`, read-only: the setting belongs to the other extension. */ +export function languageServerEnabled(): boolean { + return vscode.workspace.getConfiguration("mcppls").get("enable", true) !== false; +} + +/** + * Whether C++ Modules serves this workspace's C++ files, `build.mcpp` included: + * installed and enabled in VS Code, and not switched off here (`mcppls.enable`). + * Read-only, like everything else in this file. + */ +export function languageServerServes(): boolean { + return languageServerInstalled() && languageServerEnabled(); +} + +export function readLanguageServerState(): McpplsStateView { + const extension = dependency(); + const meta = { + version: languageServerVersion(), + active: extension?.isActive, + enabled: languageServerEnabled(), + }; + // The gate: nothing below this line runs when the setting is off, so + // `extension.exports` is never read. `read()` falls back to the declared + // default (true) when the registry has no value for the key. + if (read("mcpp.languageService.readState") === false) { + return { available: false, reason: stateUnavailableReason("disabled"), ...meta }; + } + if (extension === undefined) { + return { available: false, reason: stateUnavailableReason("missing"), ...meta }; + } + try { + return readStateFromExports(extension.exports, meta); + } catch (error) { + return { + available: false, + reason: `reading mcppls state threw: ${error instanceof Error ? error.message : String(error)}`, + ...meta, + }; + } +} + +/** + * The other C++ language services serving next to C++ Modules: mcppls's own + * answer when it gives one (and reading its exports is allowed), else a local + * read-only look at the same three candidates. + */ +export function readOtherCppFeatures(): OtherCppFeatures { + const extension = dependency(); + if (extension !== undefined && extension.isActive && read("mcpp.languageService.readState") !== false) { + const answer = otherCppFeaturesFromExports(extension.exports); + if (answer !== undefined) { + return answer; + } + } + return localOtherCppFeatures( + (extensionId) => vscode.extensions.getExtension(extensionId) !== undefined, + (section, key) => vscode.workspace.getConfiguration(section).get(key), + ); +} diff --git a/src/mcppls/timers.ts b/src/mcppls/timers.ts new file mode 100644 index 0000000..f915951 --- /dev/null +++ b/src/mcppls/timers.ts @@ -0,0 +1,80 @@ +/** + * The one interval timer two views share. + * + * Both `mcpp.cache.autoRefreshSeconds` and + * `mcpp.languageService.stateRefreshSeconds` mean the same thing: re-read while + * the view is visible, stop when it is not, and never let a second read overlap + * one that is still running. Keeping the mechanism here — free of `vscode`, so + * `node:test` can drive it with a fake clock — means the two call sites own only + * the decision of *what* to refresh. + * + * The caller disposes the timer with the extension; `dispose()` is idempotent. + */ + +export interface PollTimerOptions { + periodMs: number; + /** The refresh itself. Runs between ticks; the timer never awaits it. */ + tick: () => void; + /** Injected for tests; defaults to `setInterval`/`clearInterval`. */ + setIntervalFn?: (handler: () => void, periodMs: number) => unknown; + clearIntervalFn?: (handle: unknown) => void; +} + +export class PollTimer { + private readonly setIntervalFn: (handler: () => void, periodMs: number) => unknown; + private readonly clearIntervalFn: (handle: unknown) => void; + private handle: unknown; + private periodMs: number; + + public constructor(private readonly options: PollTimerOptions) { + this.setIntervalFn = + options.setIntervalFn ?? ((handler, periodMs) => setInterval(handler, periodMs)); + this.clearIntervalFn = options.clearIntervalFn ?? ((handle) => clearInterval(handle as NodeJS.Timeout)); + this.periodMs = options.periodMs; + } + + /** True while a period is armed, so the caller can assert it did not leak. */ + public get active(): boolean { + return this.handle !== undefined; + } + + /** The period currently armed, in milliseconds. */ + public get period(): number { + return this.periodMs; + } + + /** + * Arm, re-arm or stop. A non-positive or unreadable period stops the timer, so + * "0 = off" holds no matter what the settings file contains. + */ + public start(periodMs: number): void { + this.periodMs = periodMs; + this.stop(); + if (!Number.isFinite(periodMs) || periodMs <= 0) { + return; + } + this.handle = this.setIntervalFn(() => { + try { + this.options.tick(); + } catch { + // A refresh that throws must not kill the timer or the host. + } + }, periodMs); + } + + public stop(): void { + if (this.handle === undefined) { + return; + } + try { + this.clearIntervalFn(this.handle); + } catch { + // Already cleared, or the host is shutting down. + } + this.handle = undefined; + } + + public dispose(): void { + this.stop(); + } +} diff --git a/src/moduleSetup.ts b/src/moduleSetup.ts deleted file mode 100644 index 14d962a..0000000 --- a/src/moduleSetup.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { hostDefaultToolchains, type ToolchainInventory } from "./cli"; - -export type ModuleSetupStage = "install" | "default" | "build" | "reload" | "clangd" | "check"; -export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled"; -export type ModuleSetupBlockedReason = - | "untrusted" - | "busy" - | "project-toolchain-override" - | "unrecognized-inventory"; - -export type ModuleSetupDecision = - | { - kind: "ready"; - installLlvm: boolean; - switchDefault: boolean; - } - | { - kind: "blocked"; - reason: ModuleSetupBlockedReason; - }; - -type ModuleSetupPlan = Extract; -type ModuleCapability = "full" | "syntax-only" | "unavailable"; - -export function buildModuleSetupPlan( - inventory: ToolchainInventory, - _capability: ModuleCapability, - trusted: boolean, - busy: boolean, -): ModuleSetupDecision { - if (!trusted) { - return { kind: "blocked", reason: "untrusted" }; - } - if (!inventory.recognized) { - return { kind: "blocked", reason: "unrecognized-inventory" }; - } - if (busy) { - return { kind: "blocked", reason: "busy" }; - } - - const effectiveIsLlvm = inventory.effective?.family.toLowerCase() === "llvm"; - if (inventory.projectOverridesGlobal && inventory.effective !== undefined && !effectiveIsLlvm) { - return { kind: "blocked", reason: "project-toolchain-override" }; - } - - // target-only payload 不能成为 host 默认工具链,因此必须复用现有筛选规则。 - const installedHostLlvm = hostDefaultToolchains(inventory) - .some((toolchain) => toolchain.family.toLowerCase() === "llvm"); - return { - kind: "ready", - installLlvm: !installedHostLlvm, - switchDefault: !effectiveIsLlvm, - }; -} - -export interface ModuleSetupCommand { - stage: "install" | "default" | "build"; - mode: "task" | "process"; - args: string[]; -} - -export function mcppModuleSetupCommands(plan: ModuleSetupPlan): ModuleSetupCommand[] { - const commands: ModuleSetupCommand[] = []; - if (plan.installLlvm) { - commands.push({ - stage: "install", - mode: "task", - args: ["toolchain", "install", "llvm"], - }); - } - if (plan.switchDefault) { - commands.push({ - stage: "default", - mode: "process", - args: ["toolchain", "default", "llvm"], - }); - } - commands.push({ stage: "build", mode: "task", args: ["build", "--no-cache"] }); - return commands; -} - -export interface ModuleSetupConfirmation { - message: string; - detail: string; -} - -export function moduleSetupConfirmation( - capability: ModuleCapability, - plan: ModuleSetupPlan, -): ModuleSetupConfirmation { - const message = capability === "full" - ? "当前已具备 LLVM 模块能力,是否重新构建并配置模块代码提示?" - : capability === "syntax-only" - ? "当前仅提供模块语法高亮,是否切换到 LLVM 并配置模块代码提示?" - : "当前缺少可用的模块编译数据库,是否配置 LLVM 并构建项目?"; - const installDetail = plan.installLlvm - ? "流程可能下载 LLVM/llvm-tools" - : "将复用已安装的 LLVM;补齐配套组件时仍可能下载 LLVM/llvm-tools"; - const defaultDetail = plan.switchDefault - ? "并把 LLVM 设为全局默认工具链" - : "不会修改全局默认工具链"; - return { - message, - detail: `${installDetail},${defaultDetail};不会编辑项目清单 mcpp.toml。`, - }; -} - -export interface ModuleSetupStepResult { - stage: ModuleSetupStage; - state: ModuleSetupStepState; - detail?: string; - exitCode?: number; -} - -export interface ModuleSetupOperations { - prepare?: (command: ModuleSetupCommand) => Promise; - preparePlan?: () => Promise; - reload(): Promise; - ensureClangd(): Promise; - checkModules(): Promise; -} - -export type ModuleSetupOutcome = - | { - state: "succeeded"; - degraded: boolean; - steps: ModuleSetupStepResult[]; - } - | { - state: "failed" | "cancelled"; - stage: ModuleSetupStage; - degraded: false; - steps: ModuleSetupStepResult[]; - }; - -function stoppedOutcome( - result: ModuleSetupStepResult, - steps: ModuleSetupStepResult[], -): Exclude { - return { - state: result.state === "cancelled" ? "cancelled" : "failed", - stage: result.stage, - degraded: false, - steps, - }; -} - -export async function executeModuleSetup( - plan: ModuleSetupPlan, - operations: ModuleSetupOperations, -): Promise { - const steps: ModuleSetupStepResult[] = []; - let buildFailed = false; - - const commands = mcppModuleSetupCommands(plan); - if (operations.preparePlan !== undefined) { - const result = await operations.preparePlan(); - steps.push(result); - if (result.state !== "succeeded") { - if (result.stage === "build" && result.state === "failed") { - buildFailed = true; - } else { - return stoppedOutcome(result, steps); - } - } - } else { - if (operations.prepare === undefined) { - const firstCommand = commands[0]; - if (firstCommand === undefined) { - return stoppedOutcome({ stage: "build", state: "failed" }, steps); - } - return stoppedOutcome({ stage: firstCommand.stage, state: "failed" }, steps); - } - for (const command of commands) { - const result = await operations.prepare(command); - steps.push(result); - if (result.state === "succeeded") { - continue; - } - if (command.stage === "build" && result.state === "failed") { - buildFailed = true; - break; - } - return stoppedOutcome(result, steps); - } - } - - // 构建失败后仍尝试恢复现有 CDB;只有完整恢复链成功才算降级成功。 - const recoveryOperations: Array<() => Promise> = [ - () => operations.reload(), - () => operations.ensureClangd(), - () => operations.checkModules(), - ]; - for (const operation of recoveryOperations) { - const result = await operation(); - steps.push(result); - if (result.state !== "succeeded") { - return stoppedOutcome(result, steps); - } - } - - return { - state: "succeeded", - degraded: buildFailed, - steps, - }; -} diff --git a/src/process.ts b/src/process.ts deleted file mode 100644 index c772b7a..0000000 --- a/src/process.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -import { - classifyCheckResult, - parseToolIdentity, - type CheckResult, - type ToolIdentity, -} from "./analysis"; - -const execFileAsync = promisify(execFile); - -export interface ProcessResult { - exitCode: number; - stdout: string; - stderr: string; -} - -export interface ProcessRunOptions { - timeoutMs?: number; -} - -export type ProcessRunner = ( - executable: string, - args: string[], - cwd?: string, - options?: ProcessRunOptions, -) => Promise; - -export interface ToolVersionResult extends ProcessResult { - identity: ToolIdentity | undefined; -} - -export interface ClangdCheckResult extends ProcessResult { - output: string; - classification: CheckResult; -} - -export async function runProcess( - executable: string, - args: string[], - cwd?: string, - options: ProcessRunOptions = {}, -): Promise { - try { - const result = await execFileAsync(executable, args, { - cwd, - encoding: "utf8", - maxBuffer: 16 * 1024 * 1024, - timeout: options.timeoutMs, - }); - return { - exitCode: 0, - stdout: result.stdout, - stderr: result.stderr, - }; - } catch (error) { - const processError = error as NodeJS.ErrnoException & { - stdout?: string; - stderr?: string; - code?: number | string; - }; - return { - exitCode: typeof processError.code === "number" ? processError.code : 1, - stdout: processError.stdout ?? "", - stderr: processError.stderr ?? (typeof processError.message === "string" ? processError.message : ""), - }; - } -} - -export async function runToolVersion( - executable: string, - versionArguments: string[] = ["--version"], - runner: ProcessRunner = runProcess, -): Promise { - const result = await runner(executable, versionArguments); - return { - ...result, - identity: parseToolIdentity(`${result.stdout}\n${result.stderr}`), - }; -} - -export async function runClangdCheck( - clangdPath: string, - sourceFile: string, - cwd: string, - clangdArguments: string[] = [], - runner: ProcessRunner = runProcess, -): Promise { - const result = await runner( - clangdPath, - [`--check=${sourceFile}`, ...clangdArguments], - cwd, - { timeoutMs: 60_000 }, - ); - const output = `${result.stdout}\n${result.stderr}`; - return { - ...result, - output, - classification: classifyCheckResult(result.exitCode, output), - }; -} - -export function runMcppBuild( - cwd: string, - executable: string = "mcpp", - runner: ProcessRunner = runProcess, -): Promise { - return runner(executable, ["build"], cwd); -} diff --git a/src/inProject.ts b/src/projects/context.ts similarity index 50% rename from src/inProject.ts rename to src/projects/context.ts index 7b901e1..7b9f590 100644 --- a/src/inProject.ts +++ b/src/projects/context.ts @@ -1,6 +1,13 @@ export const IN_PROJECT_CONTEXT_KEY = "mcpp.inProject"; export const MCPP_MANIFEST_GLOB = "**/mcpp.toml"; +/** + * `mcpp.task.editorTitleButtons`: the second half of the editor/title `when` + * clause. `mcpp.inProject` says "this file belongs to an mcpp project"; this key + * lets the user turn the Run/Test buttons off without hiding them for everyone. + */ +export const EDITOR_TITLE_BUTTONS_CONTEXT_KEY = "mcpp.editorTitleButtons"; + export interface DisposableLike { dispose(): unknown; } @@ -17,6 +24,22 @@ export async function updateInProjectContext(env: InProjectEnvironment): Promise return inProject; } +export interface EditorTitleButtonsEnvironment { + enabled(): boolean; + setContextValue(key: string, value: boolean): PromiseLike; +} + +/** + * Apply `mcpp.task.editorTitleButtons` to the context key `package.json` gates + * the editor/title buttons with. The caller re-runs it on a configuration + * change; like every other context write it is fire-and-forget. + */ +export async function updateEditorTitleButtonsContext(env: EditorTitleButtonsEnvironment): Promise { + const enabled = env.enabled(); + await env.setContextValue(EDITOR_TITLE_BUTTONS_CONTEXT_KEY, enabled); + return enabled; +} + export function registerInProjectContext(env: InProjectEnvironment): { dispose(): unknown } { void updateInProjectContext(env); const disposables = env.subscribe(() => void updateInProjectContext(env)); diff --git a/src/projects/dependencyGraph.ts b/src/projects/dependencyGraph.ts new file mode 100644 index 0000000..7a2a39d --- /dev/null +++ b/src/projects/dependencyGraph.ts @@ -0,0 +1,73 @@ +import path from "node:path"; +import { readProjectDeclarations, type ProjectDeclaration } from "../library/projectState"; +import { readProjectSummary } from "./summary"; + +export interface GraphNode { key: string; name: string; version?: string; tags?: readonly string[]; dependencies?: number; depth: number; source: "project" | "manifest" | "index" | "unknown"; } +export interface GraphEdge { from: string; to: string; kind: string; conditional: boolean; constraint?: string; name?: string; declaration?: string; } +export interface DependencyGraph { nodes: GraphNode[]; edges: GraphEdge[]; limited: boolean; } +export interface GraphReader { + manifest(root: string): Promise; + descriptor(id: string): { id: string; dependencies: ProjectDeclaration[]; resolved?: string; tags?: string[] } | undefined; +} + +/** Project is depth zero; traverse at most three dependency hops, preserving shared/cyclic edges. */ +export async function buildDependencyGraph(root: string, name: string, reader: GraphReader, maxDepth = 3): Promise { + const graph: DependencyGraph = { nodes: [{ key: root, name, depth: 0, source: "project" }], edges: [], limited: false }; + const queue: Array<{ key: string; root?: string; declarations?: ProjectDeclaration[]; depth: number }> = [{ key: root, root, depth: 0 }]; + const seen = new Set([root]); + const edges = new Set(); + while (queue.length) { + const current = queue.shift()!; + if (current.depth >= maxDepth) { continue; } + const text = current.root ? await reader.manifest(current.root) : undefined; + const declarations = current.declarations ?? (text === undefined ? [] : readProjectDeclarations(text)); + const currentNode = graph.nodes.find((node) => node.key === current.key)!; + currentNode.dependencies = declarations.length; + if (text !== undefined) { + const summary = readProjectSummary(current.root!, text.split(/\r?\n/)); + currentNode.version ??= summary.version; + currentNode.tags = [currentNode.source === "project" ? "project" : "path", ...(summary.standard ? [summary.standard] : [])]; + } + for (const dep of declarations) { + const localRoot = dep.path && current.root ? path.resolve(current.root, dep.path) : undefined; + const descriptor = dep.source ? undefined : reader.descriptor(dep.id); + const key = localRoot ?? descriptor?.id ?? `unknown:${dep.id}`; + if (!seen.has(key)) { + if (seen.size >= 120) { graph.limited = true; continue; } + seen.add(key); + const localText = localRoot ? await reader.manifest(localRoot) : undefined; + const local = localText === undefined ? undefined : readProjectSummary(localRoot!, localText.split(/\r?\n/)); + graph.nodes.push({ key, name: descriptor?.id ?? local?.name ?? dep.id, version: descriptor?.resolved ?? dep.version ?? local?.version, depth: current.depth + 1, + tags: descriptor?.tags ?? (local ? ["path", ...(local.standard ? [local.standard] : [])] : ["unavailable"]), + dependencies: descriptor?.dependencies.length ?? (localText === undefined ? undefined : readProjectDeclarations(localText).length), + source: localRoot && localText !== undefined ? "manifest" : descriptor ? "index" : "unknown" }); + if (descriptor || localText !== undefined) { queue.push({ key, root: localRoot, declarations: descriptor?.dependencies, depth: current.depth + 1 }); } + } + const kind = dep.kind ?? (dep.dev ? "dev" : "normal"); + const edgeKey = JSON.stringify([current.key, key, kind, !!dep.conditional, dep.version]); + if (!edges.has(edgeKey)) { + if (edges.size >= 240) { graph.limited = true; continue; } + edges.add(edgeKey); + graph.edges.push({ from: current.key, to: key, kind, conditional: !!dep.conditional, constraint: dep.version, name: descriptor?.id ?? dep.id, declaration: dep.id }); + } + } + } + return graph; +} + +/** IDs are generated; every label is quoted and escaped, so manifest text cannot inject Mermaid syntax. */ +export function graphMermaid(graph: DependencyGraph): string { + const ids = new Map(graph.nodes.map((node, index) => [node.key, `n${index}`])); + const label = (value: string) => value.replace(/[\r\n]/g, " ").replace(/[&"<>[\]{}|`\\#]/g, (char) => `#${char.charCodeAt(0)};`); + const nodes = new Map(graph.nodes.map((node) => [node.key, node])); + return ["flowchart LR", ...graph.nodes.map((node) => { + const text = [node.name, ...(node.tags?.length ? [node.tags.slice(0, 2).join(" · ")] : []), `deps ${node.dependencies ?? "?"}`]; + return ` ${ids.get(node.key)}["${text.map(label).join("
    ")}"]:::${node.source}`; + }), ...graph.edges.map((edge) => { + const target = nodes.get(edge.to)!; + const version = edge.constraint ?? target.version; + const coordinate = (edge.name ?? target.name) + (version ? `@${version}` : ""); + const text = coordinate + (edge.kind === "normal" ? "" : ` · ${edge.kind}`) + (edge.conditional ? " · conditional" : ""); + return ` ${ids.get(edge.from)} ${edge.conditional ? "-.-" : "--"}>|"${label(text)}"| ${ids.get(edge.to)}`; + }), " classDef unknown stroke-dasharray: 4 3"].join("\n"); +} diff --git a/src/projects/dependencyGraphPanel.ts b/src/projects/dependencyGraphPanel.ts new file mode 100644 index 0000000..1b0c54e --- /dev/null +++ b/src/projects/dependencyGraphPanel.ts @@ -0,0 +1,173 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import * as vscode from "vscode"; +import { TOOL_COMMANDS } from "../commands/ids"; +import { t } from "../i18n/t"; +import { queryModuleGraph } from "../cli/moduleGraph"; +import { cachedDescriptorText, loadSnapshot } from "../library/indexLocator"; +import { descriptorDependencies, lockPackageVersions } from "../library/indexModel"; +import type { ProjectSummary } from "../views/models"; +import { WebviewDocument } from "../webview/document"; +import { ReadOnlyDocuments } from "../webview/readOnlyDocuments"; +import { graphPanelHtml } from "../graphs/panelHtml"; +import { mermaidGraph, visibleGraph, type VisualGraph } from "../graphs/model"; +import { readProjectSummary } from "./summary"; +import { buildDependencyGraph } from "./dependencyGraph"; +import { buildModuleGraph, moduleEntries, moduleOwnership, sourcePath } from "./moduleGraph"; + +interface ViewState { + root: string; mode: "packages" | "modules"; name: string; graph?: VisualGraph; + dependencyTargets?: Map; dependency?: string; dependencyKind?: string; + depth: number; expanded: Set; collapsed: Set; selected?: string; focus?: string; +} +const SOURCE_SCHEME = "mcpp-graph-source"; + +/** Two providers, one persistent graph document. Expansion never re-runs the CLI. */ +export function registerDependencyGraph(context: vscode.ExtensionContext, project: () => ProjectSummary | undefined): void { + let panel: vscode.WebviewPanel | undefined, current: ViewState | undefined, source = "", generation = 0, ready = false, loading = false; + const views = new Map(); + const document = new WebviewDocument("dependencyGraph.css"), media = vscode.Uri.joinPath(context.extensionUri, "media"), vendor = vscode.Uri.joinPath(context.extensionUri, "dist", "vendor"); + const sourceChanged = new vscode.EventEmitter(); + const sources = new ReadOnlyDocuments(SOURCE_SCHEME, uri => sourceChanged.fire(vscode.Uri.parse(uri))); + context.subscriptions.push(sourceChanged, vscode.workspace.registerTextDocumentContentProvider(SOURCE_SCHEME, { onDidChange: sourceChanged.event, provideTextDocumentContent: uri => sources.read(uri.toString()) ?? "" })); + const viewKey = (view: ViewState): string => `${view.mode}:${view.root}`; + const exists = async (file: string): Promise => fs.access(file).then(() => true, () => false); + const read = (file: string): Promise => fs.readFile(file, "utf8").catch(() => undefined); + + const publish = async (center = false, fit = false): Promise => { + if (!ready || !panel || !current?.graph) { return; } + const graph = visibleGraph(current.graph, current), rendered = mermaidGraph(graph); + source = rendered.source; + await panel.webview.postMessage({ type: "graph", viewKey: viewKey(current), mode: current.mode, name: current.name, depth: current.depth === Infinity ? "all" : String(current.depth), + source, nodes: graph.nodes.map(node => ({ id: node.id, label: node.label, hidden: graph.hidden.get(node.id) ?? 0 })), + search: current.graph.nodes.map(node => ({ id: node.id, label: node.label })), + selected: current.selected, focus: current.focus, center, fit, limited: graph.limited, truncated: graph.truncated }); + }; + const load = async (refresh = false): Promise => { + if (!current || !panel || !ready) { return; } + const view = current, target = panel, request = ++generation; + if (view.graph && !refresh) { loading = false; await publish(); return; } + loading = true; + await target.webview.postMessage({ type: "loading", viewKey: viewKey(view), mode: view.mode, name: view.name }); + try { + const manifest = await read(path.join(view.root, "mcpp.toml")); + if (manifest === undefined) { throw new Error(t("The project manifest is missing or unreadable.")); } + view.name = readProjectSummary(view.root, manifest.split(/\r?\n/)).name ?? path.basename(view.root); + let graph: VisualGraph; + if (view.mode === "modules") { + const extension = vscode.extensions.getExtension("sunrisepeak.mcpp-language-server"); + const bundled = extension ? path.join(extension.extensionPath, "payload", "bin", process.platform === "win32" ? "mcppls.exe" : "mcppls") : undefined; + const executable = bundled && await exists(bundled) ? bundled : "mcppls"; + const raw = await queryModuleGraph(vscode.workspace.isTrusted, executable, view.root); + const entries = moduleEntries(view.root, manifest, view.name); + const files = new Set(raw.modules.flatMap(node => node.files.map(file => sourcePath(view.root, file)))); + const names = new Set(raw.modules.map(node => node.name)); + const importers = new Set(raw.imports.filter(edge => !names.has(edge.from)).map(edge => sourcePath(view.root, edge.from))); + // A truncated response can still contain edges from omitted module names. + // Only an existing source file may become an ordinary importer node. + for (const file of importers) { if (await exists(file)) { files.add(file); } } + for (const file of entries.keys()) { if (await exists(file)) { files.add(file); } } + graph = buildModuleGraph(view.root, raw, await moduleOwnership(view.root, [...files], exists), entries); + } else { + const [snapshot, lock] = await Promise.all([loadSnapshot({ projectRoot: view.root }).catch(() => undefined), read(path.join(view.root, "mcpp.lock"))]); + const locked = lockPackageVersions(lock ?? ""), entries = snapshot?.entries ?? []; + const packages = await buildDependencyGraph(view.root, view.name, { + manifest: directory => directory === view.root ? Promise.resolve(manifest) : read(path.join(directory, "mcpp.toml")), + descriptor: id => { + const matches = entries.filter(entry => entry.id === id || (!id.includes(".") && entry.name === id)); + if (matches.length !== 1) { return undefined; } + const entry = matches[0], text = cachedDescriptorText(entry.file); + if (text === undefined) { return undefined; } + return { id: entry.id, tags: [entry.surface === "header" ? "#include" : entry.surface === "module" ? "import" : entry.surface ?? "index", ...entry.licenses.slice(0, 1)], + resolved: locked.find(pkg => pkg.id === entry.id)?.version, dependencies: descriptorDependencies(text).map(dep => ({ ...dep, dev: false, kind: "normal" as const })) }; + }, + }, Infinity); + view.dependencyTargets = new Map(packages.edges.filter(edge => edge.from === view.root).map(edge => [`${edge.declaration}:${edge.kind}`, edge.to])); + const nodes = new Map(packages.nodes.map(node => [node.key, node])); + graph = { roots: [view.root], truncated: packages.limited, + nodes: packages.nodes.map(node => ({ id: node.key, label: node.name, kind: node.source, lines: [...(node.tags?.length ? [node.tags.slice(0, 2).join(" · ")] : []), `deps ${node.dependencies ?? "?"}`] })), + edges: packages.edges.map(edge => { + const node = nodes.get(edge.to)!, version = edge.constraint ?? node.version; + return { from: edge.from, to: edge.to, dashed: edge.conditional, label: (edge.name ?? node.name) + (version ? `@${version}` : "") + (edge.kind === "normal" ? "" : ` · ${edge.kind}`) + (edge.conditional ? " · conditional" : "") }; + }) }; + } + if (panel !== target || current !== view || request !== generation) { return; } + view.graph = graph; + if (view.dependency) { + view.selected = view.dependencyTargets?.get(`${view.dependency}:${view.dependencyKind ?? "normal"}`) ?? view.selected; + view.focus = view.selected; view.dependency = undefined; + } + loading = false; await publish(!!view.selected); + } catch (error) { + if (panel !== target || current !== view || request !== generation) { return; } + loading = false; + await target.webview.postMessage({ type: "error", error: t("Could not read project dependencies: {0}", String(error)) }); + } + }; + const chooseView = (root: string, mode: ViewState["mode"]): ViewState => { + const key = `${mode}:${root}`; + let view = views.get(key); + if (!view) { view = { root, mode, name: path.basename(root), depth: 3, expanded: new Set(), collapsed: new Set() }; views.set(key, view); } + return view; + }; + const openSource = async (view: ViewState, id: string): Promise => { + const node = view.graph?.nodes.find(node => node.id === id); + if (!node) { return; } + view.selected = id; + if (view.mode !== "modules") { return; } + const files = node.files ?? []; + if (!files.length) { void vscode.window.showInformationMessage(t("Source is unavailable for this module.")); return; } + const file = files.length === 1 ? files[0] : (await vscode.window.showQuickPick(files.map(file => ({ label: path.basename(file), description: path.relative(view.root, file), file })), { placeHolder: t("Choose a module source file") }))?.file; + if (!file || current !== view) { return; } + const text = await read(file); + if (text === undefined) { void vscode.window.showInformationMessage(t("Source is unavailable for this module.")); return; } + const uri = vscode.Uri.parse(sources.capture({ path: file, text })); + await vscode.window.showTextDocument(uri, { viewColumn: vscode.ViewColumn.Beside, preview: true, preserveFocus: true }); + }; + const receive = async (message: unknown): Promise => { + if (!message || typeof message !== "object") { return; } + const value = message as Record; + if (value.type === "ready") { ready = true; await load(); return; } + if (value.type !== "action" || !current || value.viewKey !== viewKey(current)) { return; } + if (value.action === "mode" && (value.mode === "packages" || value.mode === "modules")) { current = chooseView(current.root, value.mode); await load(); return; } + if (value.action === "refresh") { await load(true); return; } + if (value.action === "manifest") { await vscode.window.showTextDocument(vscode.Uri.file(path.join(current.root, "mcpp.toml"))); return; } + if (loading || !current.graph) { return; } + if (value.action === "copy") { await vscode.env.clipboard.writeText(source); await panel?.webview.postMessage({ type: "copied" }); return; } + if (value.action === "depth" && ["3", "5", "all"].includes(String(value.depth))) { + current.depth = value.depth === "all" ? Infinity : Number(value.depth); current.expanded.clear(); current.collapsed.clear(); await publish(); return; + } + if (value.action === "overview") { current.focus = undefined; await publish(false, true); return; } + const id = typeof value.id === "string" ? value.id : undefined; + if (!id || !current.graph.nodes.some(node => node.id === id)) { return; } + current.selected = id; + if (value.action === "open") { await openSource(current, id); return; } + if (value.action === "expand") { current.expanded.add(id); current.collapsed.delete(id); } + else if (value.action === "collapse") { current.collapsed.add(id); current.expanded.delete(id); } + else if (value.action === "focus" || value.action === "search") { current.focus = id; current.collapsed.delete(id); } + else { return; } + await publish(value.action === "search", value.action === "focus"); + }; + const open = async (mode: ViewState["mode"], argument?: unknown): Promise => { + const requested = argument && typeof argument === "object" ? argument as { root?: unknown; dependency?: unknown; path?: unknown; kind?: unknown } : undefined; + const root = typeof requested?.root === "string" ? requested.root : project()?.root; + if (!root) { void vscode.window.showInformationMessage(t("Open an mcpp project first.")); return; } + current = chooseView(root, mode); + if (typeof requested?.dependency === "string") { + current.dependency = requested.dependency; + current.dependencyKind = typeof requested.kind === "string" ? requested.kind : "normal"; + current.selected = current.dependencyTargets?.get(`${requested.dependency}:${current.dependencyKind}`) ?? (typeof requested.path === "string" ? path.resolve(root, requested.path) : current.graph?.nodes.find(node => node.id === requested.dependency || node.label === requested.dependency)?.id ?? requested.dependency); + current.focus = current.selected; + } + if (!panel) { + panel = vscode.window.createWebviewPanel("mcpp.dependencyGraph", t("Project dependency graph"), vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [media, vendor] }); + ready = false; + panel.webview.onDidReceiveMessage(message => { void receive(message).catch(error => vscode.window.showErrorMessage(String(error))); }); + panel.onDidDispose(() => { panel = undefined; ready = false; generation++; document.invalidate(); }); + const assets = document.assets(media, panel.webview); + document.paint(panel.webview, graphPanelHtml(assets, panel.webview.asWebviewUri(vscode.Uri.joinPath(vendor, "mermaid.js")).toString(), panel.webview.asWebviewUri(vscode.Uri.joinPath(media, "graph.js")).toString())); + } else { panel.reveal(); await load(); if (current?.selected) { await publish(true); } } + }; + context.subscriptions.push(vscode.commands.registerCommand(TOOL_COMMANDS.dependencyGraph, argument => open("packages", argument)), + vscode.commands.registerCommand(TOOL_COMMANDS.moduleDependencyGraph, argument => open("modules", argument)), { dispose: () => panel?.dispose() }); +} diff --git a/src/projects/discovery.ts b/src/projects/discovery.ts new file mode 100644 index 0000000..2f680f4 --- /dev/null +++ b/src/projects/discovery.ts @@ -0,0 +1,69 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +export interface McppProjectDiscovery { + root: string; + manifestPath: string; +} + +/** + * How far the upward walk may go. `workspaceFolder` is the shipped behaviour; + * `filesystem` also reaches a project outside the opened folder + * (`mcpp.project.discoveryBoundary`). The walk itself never reads a setting — + * the `vscode` layer resolves the boundary and passes it in, which keeps this + * module pure and testable. + */ +export type DiscoveryBoundary = "workspaceFolder" | "filesystem"; + +/** `mcpp.project.discoveryBoundary`; anything unknown is the safe default. */ +export function discoveryBoundaryOf(value: unknown): DiscoveryBoundary { + return value === "filesystem" ? "filesystem" : "workspaceFolder"; +} + +function isPathWithin(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || ( + relative !== ".." + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative) + ); +} + +export function findNearestMcppProject( + startPath: string, + workspaceRoot?: string, + boundary: DiscoveryBoundary = "workspaceFolder", +): McppProjectDiscovery | undefined { + let current = path.resolve(startPath); + // `filesystem` drops the boundary entirely and keeps walking to the root. + const stop = boundary === "filesystem" || workspaceRoot === undefined + ? undefined + : path.resolve(workspaceRoot); + + try { + if (statSync(current).isFile()) { + current = path.dirname(current); + } + } catch { + // A newly-created workspace path may not exist yet; treat it as a directory. + } + + if (stop !== undefined && !isPathWithin(current, stop)) { + return undefined; + } + + while (true) { + const manifestPath = path.join(current, "mcpp.toml"); + if (existsSync(manifestPath)) { + return { root: current, manifestPath }; + } + if (stop !== undefined && current === stop) { + return undefined; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} diff --git a/src/projects/moduleGraph.ts b/src/projects/moduleGraph.ts new file mode 100644 index 0000000..350b5dd --- /dev/null +++ b/src/projects/moduleGraph.ts @@ -0,0 +1,105 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseMcppToml } from "../toml/parser"; +import { graphRoots, type VisualGraph, type VisualNode } from "../graphs/model"; + +export interface ModuleGraphJson { + modules: Array<{ name: string; files: string[]; external: boolean }>; + imports: Array<{ from: string; to: string }>; + truncated?: boolean; + snapshot?: { indexing?: boolean; preparing?: boolean }; +} +/** Reject incompatible output instead of presenting an empty, apparently successful graph. */ +export function parseModuleGraph(text: string): ModuleGraphJson { + const value = JSON.parse(text) as ModuleGraphJson; + if (!value || !Array.isArray(value.modules) || !Array.isArray(value.imports) + || value.modules.some(node => typeof node.name !== "string" || !node.name || !Array.isArray(node.files) || node.files.some(file => typeof file !== "string") || typeof node.external !== "boolean") + || value.imports.some(edge => typeof edge.from !== "string" || typeof edge.to !== "string")) { + throw new Error("Unsupported mcppls module graph format"); + } + return value; +} +export function sourcePath(root: string, file: string): string { + return file.startsWith("file:") ? fileURLToPath(file) : path.resolve(root, file); +} +/** Read entry configuration only; C++ syntax and module resolution remain owned by mcppls. */ +export function moduleEntries(root: string, manifest: string, packageName: string): Map { + const entries = new Map(); + let hasTargets = false; + let section: string[] = [], target: string | undefined, kind: string | undefined, main: string | undefined; + const flush = (): void => { + if (target && (!kind || kind === "bin")) { entries.set(path.resolve(root, main ?? "src/main.cpp"), target); } + target = kind = main = undefined; + }; + for (const node of parseMcppToml(manifest.split(/\r?\n/)).nodes) { + if (node.type === "section") { + flush(); section = node.segments.map(segment => segment.name); + if (section.length === 2 && section[0] === "targets") { target = section[1]; hasTargets = true; } + } else if (node.value?.kind === "string") { + const key = node.keyPath.map(part => part.name).join("."); + if (section.length === 1 && section[0] === "lib" && key === "path") { entries.set(path.resolve(root, node.value.text!), "lib"); } + if (target && key === "kind") { kind = node.value.text; } + if (target && key === "main") { main = node.value.text; } + } + } + flush(); + if (![...entries.values()].includes("lib")) { entries.set(path.resolve(root, `src/${packageName.split(".").at(-1)}.cppm`), "lib · default"); } + if (!hasTargets) { entries.set(path.resolve(root, "src/main.cpp"), "default"); } + return entries; +} + +/** Map already-resolved source paths to project ownership, including nested path packages. */ +export async function moduleOwnership(root: string, files: readonly string[], exists: (file: string) => Promise): Promise> { + const owned = new Set(), cache = new Map>(); + const inside = (file: string): boolean => { const relative = path.relative(root, file); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }; + const directoryOwned = (directory: string): Promise => { + if (directory === root) { return Promise.resolve(true); } + if (!inside(directory)) { return Promise.resolve(false); } + let result = cache.get(directory); + if (!result) { + result = exists(path.join(directory, "mcpp.toml")).then(nested => nested ? false : directoryOwned(path.dirname(directory))); + cache.set(directory, result); + } + return result; + }; + for (const file of files) { if (await directoryOwned(path.dirname(file))) { owned.add(file); } } + return owned; +} + +/** Keep all internal relationships and just one outgoing hop into dependency modules. */ +export function buildModuleGraph(root: string, raw: ModuleGraphJson, owned: ReadonlySet, entries: ReadonlyMap): VisualGraph { + const nodes = new Map(), internal = new Set(); + for (const module of raw.modules) { + const files = module.files.map(file => sourcePath(root, file)); + const local = !module.external && files.some(file => owned.has(file)); + const entry = files.map(file => entries.get(file)).find(Boolean); + const declared = local ? (module.name.includes(":") ? "partition" : "export module") : "external"; + nodes.set(module.name, { id: module.name, label: module.name, kind: local ? (module.name.includes(":") ? "manifest" : "project") : "index", + lines: [entry ? `entry · ${entry} · ${declared}` : declared, ...(files[0] ? [local ? path.relative(root, files[0]) : path.basename(files[0])] : [])], files, entry: !!entry }); + if (local) { internal.add(module.name); } + } + // Ordinary source files (including executable entry points) are first-class importers. + for (const edge of raw.imports) { + if (nodes.has(edge.from)) { continue; } + const file = sourcePath(root, edge.from); + if (!owned.has(file)) { continue; } + const entry = entries.get(file); + nodes.set(edge.from, { id: edge.from, label: path.relative(root, file), lines: [entry ? `entry · ${entry}` : "source"], kind: "project", files: [file], entry: !!entry }); + internal.add(edge.from); + } + for (const [file, entry] of entries) { + if (!owned.has(file) || [...nodes.values()].some(node => node.files?.includes(file))) { continue; } + const id = path.relative(root, file); + nodes.set(id, { id, label: id, lines: [`entry · ${entry}`], kind: "project", files: [file], entry: true }); + internal.add(id); + } + const edges = raw.imports.filter(edge => internal.has(edge.from)).map(edge => ({ from: edge.from, to: edge.to, label: "import" })); + for (const edge of edges) { if (!nodes.has(edge.to)) { nodes.set(edge.to, { id: edge.to, label: edge.to, lines: ["unavailable"], kind: "unknown" }); } } + const needed = new Set([...internal, ...edges.map(edge => edge.to)]); + const retained = [...nodes.values()].filter(node => needed.has(node.id)); + // Entries without imports still deserve a visible node when mcppls supplied their file. + const counts = new Map>(); + for (const edge of edges) { const targets = counts.get(edge.from) ?? new Set(); targets.add(edge.to); counts.set(edge.from, targets); } + for (const node of retained) { node.lines.push(`imports ${counts.get(node.id)?.size ?? 0}`); } + return { nodes: retained, edges, roots: graphRoots(retained, edges, retained.filter(node => node.entry).map(node => node.id)), truncated: !!raw.truncated || !!raw.snapshot?.indexing || !!raw.snapshot?.preparing }; +} diff --git a/src/projects/summary.ts b/src/projects/summary.ts new file mode 100644 index 0000000..3ecc978 --- /dev/null +++ b/src/projects/summary.ts @@ -0,0 +1,111 @@ +/** + * A project summary read out of `mcpp.toml`, for the project view. + * + * Deliberately shallow and line-oriented: the view shows an identity line and + * the toolchain/targets, so it needs a handful of scalars, not a manifest model. + * Anything it cannot read stays `undefined` and the view simply omits it — the + * authoritative reader is mcpp itself, and `mcpp: Environment Self-check` shows + * what mcpp says. + * + * Pure: no `vscode`, no `mcpp` execution. + */ + +import type { ProjectSummary, TargetSummary } from "../views/models"; + +/** `[section]` or `[section.sub]`, ignoring quotes inside dotted names. */ +function headerOf(line: string): string | undefined { + const match = /^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/.exec(line); + if (match === null) { + return undefined; + } + const name = match[1].replace(/^['"]|['"]$/g, "").trim(); + return name.length === 0 ? undefined : name; +} + +/** `key = "value"` where the value is a plain string or a bare word. */ +function scalar(body: string, key: string): string | undefined { + const pattern = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*=\\s*(.+?)\\s*(?:#.*)?$`); + const match = pattern.exec(body); + if (match === null) { + return undefined; + } + return unquote(match[1]); +} + +function unquote(raw: string): string | undefined { + const trimmed = raw.trim(); + const quoted = /^"(.*)"$/.exec(trimmed) ?? /^'(.*)'$/.exec(trimmed); + const value = quoted === null ? trimmed : quoted[1]; + return value.length === 0 ? undefined : value; +} + +/** Section names whose scalars we read, matched by prefix. */ +const IDENTITY = "package"; +const TOOLCHAIN = "toolchain"; +const TARGETS = "targets."; + +export function readProjectSummary(root: string, lines: readonly string[]): ProjectSummary { + let section: string | undefined; + let name: string | undefined; + let version: string | undefined; + let standard: string | undefined; + let profile: string | undefined; + let toolchainSpec: string | undefined; + let target: string | undefined; + const targets: TargetSummary[] = []; + let currentTarget: { name: string; kind?: string } | undefined; + + const flushTarget = (): void => { + if (currentTarget !== undefined) { + targets.push({ name: currentTarget.name, kind: currentTarget.kind ?? "target" }); + currentTarget = undefined; + } + }; + + for (const line of lines) { + const header = headerOf(line); + if (header !== undefined) { + flushTarget(); + section = header; + if (header.startsWith(TARGETS)) { + currentTarget = { name: header.slice(TARGETS.length).replace(/^['"]|['"]$/g, "") }; + } + continue; + } + if (section === IDENTITY) { + // `[package]` only: a `[target.'cfg(..)'.package]` is a different plane. + name ??= scalar(line, "name"); + version ??= scalar(line, "version"); + standard ??= scalar(line, "standard"); + profile ??= scalar(line, "default_profile"); + } else if (section === TOOLCHAIN) { + toolchainSpec ??= scalar(line, "spec") ?? scalar(line, "family"); + } else if (section === "build") { + profile ??= scalar(line, "profile"); + } else if (currentTarget !== undefined) { + currentTarget.kind ??= scalar(line, "kind"); + } + } + flushTarget(); + + // `[target.]` names the triple; `[targets.]` names an artifact. + for (const line of lines) { + const match = /^\s*\[\s*target\s*\.\s*(?:'([^']+)'|"([^"]+)"|([^\]]+))\s*\]\s*$/.exec(line); + if (match !== null) { + target = (match[1] ?? match[2] ?? match[3] ?? "").trim() || undefined; + break; + } + } + + // Keys are omitted rather than set to undefined, so a summary compares cleanly. + const summary: ProjectSummary = { root }; + if (name !== undefined) summary.name = name; + if (version !== undefined) summary.version = version; + if (standard !== undefined) summary.standard = standard; + if (profile !== undefined) summary.profile = profile; + if (toolchainSpec !== undefined) summary.toolchainSpec = toolchainSpec; + if (target !== undefined) summary.target = target; + if (targets.length > 0) summary.targets = targets; + if (lines.some((line) => headerOf(line) === "test")) summary.hasTests = true; + return summary; +} diff --git a/src/toml/completion.ts b/src/toml/completion.ts new file mode 100644 index 0000000..876d2e6 --- /dev/null +++ b/src/toml/completion.ts @@ -0,0 +1,523 @@ +// mcpp.toml 的代码补全查询层(schema 驱动版)。 +// +// 范围:段头结构建议 + 已知段的键/枚举值补全 + 开放词汇段的写法模板。每条建议 +// 携带显式替换范围。段/键/枚举来自 `data/toml-schema.json`(src/toml/schema.ts), +// 与诊断共用同一份快照:快照没有的段不会出现在段头表里,快照标记 `openKeys` +// 的段(键由用户自选)不出键建议。 +// +// 依赖版本补全(`mcpp.toml.indexCompletion`,默认关):本模块只负责 +// 「光标是不是在依赖版本值位置」与「把候选变成建议」两件纯事;执行 +// `mcpp search`、超时、会话缓存、信任/离线判定都在 src/toml/providers.ts, +// 解析人类输出在 src/cli/search.ts。本模块不依赖 vscode API:`t()` 只在 +// `auto` 路径上按需加载编辑器 API,纯单测环境下退化为英文 key。 + +import type { PackageVersion } from "../cli/search"; +import { t } from "../i18n/t"; +import { + contextAt, + parseMcppToml, + resolveSection, + type ReplaceRange, +} from "./parser"; +import { SCHEMA, sectionByName, type TomlKey, type TomlSection } from "./schema"; + +export type McppTomlSuggestionKind = "section" | "template" | "version"; + +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; + /** + * 明细文案。存的是英文 key 的**取用函数**,每个补全请求才 `t()` 一次,所以 + * 改 `mcpp.ui.language` 无需重载窗口,且本模块在无编辑器的单测里也能加载。 + */ + detail: () => string; +} + +// 段头明细表:只补充「写法」(label/snippet/detail),不再是段清单本身。 +// 段清单来自 schema;这里按 group 名匹配,schema 新出现的段用 plane 兜底。 +// 出处:mcpp 文档 02/03/05/06 与 src/manifest/toml.cppm 的段清单(契约测试用 +// 真实 mcpp 逐段验证)。 +export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ + { group: "package", label: "[package]", header: "[package]", detail: () => t("Package metadata") }, + { group: "lib", label: "[lib]", header: "[lib]", detail: () => t("Library root-module convention") }, + { group: "build", label: "[build]", header: "[build]", detail: () => t("Build configuration") }, + { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: () => t("Generated files (path → content)") }, + { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: () => t("Runtime dependencies") }, + { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: () => t("Development/test dependencies") }, + { group: "build-dependencies", label: "[build-dependencies]", header: "[build-dependencies]", detail: () => t("Build-time dependencies (pulled at build time only, invisible at run time)") }, + { group: "workspace", label: "[workspace]", header: "[workspace]", detail: () => t("Workspace member declarations") }, + { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: () => t("Declare dependency versions centrally; members inherit them with workspace = true") }, + { group: "features", label: "[features]", header: "[features]", detail: () => t("Feature definitions") }, + { group: "feature-deps", label: "[feature-deps.]", header: "[feature-deps.${1:name}]", detail: () => t("Optional dependencies pulled in by a feature") }, + { group: "capabilities", label: "[capabilities]", header: "[capabilities]", detail: () => t("Capability bindings (provider selection)") }, + { group: "targets", label: "[targets.]", header: "[targets.${1:name}]", detail: () => t("Build targets") }, + { group: "profile", label: "[profile.]", header: "[profile.${1:name}]", detail: () => t("Build profiles") }, + { group: "runtime", label: "[runtime]", header: "[runtime]", detail: () => t("Host runtime capabilities") }, + { group: "resources", label: "[resources]", header: "[resources]", detail: () => t("Metadata and assets compiled into the product (PE targets only)") }, + { group: "toolchain", label: "[toolchain]", header: "[toolchain]", detail: () => t("Compiler toolchain shorthand") }, + { group: "xlings", label: "[xlings]", header: "[xlings]", detail: () => t("Build environment (supplied by xlings)") }, + { group: "xlings.workspace", label: "[xlings.workspace]", header: "[xlings.workspace]", detail: () => t("Pin tool versions") }, + { group: "target", label: "[target.]", header: "[target.${1:x86_64-linux-gnu}]", detail: () => t("Configuration per target triple") }, + { group: "pack", label: "[pack]", header: "[pack]", detail: () => t("mcpp pack packaging configuration") }, + { group: "pack.bundle-project", label: "[pack.bundle-project]", header: "[pack.bundle-project]", detail: () => t("Fine-tuning of the vendored filtering policy") }, + { group: "indices", label: "[indices]", header: "[indices]", detail: () => t("Project-level index redirection") }, + { group: "tools.overrides", label: "[tools.overrides]", header: "[tools.overrides]", detail: () => t("Host tool binary overrides") }, + { group: "language", label: "[language]", header: "[language]", detail: () => t("Legacy compatibility field; new projects should use [package].standard") }, +]; + +/** 依赖类段(键位置给依赖写法模板)。 */ +const DEPENDENCY_GROUPS: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", + "workspace.dependencies", + "feature-deps", +]); + +interface TemplateSpec { + label: string; + /** 同 `SectionHeaderSpec.detail`:取用时才 `t()`,语言切换无需重载。 */ + detail: () => string; + documentation?: () => string; + insertSnippet: string; +} + +const DEPENDENCY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'name = "version"', + detail: () => t("SemVer version dependency"), + documentation: () => t("Caret constraint (^) by default; ~, = and range combinations such as \">=1.0, <2.0\" are also supported."), + insertSnippet: '${1:name} = "${2:1.0.0}"', + }, + { + label: "name = { path = ... }", + detail: () => t("Path dependency (local development)"), + insertSnippet: '${1:name} = { path = "${2:../mylib}" }', + }, + { + label: "name = { git = ..., tag = ... }", + detail: () => t("Git dependency (one of tag / branch / rev)"), + insertSnippet: '${1:name} = { git = "${2:https://github.com/user/repo.git}", tag = "${3:v1.0.0}" }', + }, + { + label: "name = { version = ..., features = [...] }", + detail: () => t("Long dep spec: request a feature of that dependency"), + insertSnippet: '${1:name} = { version = "${2:1.0}", features = ["${3:feature}"] }', + }, + { + label: "name = { version = ..., tools = [...] }", + detail: () => t("Host tools produced by the dependency (must be a bin target of that package)"), + insertSnippet: '${1:name} = { version = "${2:1.0}", tools = ["${3:protoc}"] }', + }, +]; + +const FEATURE_TEMPLATES: readonly TemplateSpec[] = [ + { label: "name = [...]", detail: () => t("Array shorthand: implies the feature only"), insertSnippet: "${1:name} = [${2}]" }, + { label: "name = { defines = [...] }", detail: () => t("Table form: contributes the package's own macros when activated"), insertSnippet: '${1:name} = { defines = ["${2:MACRO}"] }' }, + { label: "name = { requires = [...] }", detail: () => t("Table form: requires a capability"), insertSnippet: '${1:name} = { requires = ["${2:blas}"] }' }, + { label: "name = { sources = [...] }", detail: () => t("Table form: feature-gated source globs"), insertSnippet: '${1:name} = { sources = ["${2:src/simd/**}"] }' }, +]; + +const GENERATED_FILE_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"path" = "content"', + detail: () => t("Generated file (relative path → content, part of the fingerprint)"), + insertSnippet: '"${1:src/gen/wrap.cppm}" = """\n${2:}\n"""', + }, +]; + +const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'capability = "provider"', + detail: () => t("Capability binding (equivalent to --cap)"), + insertSnippet: '${1:blas} = "${2:compat.openblas}"', + }, +]; + +const XLINGS_WORKSPACE_TEMPLATES: readonly TemplateSpec[] = [ + { label: 'tool = "version"', detail: () => t("Pin the xlings tool version"), insertSnippet: '${1:node} = "${2:24.19.0}"' }, +]; + +const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"pkg:tool" = "path"', + detail: () => t("Override a host tool with an existing binary (skips the build)"), + 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, + "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, +}; + +/** 手写明细按 group 索引;schema 段只借它取 label/snippet/detail。 */ +const HEADER_SPEC_BY_GROUP: ReadonlyMap = new Map( + SECTION_HEADERS.map((spec) => [spec.group, spec]), +); + +interface HeaderEntry { + label: string; + header: string; + detail: string; + documentation?: string; +} + +/** + * 段头清单:以 schema 为准(§3.3.2)。schema 为空(快照未生成)时才退回手写 + * 清单,保证补全在缺数据时仍可用。手写明细按段名匹配以保留既有 label/snippet/ + * 文案;schema 新收录的段用平面名兜底。 + */ +function headerEntries(): HeaderEntry[] { + if (SCHEMA.sections.length === 0) { + return SECTION_HEADERS.map((spec) => ({ + label: spec.label, + header: spec.header, + detail: spec.detail(), + })); + } + // The snapshot describes the schema mcpp validates; the curated list also covers + // tables mcpp accepts but the snapshot does not model (for example + // `[workspace.dependencies]`). Merge them, preferring the curated wording, so + // schema-driven detail never costs a writable table its suggestion. + const entries: HeaderEntry[] = SCHEMA.sections.map((section) => { + const spec = HEADER_SPEC_BY_GROUP.get(section.name); + const entry: HeaderEntry = { + label: spec?.label ?? section.header, + header: spec?.header ?? section.header, + detail: spec?.detail() ?? `plane: ${section.plane}`, + }; + if (section.deprecatedBy) { + entry.documentation = `Deprecated; use \`${section.deprecatedBy}\`.`; + } + return entry; + }); + const suggested = new Set(entries.map((entry) => entry.header)); + for (const spec of SECTION_HEADERS) { + if (suggested.has(spec.header)) { + continue; + } + entries.push({ label: spec.label, header: spec.header, detail: spec.detail() }); + } + return entries; +} + +function headerSuggestions(range: ReplaceRange): McppTomlSuggestion[] { + return headerEntries().map((entry) => { + const suggestion: McppTomlSuggestion = { + label: entry.label, + kind: "section", + detail: entry.detail, + insertSnippet: entry.header, + range, + }; + if (entry.documentation !== undefined) { + suggestion.documentation = entry.documentation; + } + return suggestion; + }); +} + +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, + })); +} + +/** 类型的占位值:与 §3.3.2 的键补全约定一致。 */ +function keyPlaceholder(key: TomlKey): string { + switch (key.type) { + case "boolean": + return "true"; + case "number": + return "0"; + case "array": + return "[]"; + case "enum": + return JSON.stringify(key.values?.[0] ?? ""); + default: + return '""'; + } +} + +/** `detail` 展示类型;枚举展开取值,有默认值再补默认值。 */ +function keyDetail(key: TomlKey): string { + let detail = key.type; + if (key.values !== undefined && key.values.length > 0) { + detail += `: ${key.values.join(" | ")}`; + } + if (key.default !== undefined) { + detail += ` (default: ${JSON.stringify(key.default)})`; + } + return detail; +} + +function keyDocumentation(key: TomlKey): string | undefined { + const lines: string[] = []; + if (key.note !== undefined && key.note !== "") { + lines.push(key.note); + } + if (key.since !== undefined) { + lines.push(`Since ${key.since}.`); + } + if (key.legacy === true) { + lines.push("Legacy key."); + } + return lines.length === 0 ? undefined : lines.join(" "); +} + +function keySuggestions( + section: TomlSection, + used: ReadonlySet, + range: ReplaceRange, +): McppTomlSuggestion[] { + const keys = section.keys ?? []; + return keys + .filter((key) => !used.has(key.key)) + .map((key) => { + const suggestion: McppTomlSuggestion = { + label: key.key, + kind: "template", + detail: keyDetail(key), + insertSnippet: `${key.key} = ${keyPlaceholder(key)}`, + range, + }; + const documentation = keyDocumentation(key); + if (documentation !== undefined) { + suggestion.documentation = documentation; + } + return suggestion; + }); +} + +/** + * 同段中光标之前已出现的顶层键。用容错解析器遍历节点树:`[targets.]` 这类 + * 行表按 parser 的组归属聚合,因此同组的不同行表会互相剔除已用键。 + */ +function keysUsedBefore(lines: readonly string[], line: number, group: string): Set { + const used = new Set(); + let current: string | undefined; + for (const node of parseMcppToml(lines).nodes) { + if (node.type === "section") { + const resolution = resolveSection(node.segments.map((segment) => segment.name)); + current = resolution.kind === "known" ? resolution.group : undefined; + continue; + } + if (current !== group || node.range.startLine >= line) { + continue; + } + const first = node.keyPath[0]; + if (first !== undefined) { + used.add(first.name); + } + } + return used; +} + +/** 值位置的枚举键:点分键按完整路径查,找不到退回首段(与诊断一致)。 */ +function enumKeyOf(section: TomlSection | undefined, keyPath: readonly string[]): TomlKey | undefined { + if (section?.keys === undefined || keyPath.length === 0) { + return undefined; + } + const joined = keyPath.join("."); + const key = + section.keys.find((entry) => entry.key === joined) ?? + section.keys.find((entry) => entry.key === keyPath[0]); + return key?.type === "enum" ? key : undefined; +} + +function enumValueSuggestions( + key: TomlKey, + insideString: boolean, + range: ReplaceRange, +): McppTomlSuggestion[] { + return (key.values ?? []).map((value) => ({ + label: value, + kind: "template", + detail: `value of ${key.key}`, + // 字符串内只替换内容;裸值位置补上引号,枚举值在 manifest 里都是字符串。 + insertSnippet: insideString ? value : JSON.stringify(value), + 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 headerSuggestions(range); + } + + if (context.kind === "key") { + const { section, containerPath, replaceRange } = context; + // 文档顶部(尚无段头):提示段头。未知段:不提供建议 + // (附录 A:不支持包自定义 toml 键)。 + if (section.kind === "top") { + return headerSuggestions(replaceRange); + } + if (section.kind !== "known" || containerPath.length > 0) { + return []; + } + if (DEPENDENCY_GROUPS.has(section.group)) { + return templateSuggestions(DEPENDENCY_TEMPLATES, replaceRange); + } + const schemaSection = sectionByName(section.group); + // openKeys 段的键由用户自选(如 [toolchain] 的平台名),不能给固定词表。 + if (schemaSection?.openKeys !== true && schemaSection?.keys !== undefined) { + const used = keysUsedBefore(lines, line, section.group); + return keySuggestions(schemaSection, used, replaceRange); + } + const templates = TEMPLATES_BY_GROUP[section.group]; + return templates === undefined ? [] : templateSuggestions(templates, replaceRange); + } + + // 值位置:只有已知枚举键才出候选,其余自由格式值不瞎猜。 + if (context.section.kind === "known") { + const schemaSection = sectionByName(context.section.group); + const key = enumKeyOf(schemaSection, context.keyPath); + if (key !== undefined) { + return enumValueSuggestions(key, context.insideString, context.replaceRange); + } + } + return []; +} + +/** 光标所在的依赖版本值位置:属于哪个依赖、替换范围、是否已在字符串内。 */ +export interface DependencyVersionContext { + /** 依赖名(值所属的键,如 `zlib`)。 */ + name: string; + /** 光标已在引号内(只替换内容,不再补引号)。 */ + insideString: boolean; + /** 替换范围:简写覆盖 `= ` 之后的值,长式在字符串内只覆盖已输入内容。 */ + range: ReplaceRange; +} + +/** + * 判断光标是否落在依赖的**版本值**上(方案 §3.3.2 的 `toml.indexCompletion` + * 位置)。只认两种写法: + * + * `zlib = "1.2|"` 简写版本 + * `zlib = { version = "1.2|" }` 长式 dep spec 的 version 字段 + * + * `git` / `path` / `features` 的值不是版本,返回 undefined,绝不据此查询索引。 + */ +export function dependencyVersionContextAt( + lines: readonly string[], + line: number, + character: number, +): DependencyVersionContext | undefined { + const context = contextAt(lines, line, character); + if (context.kind !== "value" || context.section.kind !== "known") { + return undefined; + } + if (!DEPENDENCY_GROUPS.has(context.section.group) || context.keyPath.length === 0) { + return undefined; + } + const last = context.keyPath[context.keyPath.length - 1]; + if (context.keyPath.length > 1 && last !== "version") { + return undefined; + } + // A version is a string, or a value not typed yet. An inline table, an array + // or a bare non-version token is a different dep-spec field; offering versions + // there would replace the wrong thing. + const kind = context.valueKind; + if (kind !== undefined && kind !== "string" && kind !== "unknown") { + return undefined; + } + return { + name: context.keyPath[0], + insideString: context.insideString, + range: context.replaceRange, + }; +} + +/** 把索引候选变成补全建议;字符串内只替换内容,裸值位置补上引号。 */ +export function indexVersionSuggestions( + context: DependencyVersionContext, + versions: readonly PackageVersion[], +): McppTomlSuggestion[] { + return versions.map((entry) => { + const suggestion: McppTomlSuggestion = { + label: entry.version, + kind: "version", + detail: + entry.summary === undefined ? `version of ${context.name}` : `${context.name}: ${entry.summary}`, + insertSnippet: context.insideString ? entry.version : JSON.stringify(entry.version), + range: context.range, + }; + return suggestion; + }); +} + +/** 取索引候选:按依赖名查询,失败与超时都由调用方降级为空数组。 */ +export type IndexVersionResolver = (name: string) => Promise; + +/** + * 静态补全 + 依赖版本补全的合并入口。静态层有建议就返回它;否则若光标在依赖 + * 版本值上且调用方给了 resolver,就询问索引。resolver 抛异常时静默降级为 + * 「无候选」,绝不冒泡成错误。 + */ +export async function computeMcppTomlCompletionsWithIndex( + lines: readonly string[], + line: number, + character: number, + resolveIndex?: IndexVersionResolver, +): Promise { + const direct = computeMcppTomlCompletions(lines, line, character); + if (direct.length > 0 || resolveIndex === undefined) { + return direct; + } + const context = dependencyVersionContextAt(lines, line, character); + if (context === undefined) { + return direct; + } + let versions: readonly PackageVersion[]; + try { + versions = await resolveIndex(context.name); + } catch { + return direct; + } + return indexVersionSuggestions(context, versions); +} diff --git a/src/toml/diagnostics.ts b/src/toml/diagnostics.ts new file mode 100644 index 0000000..1f0095e --- /dev/null +++ b/src/toml/diagnostics.ts @@ -0,0 +1,629 @@ +// mcpp.toml 的纯文本诊断层。 +// +// 输入是文件的物理行 + 严重度配置,输出是 1 基行列(列按 UTF-16 码元计数, +// 与 VS Code 的 Diagnostic 一致)的诊断列表。本模块不依赖 vscode API, +// 可在 node --test 下直接测试。 +// +// 它**不是**一个 TOML 校验器:只理解一个保守子集——段头、`key = value`、 +// 行注释、单/双引号字符串、三引号多行字符串,以及可跨行的数组/内联表—— +// 目的是抓住明显写坏的行与 mcpp 专属的平面/废弃规则,而不是重实现 TOML。 +// 子集之外的一切一律不报(宁可漏报,不可误报)。下面每个规则都注明它停在哪里。 +// +// 规则与严重度(默认值见 data/toml-schema.json 的 rules): +// 1 syntax 明显的语法破坏 +// 2 unknown-section 段不在 schema 中 +// 3 unknown-key 已知段中的未知键 +// 4 plane-separation 工具当依赖 / 包当工具 +// 5 mcpp-floor [package].mcpp 不是 ">=" 形式 +// 6 legacy-key legacy 段/键 +// 7 array-table 数组表 [[...]](mcpp 只接受少数几处,见下) + +import { sectionByName, type TomlKey, type TomlSection } from "./schema"; + +export type Severity = "error" | "warning" | "info" | "off"; + +export interface TomlDiagnostic { + code: string; + message: string; + severity: Exclude; + /** 1 基行号。 */ + line: number; + /** 1 基起始列(含)。 */ + startCharacter: number; + /** 1 基结束列(不含)。 */ + endCharacter: number; + relatedKey?: string; +} + +export interface DiagnosticSettings { + syntax: Severity; + unknownSection: Severity; + unknownKey: Severity; + planeSeparation: Severity; + legacyKeys: Severity; +} + +/** + * `mcppFloor` 与 `arrayTable` 在设置面上还没有独立开关(§4 的 registry 尚未 + * 收录),因此默认是 error;一旦设置面补齐,传入同名字段即可覆盖,无需改签名。 + */ +type ExtendedSettings = DiagnosticSettings & { + mcppFloor?: Severity; + arrayTable?: Severity; +}; + +/** 稳定的诊断码。补全/快速修复按它分发。 */ +export const DIAGNOSTIC_CODES = { + syntax: "mcpp.manifest.syntax", + unknownSection: "mcpp.manifest.unknownSection", + unknownKey: "mcpp.manifest.unknownKey", + planeSeparation: "mcpp.manifest.planeSeparation", + mcppFloor: "mcpp.manifest.mcppFloor", + legacyKey: "mcpp.manifest.legacyKey", + arrayTable: "mcpp.manifest.arrayTable", +} as const; + +type RuleName = keyof typeof DIAGNOSTIC_CODES; + +// ── TOML 子集的扫描状态 ───────────────────────────────────────────────────── +// mode: 当前是否在一个未闭合的三引号字符串里。 +// depth: 未闭合的 `[` / `{` 层数(数组与内联表可跨行)。 +interface ParseState { + mode: "normal" | "ml-double" | "ml-single"; + depth: number; +} + +/** text[i] 之前的反斜杠是否为奇数个(三引号串里的 \"\"\" 不结束)。 */ +function isEscaped(text: string, index: number): boolean { + let backslashes = 0; + for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + +/** + * 扫描一行,更新跨行状态;返回该行中「真正的注释起始 `#`」的下标(没有则 -1)。 + * 引号内的 `#` 不是注释。这是本模块对 TOML 词法理解的边界:不处理转义还原、 + * 不处理 \uXXXX、不看值是否合法。 + */ +function scan(text: string, state: ParseState): number { + let i = 0; + while (i < text.length) { + if (state.mode === "ml-double") { + if (text.startsWith('"""', i) && !isEscaped(text, i)) { + state.mode = "normal"; + i += 3; + continue; + } + i += 1; + continue; + } + if (state.mode === "ml-single") { + if (text.startsWith("'''", i)) { + state.mode = "normal"; + i += 3; + continue; + } + i += 1; + continue; + } + + const ch = text[i]; + if (ch === "#") { + return i; + } + if (ch === '"') { + if (text.startsWith('"""', i)) { + state.mode = "ml-double"; + i += 3; + continue; + } + i += 1; + while (i < text.length) { + if (text[i] === "\\") { + i += 2; + continue; + } + if (text[i] === '"') { + i += 1; + break; + } + i += 1; + } + continue; + } + if (ch === "'") { + if (text.startsWith("'''", i)) { + state.mode = "ml-single"; + i += 3; + continue; + } + i += 1; + while (i < text.length) { + if (text[i] === "'") { + i += 1; + break; + } + i += 1; + } + continue; + } + if (ch === "[" || ch === "{") { + state.depth += 1; + i += 1; + continue; + } + if (ch === "]" || ch === "}") { + if (state.depth > 0) state.depth -= 1; + i += 1; + continue; + } + i += 1; + } + return -1; +} + +/** 去掉行尾注释(引号内的 `#` 保留),返回可解析的代码片段。 */ +function codeBeforeComment(text: string): string { + const stop = scan(text, { mode: "normal", depth: 0 }); + return stop < 0 ? text : text.slice(0, stop); +} + +/** 在引号之外查找 needle 的下标;找不到返回 -1。 */ +function indexOfUnquoted(text: string, needle: string): number { + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"' || ch === "'") { + const delim = ch.repeat(3); + if (!text.startsWith(delim, i)) { + i += 1; + while (i < text.length) { + if (ch === '"' && text[i] === "\\") { + i += 2; + continue; + } + if (text[i] === ch) { + i += 1; + break; + } + i += 1; + } + continue; + } + i += 3; + while (i < text.length) { + if (text.startsWith(delim, i) && !isEscaped(text, i)) { + i += 3; + break; + } + i += 1; + } + continue; + } + if (text.startsWith(needle, i)) { + return i; + } + i += 1; + } + return -1; +} + +/** 按点拆分段路径/点分键,引号内的点不拆。保留引号。 */ +function splitDots(text: string): string[] { + const parts: string[] = []; + let current = ""; + let quote: string | null = null; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (quote !== null) { + current += ch; + if (ch === quote) { + quote = null; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + if (ch === ".") { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + parts.push(current); + return parts; +} + +function isBareKey(text: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(text); +} + +function isQuotedKey(text: string): boolean { + if (text.length < 2) { + return false; + } + const first = text[0]; + const last = text[text.length - 1]; + return (first === '"' && last === '"') || (first === "'" && last === "'"); +} + +/** `a.b` / `"a b"` / `c."d.e"` 是合法键;其余交给语法规则。 */ +function isValidKey(text: string): boolean { + const parts = splitDots(text); + return parts.length > 0 && parts.every((part) => isBareKey(part) || isQuotedKey(part)); +} + +/** 去掉一层引号;不是带引号的字符串则返回 undefined(不做转义还原)。 */ +function unquote(text: string): string | undefined { + if (text.length >= 2) { + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return text.slice(1, -1); + } + } + return undefined; +} + +// ── schema 侧的段解析 ─────────────────────────────────────────────────────── + +/** 精确匹配,失败时逐级去掉尾部段(`[target.'cfg(x)'.dependencies]` → `target`)。 */ +function lookupSection(name: string): TomlSection | undefined { + const exact = sectionByName(name); + if (exact !== undefined) { + return exact; + } + const parts = splitDots(name); + for (let i = parts.length - 1; i >= 1; i -= 1) { + const candidate = sectionByName(parts.slice(0, i).join(".")); + if (candidate !== undefined) { + return candidate; + } + } + return undefined; +} + +/** + * 键规则要用的段:`[targets.]`、`[profile.]`、`[target.]` 这三种 + * 「行表」继承基段的键表,更深一层(`[package.metadata.]`、 + * `[target..build]`、`[runtime.]`、`[toolchain.]`)的键 + * 属于另一套词表,不做未知键检查。 + */ +const ROW_KEY_SECTIONS: ReadonlySet = new Set(["targets", "profile", "target"]); + +function keySectionOf(headerName: string): TomlSection | undefined { + const exact = sectionByName(headerName); + if (exact !== undefined) { + return exact; + } + const parts = splitDots(headerName); + if (parts.length === 2 && ROW_KEY_SECTIONS.has(parts[0])) { + return sectionByName(parts[0]); + } + return undefined; +} + +/** 键在段里的元数据;点分键(`metadata.demo`)按首段查找。 */ +function keyInfoOf(section: TomlSection | undefined, key: string): TomlKey | undefined { + if (section?.keys === undefined) { + return undefined; + } + const parts = splitDots(key); + const direct = section.keys.find((entry) => entry.key === key); + if (direct !== undefined) { + return direct; + } + if (parts.length > 1) { + return section.keys.find((entry) => entry.key === parts[0]); + } + return undefined; +} + +// ── mcpp 专属规则用的判定 ─────────────────────────────────────────────────── + +const DEPENDENCY_TABLES: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", +]); + +function lastSegment(name: string): string { + const parts = splitDots(name); + return parts[parts.length - 1]; +} + +function isDependencySection(name: string): boolean { + return DEPENDENCY_TABLES.has(lastSegment(name)); +} + +/** `[xlings]`、`[xlings.workspace]` 及其条件形式 `[target..xlings…]`。 */ +function isXlingsSection(name: string): boolean { + return splitDots(name).includes("xlings"); +} + +/** 工具载荷名:`xim:` / `xpm:` / `xlings:` / `xvm:` 前缀。 */ +function isToolPayloadName(key: string): boolean { + const bare = unquote(key) ?? key; + return /^(xim|xpm|xlings|xvm):/i.test(bare); +} + +/** mcpp 包身份形如 `namespace.name`(至少一个点),不是工具载荷。 */ +function isMcppPackageName(key: string): boolean { + const bare = unquote(key) ?? key; + if (isToolPayloadName(bare)) { + return false; + } + return /^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/.test(bare); +} + +/** + * 值是否陈述了一个版本而不是一个路径。`"1.0"` / `{ version = "1.0" }` 是; + * `{ path = "../x" }`、`""`(平台键对象)、`{ linux = "" }` 不是。 + */ +function declaresVersion(valueText: string): boolean { + const text = valueText.trim(); + if (text.startsWith("{")) { + return /(^|[,{\s])version\s*=/.test(text) && !/(^|[,{\s])path\s*=/.test(text); + } + const bare = unquote(text); + if (bare === undefined || bare === "") { + return false; + } + return !bare.includes("/") && !bare.startsWith("."); +} + +/** + * mcpp 允许的数组表(`[[...]]`)。这份名单来自 toml.cppm 的 + * `kAllowedArraysOfTables`:除这些之外,任何 `[[...]]` 都是 mcpp 不接受的写法。 + * + * 注意:任务书写的是「`[[name]]`,mcpp 不使用」,但 mcpp 明确接受 + * `[[build.flags]]`、`[[features..flags]]`、`[[runtime.requirements]]` 等 + * 少数数组表,把它们报错就是误报——而本层的第一原则是不误报。 + */ +const ALLOWED_ARRAY_TABLES: ReadonlyArray> = [ + ["build", "flags"], + ["build", "sources"], + ["features", "*", "flags"], + ["runtime", "requirements"], + ["runtime", "artifacts"], + ["runtime", "deploy"], + ["target", "*", "build", "flags"], + ["target", "*", "build", "sources"], + ["xlings", "deps"], +]; + +function isAllowedArrayTable(name: string): boolean { + const parts = splitDots(name); + return ALLOWED_ARRAY_TABLES.some( + (pattern) => + pattern.length === parts.length && pattern.every((part, index) => part === "*" || part === parts[index]), + ); +} + +/** docs/04 §2.1:只有 `>=` 形式的下界被接受。 */ +function isMcppFloor(valueText: string): boolean { + const bare = unquote(valueText.trim()); + return bare !== undefined && /^>=\d+(\.\d+)+$/.test(bare); +} + +// ── 入口 ──────────────────────────────────────────────────────────────────── + +/** + * 分析一份 mcpp.toml。`lines` 是文件的物理行(含或不含 `\r` 都可以); + * 返回按 (line, startCharacter, code) 排序的稳定诊断列表。 + */ +export function analyseManifest( + lines: readonly string[], + settings: DiagnosticSettings, +): TomlDiagnostic[] { + const extended = settings as ExtendedSettings; + const severity: Record = { + syntax: settings.syntax, + unknownSection: settings.unknownSection, + unknownKey: settings.unknownKey, + planeSeparation: settings.planeSeparation, + mcppFloor: extended.mcppFloor ?? "error", + legacyKey: settings.legacyKeys, + arrayTable: extended.arrayTable ?? "error", + }; + + const diagnostics: TomlDiagnostic[] = []; + + function push( + rule: RuleName, + message: string, + line: number, + startCharacter: number, + endCharacter: number, + relatedKey?: string, + ): void { + const level = severity[rule]; + if (level === "off") { + return; + } + const diagnostic: TomlDiagnostic = { + code: DIAGNOSTIC_CODES[rule], + message, + severity: level, + line, + startCharacter, + endCharacter, + }; + if (relatedKey !== undefined) { + diagnostic.relatedKey = relatedKey; + } + diagnostics.push(diagnostic); + } + + const state: ParseState = { mode: "normal", depth: 0 }; + /** 当前段头的原始名字(未解析到 schema 的时刻也保留,用于平面规则)。 */ + let headerName: string | undefined; + /** 当前段解析到的 schema 段(键规则用;未知段为 undefined)。 */ + let section: TomlSection | undefined; + + for (let index = 0; index < lines.length; index += 1) { + const raw = lines[index].endsWith("\r") ? lines[index].slice(0, -1) : lines[index]; + const lineNumber = index + 1; + + // 多行字符串/数组的续行:整行属于上一个结构,不产生诊断。 + if (state.mode !== "normal" || state.depth > 0) { + scan(raw, state); + continue; + } + + const code = codeBeforeComment(raw); + const text = code.trim(); + if (text === "") { + scan(raw, state); + continue; + } + // 行首空白宽度。 + const lead = code.length - code.trimStart().length; + const lineStart = lead + 1; + const lineEnd = lead + text.length + 1; + + // ── 段头 ── + if (text.startsWith("[")) { + const arrayHeader = /^\[\[(.+?)\]\]$/.exec(text); + const plainHeader = /^\[(.+?)\]$/.exec(text); + if (arrayHeader === null && plainHeader === null) { + // `[` / `[foo` / `[foo] bar`:不是合法段头,也不是 key = value。 + push("syntax", `not a [section] header: ${text}`, lineNumber, lineStart, lineEnd); + scan(raw, state); + continue; + } + + const isArray = arrayHeader !== null; + const name = ((isArray ? arrayHeader : plainHeader)?.[1] ?? "").trim(); + headerName = name; + section = keySectionOf(name); + + if (isArray) { + // 规则 7:mcpp 只接受少数数组表;其余是用户写错的 `[[...]]`。 + if (!isAllowedArrayTable(name)) { + push( + "arrayTable", + `[[${name}]] is an array of tables, which mcpp does not use here`, + lineNumber, + lineStart, + lineEnd, + name, + ); + } + // 规则 2 明确忽略数组表。 + } else { + // 规则 2:未知段(允许点分名逐级回退到已知前缀段)。 + if (lookupSection(name) === undefined) { + push("unknownSection", `unknown section [${name}]`, lineNumber, lineStart, lineEnd, name); + } else if (section?.deprecatedBy) { + // 规则 6:段级 legacy。 + push( + "legacyKey", + `[${name}] is deprecated; use ${section.deprecatedBy}`, + lineNumber, + lineStart, + lineEnd, + section.deprecatedBy, + ); + } + } + scan(raw, state); + continue; + } + + // ── 键值行 ── + const equals = indexOfUnquoted(text, "="); + if (equals <= 0) { + push("syntax", `not a [section] header or key = value: ${text}`, lineNumber, lineStart, lineEnd); + scan(raw, state); + continue; + } + const key = text.slice(0, equals).trim(); + const value = text.slice(equals + 1).trim(); + const keyStart = lineStart; + const keyEnd = keyStart + key.length; + + if (!isValidKey(key)) { + push("syntax", `invalid key: ${key}`, lineNumber, keyStart, keyEnd); + scan(raw, state); + continue; + } + if (value === "") { + push("syntax", `key ${key} has no value`, lineNumber, keyStart, keyEnd, key); + scan(raw, state); + continue; + } + + const info = keyInfoOf(section, key); + + // 规则 3:已知段 + 有键表 + 不在表中(且该段不是自选键的段)。 + if (section?.keys !== undefined && section.openKeys !== true && info === undefined) { + const label = section.header; + push("unknownKey", `unknown key ${key} in ${label}`, lineNumber, keyStart, keyEnd, key); + } + + // 规则 6:键级 legacy(段本身已 deprecated 时只在段头报一次)。 + if (info?.legacy === true && !section?.deprecatedBy) { + const replacement = info.note ?? "no replacement documented"; + push( + "legacyKey", + `key ${key} is deprecated; ${replacement}`, + lineNumber, + keyStart, + keyEnd, + key, + ); + } + + // 规则 5:[package].mcpp 必须是 ">="。 + if (section?.name === "package" && (splitDots(key)[0] === "mcpp") && !isMcppFloor(value)) { + push( + "mcppFloor", + `[package] mcpp must be a release floor like ">=2026.9.28.3"`, + lineNumber, + keyStart, + keyEnd, + "mcpp", + ); + } + + // 规则 4:平面分离。 + const bareKey = unquote(key) ?? key; + if (isDependencySection(headerName ?? "") && isToolPayloadName(bareKey)) { + push( + "planeSeparation", + `${bareKey} is a tool payload, not a library dependency; declare it in [xlings]/[xlings.workspace]`, + lineNumber, + keyStart, + keyEnd, + bareKey, + ); + } else if (isXlingsSection(headerName ?? "") && isMcppPackageName(bareKey) && declaresVersion(value)) { + push( + "planeSeparation", + `${bareKey} is an mcpp package, not a tool payload; declare it in [dependencies]`, + lineNumber, + keyStart, + keyEnd, + bareKey, + ); + } + + scan(raw, state); + } + + diagnostics.sort( + (a, b) => + a.line - b.line || + a.startCharacter - b.startCharacter || + a.code.localeCompare(b.code) || + a.message.localeCompare(b.message), + ); + return diagnostics; +} diff --git a/src/toml/hover.ts b/src/toml/hover.ts new file mode 100644 index 0000000..1342499 --- /dev/null +++ b/src/toml/hover.ts @@ -0,0 +1,206 @@ +// mcpp.toml 的悬停查询层。 +// +// 输入是文件的物理行 + 0 基光标,输出纯数据 HoverInfo;provider 负责渲染成 +// MarkdownString(本模块不依赖 vscode API)。上下文来自 parser 的 contextAt, +// 与补全共用同一套容错解析,不另写 TOML 解析器。 +// +// 覆盖三类位置:段头、键、枚举键的值。其余位置一律 undefined——宁可不显示, +// 也不猜;任何畸形输入都不抛异常。 + +import { + contextAt, + resolveSection, + type ReplaceRange, + type TomlCursorContext, +} from "./parser"; +import { sectionByName, type TomlKey, type TomlSection } from "./schema"; + +export interface HoverInfo { + title: string; + body: string; + documentation?: string; +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(Math.trunc(value), min), max); +} + +/** 取光标所在行(越界与 CRLF 都容错)。 */ +function lineTextAt(lines: readonly string[], line: number): string { + if (!Array.isArray(lines) || lines.length === 0) { + return ""; + } + const index = clamp(line, 0, lines.length - 1); + return (lines[index] ?? "").replace(/\r$/, ""); +} + +/** 替换范围对应的原文(越界钳制)。 */ +function tokenAt(lineText: string, range: ReplaceRange): string { + const start = clamp(range.startCharacter, 0, lineText.length); + const end = clamp(range.endCharacter, start, lineText.length); + return lineText.slice(start, end); +} + +function unquote(text: string): string { + if (text.length >= 2) { + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return text.slice(1, -1); + } + } + return text; +} + +/** + * 段头路径 → schema 段:先精确匹配,再让 parser 的组映射处理参数化段 + * (`[targets.app]` → `targets`),最后逐级去掉尾部(与诊断的 lookupSection + * 同策略),使 `[features.a]` 这类行表也能落到基段。 + */ +function sectionForHeader(segments: readonly string[]): TomlSection | undefined { + if (segments.length === 0) { + return undefined; + } + const exact = sectionByName(segments.join(".")); + if (exact !== undefined) { + return exact; + } + const resolution = resolveSection(segments); + if (resolution.kind === "known") { + // parser 认识的组就是权威归属:schema 没收录它(如 [xlings.workspace])时 + // 不显示,而不是把键张冠李戴到基段上。 + return sectionByName(resolution.group); + } + for (let length = segments.length - 1; length >= 1; length -= 1) { + const found = sectionByName(segments.slice(0, length).join(".")); + if (found !== undefined) { + return found; + } + } + return undefined; +} + +function sectionHover(section: TomlSection): HoverInfo { + const lines: string[] = [`plane: \`${section.plane}\``]; + if (section.deprecatedBy) { + lines.push(`legacy: replaced by \`${section.deprecatedBy}\``); + } + const keyCount = section.keys?.length ?? 0; + lines.push( + section.keys === undefined + ? "keys: none declared in the snapshot" + : `keys: ${keyCount}${section.openKeys === true ? " (open: the user picks the names)" : ""}`, + ); + const info: HoverInfo = { title: section.header, body: lines.join("\n\n") }; + if (section.doc !== undefined) { + info.documentation = section.doc; + } + return info; +} + +function keyHover(key: TomlKey, section: TomlSection | undefined): HoverInfo { + const lines: string[] = [`type: \`${key.type}\``]; + if (key.values !== undefined && key.values.length > 0) { + lines.push(`values: ${key.values.map((value) => `\`${value}\``).join(" | ")}`); + } + if (key.default !== undefined) { + lines.push(`default: \`${JSON.stringify(key.default)}\``); + } + if (key.since !== undefined) { + lines.push(`since: ${key.since}`); + } + if (key.note !== undefined && key.note !== "") { + lines.push(key.note); + } + if (key.legacy === true) { + lines.push("legacy: this key is deprecated"); + } + if (key.unmodelled === true) { + lines.push("unmodelled: the type is inferred from the docs, not read from mcpp"); + } + const info: HoverInfo = { title: key.key, body: lines.join("\n\n") }; + if (section?.doc !== undefined) { + info.documentation = section.doc; + } + return info; +} + +function enumValueHover(key: TomlKey, value: string, section: TomlSection | undefined): HoverInfo { + const lines: string[] = [`enum value of \`${key.key}\``]; + if (key.values !== undefined && key.values.length > 0) { + lines.push(`one of: ${key.values.map((entry) => `\`${entry}\``).join(" | ")}`); + } + if (key.default !== undefined) { + lines.push(`default: \`${JSON.stringify(key.default)}\``); + } + if (key.note !== undefined && key.note !== "") { + lines.push(key.note); + } + const info: HoverInfo = { title: value === "" ? key.key : value, body: lines.join("\n\n") }; + if (section?.doc !== undefined) { + info.documentation = section.doc; + } + return info; +} + +/** + * 光标下的悬停内容;没有可显示的内容时返回 undefined。永不抛异常。 + */ +export function hoverAt( + lines: readonly string[], + line: number, + character: number, +): HoverInfo | undefined { + try { + const context: TomlCursorContext = contextAt(lines, line, character); + const text = lineTextAt(lines, line); + + if (context.kind === "section-header") { + const segments = [...context.segments]; + const token = tokenAt(text, context.replaceRange).trim(); + // 光标落在某个段路径 token 内时,context.segments 是它之前的段。 + if (token !== "") { + segments.push(unquote(token)); + } + const section = sectionForHeader(segments); + return section === undefined ? undefined : sectionHover(section); + } + + if (context.kind === "key") { + if (context.section.kind !== "known" || context.containerPath.length > 0) { + return undefined; + } + const section = sectionByName(context.section.group); + if (section?.keys === undefined) { + return undefined; + } + // 点分键的根段是 schema 里的键(`metadata.demo` → `metadata`)。 + const token = unquote(tokenAt(text, context.replaceRange).trim()); + const name = context.keyPrefix.length > 0 ? context.keyPrefix[0] : token; + const key = section.keys.find((entry) => entry.key === name); + return key === undefined ? undefined : keyHover(key, section); + } + + // 值位置:只有枚举键的值有可展示的语义。 + if (context.section.kind !== "known") { + return undefined; + } + const section = sectionByName(context.section.group); + if (section?.keys === undefined || context.keyPath.length === 0) { + return undefined; + } + const key = + section.keys.find((entry) => entry.key === context.keyPath.join(".")) ?? + section.keys.find((entry) => entry.key === context.keyPath[0]); + if (key?.type !== "enum") { + return undefined; + } + const value = unquote(tokenAt(text, context.replaceRange)).trim(); + return enumValueHover(key, value, section); + } catch { + return undefined; + } +} diff --git a/src/toml/navigation.ts b/src/toml/navigation.ts new file mode 100644 index 0000000..aa0b7f8 --- /dev/null +++ b/src/toml/navigation.ts @@ -0,0 +1,306 @@ +// mcpp.toml 的跳转查询层:只处理 manifest 内部的三种局部跳转。 +// +// workspace = true → [workspace.dependencies] 里的同名依赖键 +// path = "../x" → 那个 mcpp.toml 的 [package] 段头(行号由调用方解析) +// features = ["a"] → [features.a] 段头(光标所在的那个元素) +// +// 复用 parser 的节点树(parseMcppToml)定位光标下的键/值/数组元素;跨文件的 +// 文件系统语义由 `resolveManifest` 回调注入,本模块保持纯函数、不依赖 vscode, +// 也不自己读盘。任何畸形输入都不抛异常。 + +import { + parseMcppToml, + resolveSection, + type TomlDocument, + type TomlKeyValueNode, + type TomlSectionNode, + type TomlValueNode, +} from "./parser"; + +export interface ManifestLocation { + line: number; + startCharacter: number; + endCharacter: number; +} + +/** 依赖类段组:三种跳转都只在这些段里有意义(`[lib] path`、`[indices] path` 不是)。 */ +const DEPENDENCY_GROUPS: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", + "workspace.dependencies", + "feature-deps", +]); + +const PACKAGE_HEADER = "[package]"; + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(Math.trunc(value), min), max); +} + +/** 光标是否落在范围内(端点含尾,与 parser 的判定一致)。 */ +function contains( + range: { startLine: number; startCharacter: number; endLine: number; endCharacter: number }, + line: number, + character: number, +): boolean { + if (line < range.startLine || line > range.endLine) { + return false; + } + if (line === range.startLine && character < range.startCharacter) { + return false; + } + if (line === range.endLine && character > range.endCharacter) { + return false; + } + return true; +} + +/** 光标所在候选:一条键值语句,外加它所属的顶层依赖键(内联表内的条目才有)。 */ +interface Candidate { + node: TomlKeyValueNode; + /** 顶层语句的点分键(`compat.zlib = { workspace = true }` → "compat.zlib")。 */ + ownerKey?: string; + sectionSegments: string[]; +} + +/** 内联表/数组可以嵌套:把所有可达的键值语句摊平,附带顶层依赖键。 */ +function collectNested( + value: TomlValueNode | undefined, + ownerKey: string, + sectionSegments: string[], + out: Candidate[], +): void { + if (value === undefined) { + return; + } + if (value.kind === "inlineTable" && value.entries !== undefined) { + for (const entry of value.entries) { + out.push({ node: entry, ownerKey, sectionSegments }); + collectNested(entry.value, ownerKey, sectionSegments, out); + } + return; + } + if (value.kind === "array" && value.elements !== undefined) { + for (const element of value.elements) { + collectNested(element, ownerKey, sectionSegments, out); + } + } +} + +function candidatesOf(doc: TomlDocument): Candidate[] { + const out: Candidate[] = []; + let sectionSegments: string[] = []; + for (const node of doc.nodes) { + if (node.type === "section") { + sectionSegments = node.segments.map((segment) => segment.name); + continue; + } + out.push({ node, sectionSegments }); + const key = node.keyPath.map((segment) => segment.name).join("."); + collectNested(node.value, key, sectionSegments, out); + } + return out; +} + +function groupOf(sectionSegments: readonly string[]): string | undefined { + if (sectionSegments.length === 0) { + return undefined; + } + const resolution = resolveSection(sectionSegments); + return resolution.kind === "known" ? resolution.group : undefined; +} + +/** 依赖名:内联表条目取顶层语句的键;`[dependencies.foo]` 取段名去掉首段。 */ +function dependencyName(candidate: Candidate): string { + if (candidate.ownerKey !== undefined && candidate.ownerKey !== "") { + return candidate.ownerKey; + } + if (candidate.sectionSegments.length >= 2) { + return candidate.sectionSegments.slice(1).join("."); + } + return ""; +} + +function locationOfKey(node: TomlKeyValueNode): ManifestLocation | undefined { + const first = node.keyPath[0]; + const last = node.keyPath[node.keyPath.length - 1]; + if (first === undefined || last === undefined) { + return undefined; + } + // 点分键(`compat.zlib`)整体选中,不只选第一段。 + return { + line: first.range.startLine, + startCharacter: first.range.startCharacter, + endCharacter: last.range.endCharacter, + }; +} + +function locationOfSection(node: TomlSectionNode): ManifestLocation { + return { + line: node.line, + startCharacter: node.range.startCharacter, + endCharacter: node.range.endCharacter, + }; +} + +/** `[workspace.dependencies]` 里名字匹配的那条键值语句。 */ +function findWorkspaceDependency( + doc: TomlDocument, + dependency: string, +): TomlKeyValueNode | undefined { + if (dependency === "") { + return undefined; + } + let inWorkspaceDependencies = false; + for (const node of doc.nodes) { + if (node.type === "section") { + inWorkspaceDependencies = groupOf(node.segments.map((segment) => segment.name)) === "workspace.dependencies"; + continue; + } + if (inWorkspaceDependencies && node.keyPath.map((segment) => segment.name).join(".") === dependency) { + return node; + } + } + return undefined; +} + +/** `[features.]` 段头(数组表不算)。 */ +function findFeatureSection(doc: TomlDocument, name: string): TomlSectionNode | undefined { + for (const node of doc.nodes) { + if (node.type !== "section" || node.isArray || node.segments.length !== 2) { + continue; + } + if (node.segments[0].name === "features" && node.segments[1].name === name) { + return node; + } + } + return undefined; +} + +function isRelativePath(value: string): boolean { + const text = value.trim(); + if (text === "") { + return false; + } + if (text.startsWith("/") || text.startsWith("\\") || text.startsWith("~")) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(text)) { + return false; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) { + return false; + } + return true; +} + +function safeResolve( + resolveManifest: (relative: string) => number | undefined, + relative: string, +): number | undefined { + try { + const value = resolveManifest(relative); + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.trunc(value); + } catch { + return undefined; + } +} + +function locate( + candidate: Candidate, + line: number, + character: number, + doc: TomlDocument, + resolveManifest: (relative: string) => number | undefined, +): ManifestLocation | undefined { + const { node, sectionSegments } = candidate; + const onKey = node.keyPath.some((segment) => contains(segment.range, line, character)); + const onValue = node.value !== undefined && contains(node.value.range, line, character); + if (!onKey && !onValue) { + return undefined; + } + + const keyName = node.keyPath[node.keyPath.length - 1]?.name ?? ""; + const group = groupOf(sectionSegments); + const inDependencies = group !== undefined && DEPENDENCY_GROUPS.has(group); + + if (keyName === "workspace") { + const isTrue = node.value?.kind === "boolean" && node.value.text === "true"; + if (!isTrue || !inDependencies) { + return undefined; + } + const dependency = dependencyName(candidate); + const target = findWorkspaceDependency(doc, dependency); + return target === undefined ? undefined : locationOfKey(target); + } + + if (keyName === "path") { + if (!inDependencies || node.value?.kind !== "string") { + return undefined; + } + const relative = node.value.text ?? ""; + if (!isRelativePath(relative)) { + return undefined; + } + const resolved = safeResolve(resolveManifest, relative); + if (resolved === undefined) { + return undefined; + } + // 只有行号是可知的(另一个文件的内容不在本函数手里),所以选中该行的 + // [package] 字面量;缩进可能使起点略有偏差,位置仍落在段头行上。 + return { line: resolved, startCharacter: 0, endCharacter: PACKAGE_HEADER.length }; + } + + if (keyName === "features") { + if (node.value?.kind !== "array" || node.value.elements === undefined) { + return undefined; + } + const element = node.value.elements.find((entry) => contains(entry.range, line, character)); + if (element === undefined || element.kind !== "string") { + return undefined; + } + const name = (element.text ?? "").trim(); + if (name === "") { + return undefined; + } + const target = findFeatureSection(doc, name); + return target === undefined ? undefined : locationOfSection(target); + } + + return undefined; +} + +/** + * 光标位置的跳转目标;不在三种形态上时返回 undefined。永不抛异常。 + * + * `resolveManifest` 把相对路径解析为「那个 mcpp.toml 里 [package] 段头的行号 + * (0 基)」,不存在时返回 undefined——文件系统由调用方负责。 + */ +export function definitionAt( + lines: readonly string[], + line: number, + character: number, + resolveManifest: (relative: string) => number | undefined = () => undefined, +): ManifestLocation | undefined { + try { + const cursorLine = clamp(line, 0, Number.MAX_SAFE_INTEGER); + const cursorCharacter = clamp(character, 0, Number.MAX_SAFE_INTEGER); + const doc = parseMcppToml(lines); + for (const candidate of candidatesOf(doc)) { + const found = locate(candidate, cursorLine, cursorCharacter, doc, resolveManifest); + if (found !== undefined) { + return found; + } + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/toml/parser.ts b/src/toml/parser.ts new file mode 100644 index 0000000..9587c0e --- /dev/null +++ b/src/toml/parser.ts @@ -0,0 +1,894 @@ +// 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() !== "#") { + // 光标落在 token 内时由 scanValue 捕获。 + const ahead = this.scanValue(valuePath); + 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/toml/providers.ts b/src/toml/providers.ts new file mode 100644 index 0000000..d474ecc --- /dev/null +++ b/src/toml/providers.ts @@ -0,0 +1,284 @@ +/** + * `mcpp.toml` editing: structural completion plus manifest diagnostics. + * + * Both are pure text analysis — no mcpp process, no language server — so they + * keep working in a restricted workspace, which is what + * `capabilities.untrustedWorkspaces: limited` promises. + * + * The one exception is dependency-version completion + * (`mcpp.toml.indexCompletion`, off by default): it runs `mcpp search`, which may + * use the network. Everything about *that* decision lives here — the two + * settings, workspace trust, the timeout and the session cache — while + * `./completion` stays pure and `../cli/search` stays the one place that parses + * mcpp's human-readable output. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { + indexCompletionRequest, + parseSearchOutput, + withDeadline, + type PackageVersion, +} from "../cli/search"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; + +import { computeMcppTomlCompletionsWithIndex } from "./completion"; +import { analyseManifest, type DiagnosticSettings, type Severity } from "./diagnostics"; +import { hoverAt } from "./hover"; +import { definitionAt } from "./navigation"; + +export const MCPP_TOML_LANGUAGE = "mcpp-toml"; + +/** + * The session cache §3.3.2 asks for: at most one `mcpp search + * --all-versions` per package per session. Empty results are cached too — a + * package the index does not know must not be queried on every keystroke. + */ +const indexVersions = new Map(); + +let indexNoticeShown = false; + +/** The plan's one-time notice: this feature runs mcpp and may use the network. */ +function noticeIndexQuery(): void { + if (indexNoticeShown) { + return; + } + indexNoticeShown = true; + void vscode.window.showInformationMessage( + t("Completing dependency versions runs `mcpp search`, which may use the network."), + ); +} + +/** + * Versions for one dependency, or none. Every failure mode — the setting off, an + * untrusted workspace, a non-zero exit, a timeout, output the parser does not + * recognise — degrades to an empty list; nothing here surfaces an error. + */ +async function resolveIndexVersions( + document: vscode.TextDocument, + name: string, +): Promise { + const request = indexCompletionRequest(name, { + enabled: read("mcpp.toml.indexCompletion"), + trusted: vscode.workspace.isTrusted, + // VS Code exposes no offline flag and this extension never probes the + // network, so "offline" is discovered the only honest way: the query fails + // and degrades to no candidates. The term stays in `shouldSearch`'s contract + // (and its tests) so a future signal can fill it in without a shape change. + offline: false, + timeoutSeconds: read("mcpp.toml.indexCompletionTimeoutSeconds"), + }); + if (request === undefined) { + return []; + } + + // The cache is consulted *after* the switches, so turning the setting off (or + // losing trust) stops suggestions immediately instead of serving a hit. + const cached = indexVersions.get(name); + if (cached !== undefined) { + return cached; + } + noticeIndexQuery(); + + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + const cwd = folder?.uri.fsPath ?? vscode.Uri.joinPath(document.uri, "..").fsPath; + const executable = (read("mcpp.path") ?? "").trim() || "mcpp"; + // The process gets the setting's timeout and the promise a short grace period, + // so a process that refuses to die still cannot hold the editor past it. + const result = await withDeadline( + runMcpp(vscode.workspace.isTrusted, executable, request.args, cwd, { timeoutMs: request.timeoutMs }), + request.timeoutMs + 250, + ); + const versions = + result === undefined || result.exitCode !== 0 ? [] : parseSearchOutput(result.stdout); + indexVersions.set(name, versions); + return versions; +} + +function linesOf(document: vscode.TextDocument): string[] { + const lines: string[] = []; + for (let line = 0; line < document.lineCount; line += 1) { + lines.push(document.lineAt(line).text); + } + return lines; +} + +/** + * The line index of `[package]` in a neighbouring manifest, for `path = "…"`. + * The file system belongs to the caller, which is why navigation takes a callback. + */ +function packageHeaderLine(uri: vscode.Uri, relative: string): number | undefined { + try { + const target = vscode.Uri.joinPath(uri, "..", relative, "mcpp.toml"); + const bytes = require("node:fs").readFileSync(target.fsPath, "utf8") as string; + const index = bytes.split(/\r?\n/).findIndex((line) => /^\s*\[\s*package\s*\]\s*$/.test(line)); + return index === -1 ? undefined : index; + } catch { + return undefined; + } +} + +function completionItemKind(kind: "section" | "template" | "version"): vscode.CompletionItemKind { + switch (kind) { + case "section": + return vscode.CompletionItemKind.Folder; + case "version": + return vscode.CompletionItemKind.Value; + default: + return vscode.CompletionItemKind.Snippet; + } +} + +function severityOf(value: Severity | undefined, fallback: Severity): Severity { + return value === "error" || value === "warning" || value === "info" || value === "off" ? value : fallback; +} + +export function registerTomlProviders( + context: vscode.ExtensionContext, + /** `mcpp.toml.completion`: structure suggestions on or off. */ + completionEnabled: () => boolean, + settings: () => DiagnosticSettings, + /** `mcpp.toml.diagnostics.enabled`. */ + enabled: () => boolean, + /** `mcpp.toml.hover` and `mcpp.toml.navigation`, which are independent switches. */ + hoverEnabled: () => boolean = () => true, + navigationEnabled: () => boolean = () => true, +): void { + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: MCPP_TOML_LANGUAGE }, + { + async provideCompletionItems(document, position) { + if (!completionEnabled()) { + return undefined; + } + const lines: string[] = []; + for (let line = 0; line <= position.line; line += 1) { + lines.push(document.lineAt(line).text); + } + const suggestions = await computeMcppTomlCompletionsWithIndex( + lines, + position.line, + position.character, + (name) => resolveIndexVersions(document, name), + ); + return suggestions.map((suggestion) => { + const item = new vscode.CompletionItem(suggestion.label, completionItemKind(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; + }); + }, + }, + // `"` so a dependency version value is discoverable without Ctrl+Space; + // every other trigger position falls straight through to no suggestions. + "[", + '"', + ), + ); + + context.subscriptions.push( + vscode.languages.registerHoverProvider({ language: MCPP_TOML_LANGUAGE }, { + provideHover(document, position) { + if (!hoverEnabled()) { + return undefined; + } + const info = hoverAt(linesOf(document), position.line, position.character); + if (info === undefined) { + return undefined; + } + const contents = [new vscode.MarkdownString(`**${info.title}**`), new vscode.MarkdownString(info.body)]; + if (info.documentation !== undefined) { + contents.push(new vscode.MarkdownString(`[${t("Manifest reference")}](${info.documentation})`)); + } + return new vscode.Hover(contents); + }, + }), + vscode.languages.registerDefinitionProvider({ language: MCPP_TOML_LANGUAGE }, { + provideDefinition(document, position) { + if (!navigationEnabled()) { + return undefined; + } + const location = definitionAt(linesOf(document), position.line, position.character, (relative) => + packageHeaderLine(document.uri, relative), + ); + if (location === undefined) { + return undefined; + } + return new vscode.Location(document.uri, new vscode.Position(location.line, location.startCharacter)); + }, + }), + ); + + const collection = vscode.languages.createDiagnosticCollection("mcpp-manifest"); + context.subscriptions.push(collection); + + const refresh = (document: vscode.TextDocument): void => { + if (document.languageId !== MCPP_TOML_LANGUAGE) { + return; + } + if (!enabled()) { + collection.delete(document.uri); + return; + } + const lines: string[] = []; + for (let line = 0; line < document.lineCount; line += 1) { + lines.push(document.lineAt(line).text); + } + const diagnostics = analyseManifest(lines, settings()).map((entry) => { + const range = new vscode.Range( + Math.max(0, entry.line - 1), + entry.startCharacter, + Math.max(0, entry.line - 1), + entry.endCharacter, + ); + const diagnostic = new vscode.Diagnostic(range, entry.message, severityFor(entry.severity)); + diagnostic.code = entry.code; + diagnostic.source = "mcpp"; + return diagnostic; + }); + collection.set(document.uri, diagnostics); + }; + + for (const document of vscode.workspace.textDocuments) { + refresh(document); + } + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(refresh), + vscode.workspace.onDidChangeTextDocument((event) => refresh(event.document)), + vscode.workspace.onDidCloseTextDocument((document) => collection.delete(document.uri)), + vscode.workspace.onDidChangeConfiguration(() => { + for (const document of vscode.workspace.textDocuments) { + refresh(document); + } + }), + ); +} + +function severityFor(severity: "error" | "warning" | "info"): vscode.DiagnosticSeverity { + switch (severity) { + case "error": + return vscode.DiagnosticSeverity.Error; + case "warning": + return vscode.DiagnosticSeverity.Warning; + default: + return vscode.DiagnosticSeverity.Information; + } +} + +/** The registry keys that shape manifest diagnostics, read in one place. */ +export { severityOf }; diff --git a/src/toml/schema.ts b/src/toml/schema.ts new file mode 100644 index 0000000..a27b93d --- /dev/null +++ b/src/toml/schema.ts @@ -0,0 +1,125 @@ +// mcpp.toml 快照的读取层。 +// +// 数据来自 `tools/generate-toml-schema.mjs` 生成的 `data/toml-schema.json` +// (贡献者期从 mcpp 仓库生成,运行期只读快照,不新增依赖)。本模块是纯数据 +// 访问,不依赖 vscode API:补全、悬停、诊断都从这里取段/键/枚举。 +// +// 快照里没有的段不会出现在这里,因此 mcpp 停止接受的段会自动消失——这正是 +// "段列表必须来自 toml.cppm" 的落点。 + +import raw from "../../data/toml-schema.json"; + +export interface TomlKey { + key: string; + type: "string" | "boolean" | "number" | "array" | "enum"; + values?: string[]; + default?: unknown; + since?: string; + legacy?: boolean; + note?: string; + /** 类型是推断而非读出来的:不要据此做严格的类型校验。 */ + unmodelled?: boolean; +} + +export interface TomlSection { + header: string; + name: string; + plane: string; + doc?: string; + deprecatedBy?: string | null; + keys?: TomlKey[]; + /** + * 该段的键由用户自选而非固定词表(如 `[toolchain]` 的平台名与 `bootstrap`), + * 诊断层不对它做未知键检查。生成器只对这类段写 true。 + */ + openKeys?: boolean; +} + +export interface TomlSchema { + sourceVersion: string; + sourceCommit: string; + sections: TomlSection[]; + rules: Array<{ id: string; severity: string }>; +} + +export const SCHEMA: TomlSchema = raw as unknown as TomlSchema; + +/** `[package]` / `package` → `package`;数组表 `[[x]]` 不是段,返回空串。 */ +function normaliseSectionName(headerOrName: string): string { + const text = headerOrName.trim(); + if (text.startsWith("[[")) { + return ""; + } + if (text.startsWith("[") && text.endsWith("]")) { + return text.slice(1, -1).trim(); + } + return text; +} + +/** 按段头(`"[package]"` 或 `"package"`)查找。数组表不匹配任何段。 */ +export function sectionByHeader(header: string): TomlSection | undefined { + const name = normaliseSectionName(header); + return name === "" ? undefined : sectionByName(name); +} + +/** 按段名精确查找。参数化段(`[targets.]`)由调用方用前缀匹配处理。 */ +export function sectionByName(name: string): TomlSection | undefined { + const wanted = normaliseSectionName(name); + if (wanted === "") { + return undefined; + } + return SCHEMA.sections.find((section) => section.name === wanted); +} + +/** 段内某个键的元数据;段不存在、段没有键表或键不在表中都返回 undefined。 */ +export function keyOf(sectionName: string, key: string): TomlKey | undefined { + const section = sectionByName(sectionName); + return section?.keys?.find((entry) => entry.key === key); +} + +/** + * `deprecatedBy` 形如 `[package].standard` / `[package]` / `package.standard`, + * 取出它指向的段名。 + */ +function deprecatedSectionName(reference: string): string { + const text = reference.trim(); + const bracketed = /^\[([^\]]+)\]/.exec(text); + if (bracketed !== null) { + return bracketed[1].split(".")[0].trim(); + } + return text.split(".")[0].trim(); +} + +/** + * 快照自身的结构问题:重复段头、空平面、以及指向不存在段的 deprecatedBy。 + * 生成器保证当前快照为空数组;测试把它当门禁。 + */ +export function sectionProblems(): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const section of SCHEMA.sections) { + if (seen.has(section.header)) { + problems.push(`duplicate section header ${section.header}`); + } + seen.add(section.header); + + if (section.plane.trim() === "") { + problems.push(`section ${section.header} has an empty plane`); + } + + if (section.deprecatedBy) { + const target = deprecatedSectionName(section.deprecatedBy); + if (target === "" || sectionByName(target) === undefined) { + problems.push( + `${section.header} is deprecated by ${section.deprecatedBy}, which names no known section`, + ); + } + } + } + return problems; +} + +/** 快照来自哪个 mcpp 版本与提交(环境自检与诊断输出用)。 */ +export function schemaSource(): { version: string; commit: string } { + return { version: SCHEMA.sourceVersion, commit: SCHEMA.sourceCommit }; +} diff --git a/src/util/format.ts b/src/util/format.ts new file mode 100644 index 0000000..d4a03b9 --- /dev/null +++ b/src/util/format.ts @@ -0,0 +1,153 @@ +/** + * Byte / count formatting for the cache views and the status bar. + * + * Pure: no `vscode`, no i18n. The unit suffixes (B/KiB/MiB/GiB/TiB) are the same + * in every language this extension speaks, so they are not translated; the + * surrounding sentence is (`src/i18n/t.ts`). + */ + +export type NumberFormat = "binary" | "decimal"; + +const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; +const DECIMAL_UNITS = ["B", "kB", "MB", "GB", "TB", "PB"] as const; + +function significant(value: number): string { + if (!Number.isFinite(value)) { + return "0"; + } + const abs = Math.abs(value); + if (abs >= 100) { + return value.toFixed(0); + } + if (abs >= 10) { + return value.toFixed(1); + } + return value.toFixed(2); +} + +/** + * `1500000` -> `"1.43 MiB"` (binary) / `"1.50 MB"` (decimal). + * + * Three significant digits, matching what the plan promises for the cache views: + * big numbers stay readable and small ones keep two decimals. + */ +export function formatBytes(bytes: number, format: NumberFormat = "binary"): string { + const units = format === "decimal" ? DECIMAL_UNITS : BINARY_UNITS; + const step = format === "decimal" ? 1000 : 1024; + let value = Number.isFinite(bytes) ? Math.max(0, bytes) : 0; + let index = 0; + while (value >= step && index < units.length - 1) { + value /= step; + index += 1; + } + return `${significant(value)} ${units[index]}`; +} + +/** `12345` -> `"12,345"`. Grouping follows the host locale. */ +export function formatCount(value: number): string { + return Number.isFinite(value) ? Math.round(value).toLocaleString() : "0"; +} + +/** Seconds since the Unix epoch -> `Date`, or `undefined` for a non-finite input. */ +export function fromUnixSeconds(seconds: number | undefined): Date | undefined { + if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) { + return undefined; + } + return new Date(seconds * 1000); +} + +/** + * Numbers of whole days between two instants, floored at 0. + * Used by the cache age buckets: `<1d` / `1–7d` / `7–30d` / `>30d`. + */ +export function ageInDays(at: Date, now: Date): number { + const delta = now.getTime() - at.getTime(); + return delta <= 0 ? 0 : Math.floor(delta / 86_400_000); +} + +/** + * Bucket boundaries in days, ascending, from settings such as + * `["1d", "7d", "30d"]`. Unparsable entries are dropped; the result is sorted + * and de-duplicated so a hand-edited setting cannot produce overlapping buckets. + */ +export function parseAgeBuckets(values: readonly string[] | undefined): number[] { + const parsed = new Set(); + for (const raw of values ?? []) { + const match = /^\s*(\d+)\s*([dhwm]?)\s*$/.exec(raw); + if (match === null) { + continue; + } + const amount = Number.parseInt(match[1], 10); + const unit = match[2]; + const days = unit === "h" ? amount / 24 : unit === "w" ? amount * 7 : unit === "m" ? amount * 30 : amount; + const rounded = Math.max(0, Math.round(days)); + parsed.add(rounded); + } + if (parsed.size === 0) { + return [1, 7, 30]; + } + return [...parsed].sort((a, b) => a - b); +} + +/** Which bucket index `days` falls in; `boundaries.length` is the last (oldest) bucket. */ +export function bucketIndex(days: number, boundaries: readonly number[]): number { + for (let index = 0; index < boundaries.length; index += 1) { + if (days < boundaries[index]) { + return index; + } + } + return boundaries.length; +} + +export interface CacheEntrySize { + bytes: number; + accessed?: number; + complete?: boolean; +} + +export interface CacheProjection { + /** Entries removed, in LRU order, to reach the budget. */ + removed: CacheEntrySize[]; + kept: CacheEntrySize[]; + freedBytes: number; + remainingBytes: number; +} + +/** + * Simulate `mcpp cache gc --max-size `: drop least-recently-used entries + * until the total fits the budget. Entries without a usable `accessed` timestamp + * are treated as the most recent, so a projection never promises to free + * something mcpp would keep. + * + * This is an **estimate** — mcpp's own LRU also weighs entry completeness and + * its own bookkeeping, so the figure is offered as a preview only. + */ +export function projectGc(entries: readonly CacheEntrySize[], budgetBytes: number): CacheProjection { + const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.bytes), 0); + if (budgetBytes >= total) { + return { removed: [], kept: [...entries], freedBytes: 0, remainingBytes: total }; + } + const ranked = entries + .map((entry, index) => ({ entry, index })) + .sort((a, b) => { + const left = a.entry.accessed ?? Number.MAX_SAFE_INTEGER; + const right = b.entry.accessed ?? Number.MAX_SAFE_INTEGER; + return left === right ? a.index - b.index : left - right; + }); + const removed: CacheEntrySize[] = []; + let remaining = total; + for (const { entry } of ranked) { + if (remaining <= budgetBytes) { + break; + } + removed.push(entry); + remaining -= Math.max(0, entry.bytes); + } + const removedSet = new Set(removed); + return { + removed, + kept: entries.filter((entry) => !removedSet.has(entry)), + freedBytes: total - remaining, + remainingBytes: remaining, + }; +} diff --git a/src/util/log.ts b/src/util/log.ts new file mode 100644 index 0000000..2119e4d --- /dev/null +++ b/src/util/log.ts @@ -0,0 +1,82 @@ +/** + * One place that decides whether a line reaches the `mcpp` output channel. + * + * `mcpp.log.level` is a **threshold**, not a filter list: `error` writes only + * errors, `warn` adds warnings, `info` is the declared default, and `debug` + * writes everything. Two rules keep the channel honest: + * + * - an error is never suppressed by any configured level — that is why a failed + * command's raw stdout/stderr is written at `error` by its caller; + * - an unknown configured value degrades to the default instead of silencing + * the channel, and an unknown line level is treated as the most verbose thing + * it could be. + * + * Pure and `vscode`-free: `createLogger` takes any `appendLine`-shaped sink and + * a level callback, so the policy can be asserted without an editor. + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const ORDER: Readonly> = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const DEFAULT_LEVEL: LogLevel = "info"; + +export function isLogLevel(value: unknown): value is LogLevel { + return value === "error" || value === "warn" || value === "info" || value === "debug"; +} + +/** Coerce a configured value: anything unknown becomes the declared default. */ +export function logLevelOf(value: unknown): LogLevel { + return isLogLevel(value) ? value : DEFAULT_LEVEL; +} + +/** + * True when a line at `level` should be written while `configured` is the + * effective `mcpp.log.level`. An error passes at every level. + */ +export function shouldLog(level: LogLevel, configured: unknown): boolean { + const threshold = ORDER[logLevelOf(configured)]; + const wanted = ORDER[isLogLevel(level) ? level : "debug"]; + return wanted <= threshold; +} + +/** The part of an `OutputChannel` this module needs. */ +export interface LogChannel { + appendLine(line: string): unknown; +} + +export interface Logger { + error(line: string): void; + warn(line: string): void; + info(line: string): void; + debug(line: string): void; +} + +/** + * A levelled writer over an output channel. The level is read per line, so a + * configuration change applies to the next line without re-creating the logger. + * A channel that has already been disposed is a no-op, never a crash. + */ +export function createLogger(channel: LogChannel, levelOf: () => unknown): Logger { + const write = (level: LogLevel, line: string): void => { + if (!shouldLog(level, levelOf())) { + return; + } + try { + channel.appendLine(line); + } catch { + // The output channel can be released during a window reload or shutdown. + } + }; + return { + error: (line) => write("error", line), + warn: (line) => write("warn", line), + info: (line) => write("info", line), + debug: (line) => write("debug", line), + }; +} diff --git a/src/util/text.ts b/src/util/text.ts new file mode 100644 index 0000000..ec3c0ae --- /dev/null +++ b/src/util/text.ts @@ -0,0 +1,79 @@ +/** + * Small text helpers shared by the CLI adapter and the views. Pure: no `vscode`. + */ + +/** `mcpp` colourises its status row; anything captured from a terminal may carry ANSI. */ +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE, ""); +} + +export interface ClampedText { + text: string; + truncated: boolean; + /** Lines dropped from the front, so a caller can say "… N earlier lines". */ + droppedLines: number; +} + +export interface ClampOptions { + maxLines?: number; + maxChars?: number; +} + +/** + * Keep the **tail** of a long output and say so. Command output that matters + * (a failure, a list of stale directories) is at the end; the head is status + * narration. The full text is expected to be in the `mcpp` output channel. + */ +export function clampOutput(input: string, options: ClampOptions = {}): ClampedText { + const maxLines = options.maxLines ?? 400; + const maxChars = options.maxChars ?? 64 * 1024; + const normalised = stripAnsi(input).replace(/\r\n/g, "\n"); + const lines = normalised.split("\n"); + let truncated = false; + let droppedLines = 0; + let kept = lines; + if (lines.length > maxLines) { + droppedLines = lines.length - maxLines; + kept = lines.slice(droppedLines); + truncated = true; + } + let text = kept.join("\n"); + if (text.length > maxChars) { + text = text.slice(text.length - maxChars); + truncated = true; + } + return { text, truncated, droppedLines }; +} + +/** First line of a multi-line string, trimmed; used for one-line tree tooltips. */ +export function firstLine(text: string): string { + const index = text.indexOf("\n"); + return (index === -1 ? text : text.slice(0, index)).trim(); +} + +/** Escape a string for use as a `markdown` tooltip / webview text node. */ +export function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** Split a comma-separated setting value (`"mcpp,cmake"`) into trimmed entries. */ +export function splitList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** `["-j","4"]` -> `"-j 4"`, quoting entries that contain whitespace. */ +export function formatArguments(args: readonly string[]): string { + return args + .map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg)) + .join(" "); +} diff --git a/src/views/languageServerView.ts b/src/views/languageServerView.ts new file mode 100644 index 0000000..1ac83f3 --- /dev/null +++ b/src/views/languageServerView.ts @@ -0,0 +1,351 @@ +/** + * The C++ Modules language service, as commands plus one state source. + * + * There is no view of its own any more: the four things worth seeing — state, + * provider, problems, actions — are folded into the project view's 「基本信息」 + * section, and this file feeds that block. Every button still forwards an mcppls + * command; this extension never reimplements language-service behaviour and never + * writes an `mcppls.*` setting itself. The block states its provider on its own + * line, so "who owns what" stays answerable after the fold. + * + * Confirmation policy comes from the capability table (`danger` + `confirmHint`), + * so a new dangerous action cannot be added without saying what it does. + * `mcpp.languageService.confirmResetCache` can add one more explicit step to the + * reset-cache action; it can never take the capability's own modal away, and + * `./viewPolicy.ts` is where that rule lives. + * + * Three settings also own the block's *timing*: `mcpp.languageService.readState` + * decides whether state is read at all, `…stateRefreshSeconds` whether it is + * re-read on an interval, and `…notifyOnDegraded` whether the one-time + * missing-dependency line is written. `mcpp.views.languageServer.show` decides + * whether the block appears in the project view at all. + */ + +import { existsSync } from "node:fs"; + +import * as vscode from "vscode"; + +import { LANGUAGE_SERVER_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "../commands/ids"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import type { LanguageServerBridge } from "../mcppls/bridge"; +import { capability } from "../mcppls/contract"; +import { confirmationFor, formatResult, type FormattedResult } from "../mcppls/messages"; +import { + languageServerEnabled, + languageServerInstalled, + languageServerVersion, + readLanguageServerState, + readOtherCppFeatures, +} from "../mcppls/stateSource"; +import { OTHER_CPP_SETTINGS } from "../mcppls/otherCppFeatures"; +import { PollTimer } from "../mcppls/timers"; +import { refreshTimerDecision } from "../cache/cacheState"; +import type { LanguageServiceBlock } from "./models"; +import { degradedNoticeEnabled, resetCacheConfirmation } from "./viewPolicy"; + +/** The tick granularity; the settings are in seconds. */ +const REFRESH_TICK_MS = 100; + +export interface LanguageServerViewDeps { + bridge: LanguageServerBridge; + output: vscode.OutputChannel; +} + +/** + * The folded block's data source, handed to the project view. + * + * `input()` reads; `onDidChange` tells the project view when to rebuild; and + * `setVisible` is how the poller learns whether the project view is on screen at + * all. Keeping the timer here rather than in the project view means the setting + * that shapes it (`mcpp.languageService.stateRefreshSeconds`) is read next to the + * action it governs. + */ +export interface LanguageServerBlockSource { + /** The block's current input, or `undefined` while `mcpp.views.languageServer.show` is off. */ + input(): LanguageServiceBlock | undefined; + /** Fires whenever the state, or the setting that shows it, may have changed. */ + onDidChange(listener: () => void): vscode.Disposable; + /** The project view says whether its tree is visible; the poller follows it. */ + setVisible(visible: boolean): void; +} + +/** Ask, run, report — the same shape for every forwarded command. */ +export async function runLanguageServerCommand( + bridge: LanguageServerBridge, + output: vscode.OutputChannel, + key: string, + options: { args?: unknown[]; value?: unknown; extraConfirmation?: string } = {}, +): Promise<{ message: string; value?: unknown } | undefined> { + const confirmation = confirmationFor(key, options.value); + if (confirmation !== undefined) { + const run = t("Run"); + const choice = await vscode.window.showWarningMessage(confirmation, { modal: true }, run); + if (choice !== run) { + return undefined; + } + } + // A setting may ask once more; it never replaces the capability's own modal. + if (options.extraConfirmation !== undefined) { + const run = t("Run"); + const choice = await vscode.window.showWarningMessage( + options.extraConfirmation, + { modal: true }, + run, + ); + if (choice !== run) { + return undefined; + } + } + const result = await bridge.invoke(key, ...(options.args ?? [])); + const formatted: FormattedResult = formatResult(result); + try { + output.appendLine(`[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); + } catch { + // The channel can be gone during shutdown. + } + if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); + } else if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } + return result.value === undefined + ? { message: formatted.message } + : { message: formatted.message, value: result.value }; +} + +/** + * The extra confirmation `mcpp.languageService.confirmResetCache` adds, or + * `undefined` when the capability's own `danger` level should be the only one. + * `bridge.dangerOf` is the capability table's answer, never a copy of it. + * + * The text is the capability's own `confirmHint`, so the second modal repeats + * exactly what the reset does — the setting buys a second explicit "yes", not a + * second explanation. It is resolved at render time, like every other capability + * string (`src/mcppls/messages.ts`). + */ +export function extraResetCacheConfirmation( + bridge: LanguageServerBridge, + confirmResetCache: boolean, +): string | undefined { + const decision = resetCacheConfirmation({ + danger: bridge.dangerOf("resetCache"), + confirmResetCache, + }); + return decision === "extra-modal" ? capability("resetCache")?.confirmHint : undefined; +} + +/** + * Registers every `mcpp.languageServer.*` command (and the forwarded mcppls + * actions behind them) and returns the state source the project view renders. + */ +export function registerLanguageServerCommands( + context: vscode.ExtensionContext, + deps: LanguageServerViewDeps, +): LanguageServerBlockSource { + const changed = new vscode.EventEmitter(); + context.subscriptions.push(changed); + + // Whether the project view's tree is on screen. The timer below follows it, so + // a collapsed sidebar does not keep asking mcppls for state. + let visible = false; + const refresh = (): void => { + changed.fire(); + }; + + const input = (): LanguageServiceBlock | undefined => { + // Literal key, so the wiring gate can see it. + if (read("mcpp.views.languageServer.show") === false) { + return undefined; + } + return { + show: true, + installed: languageServerInstalled(), + enabled: languageServerEnabled(), + version: languageServerVersion(), + state: readLanguageServerState(), + otherCppFeatures: languageServerInstalled() ? readOtherCppFeatures() : undefined, + }; + }; + + // `mcpp.languageService.stateRefreshSeconds`, but only while the project view is + // visible and only while state is read at all (0 = off). + const timer = new PollTimer({ + periodMs: 0, + tick: (): void => { + if (visible) { + refresh(); + } + }, + }); + context.subscriptions.push(timer); + const applyTimer = (): void => { + const disabled = read("mcpp.languageService.readState") === false; + const seconds = read("mcpp.languageService.stateRefreshSeconds"); + const usable = !disabled && Number.isFinite(seconds) && seconds > 0; + const decision = refreshTimerDecision(usable ? seconds : 0, visible); + timer.start(decision.active ? decision.seconds * 1000 : 0); + }; + applyTimer(); + + const register = (id: string, handler: (...args: unknown[]) => Promise): void => { + context.subscriptions.push( + vscode.commands.registerCommand(id, async (...args: unknown[]) => { + try { + await handler(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.output.appendLine(t("C++ Modules command failed: {0}", message)); + void vscode.window.showErrorMessage(t("{0} failed: {1}", id, message)); + } + }), + ); + }; + + const forward = (id: string, key: string, args?: () => unknown[]): void => { + register(id, async () => { + const options: { args?: unknown[]; extraConfirmation?: string } = + args === undefined ? {} : { args: args() }; + if (key === "resetCache") { + const extra = extraResetCacheConfirmation( + deps.bridge, + read("mcpp.languageService.confirmResetCache"), + ); + if (extra !== undefined) { + options.extraConfirmation = extra; + } + } + await runLanguageServerCommand(deps.bridge, deps.output, key, options); + refresh(); + }); + }; + + register(LANGUAGE_SERVER_COMMANDS.refreshState, async () => { + refresh(); + }); + + /** + * Where the last bundle was written. + * + * `mcppls.exportDiagnosticBundle` resolves to the zip it wrote, and that path + * is the only reliable answer to "where is it?" — the directory is + * `/bundles` on the server's own terms, which this extension + * must not guess at. Remembered machine-wide, because the bundle directory is + * machine-wide too. + */ + const BUNDLE_PATH_KEY = "mcpp.languageServer.lastDiagnosticBundle"; + const bundleFrom = (value: unknown): string | undefined => { + const path = value instanceof vscode.Uri ? value.fsPath : typeof value === "string" ? value : undefined; + return path !== undefined && path.length > 0 ? path : undefined; + }; + + register(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle, async () => { + const result = await runLanguageServerCommand(deps.bridge, deps.output, "diagnosticBundle"); + const bundle = bundleFrom(result?.value); + if (bundle !== undefined) { + await context.globalState.update(BUNDLE_PATH_KEY, bundle); + } + refresh(); + }); + + register(LANGUAGE_SERVER_COMMANDS.revealBundle, async () => { + const remembered = context.globalState.get(BUNDLE_PATH_KEY); + if (remembered === undefined || !existsSync(remembered)) { + const exportNow = t("Capture logs now"); + const choice = await vscode.window.showInformationMessage( + t("No diagnostic bundle has been written yet. Capturing the logs writes one zip with the report, the environment and the recent logs."), + exportNow, + ); + if (choice === exportNow) { + await vscode.commands.executeCommand(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle); + } + return; + } + await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(remembered)); + }); + + register(LANGUAGE_SERVER_COMMANDS.openLogFolder, async () => { + // Upstream owns the path: `revealCacheDirectory('logs')` asks the server's own + // detail for `paths.logDirectory` and reveals it, falling back to the output + // channel when the server cannot answer. Nothing here reconstructs a path. + await runLanguageServerCommand(deps.bridge, deps.output, "logsDirectory", { args: ["logs"] }); + }); + + forward(LANGUAGE_SERVER_COMMANDS.restart, "restartServer"); + forward(LANGUAGE_SERVER_COMMANDS.restartEngine, "restartEngine"); + forward(LANGUAGE_SERVER_COMMANDS.resetWorkspaceCache, "resetCache"); + forward(LANGUAGE_SERVER_COMMANDS.selectContext, "selectContext"); + forward(LANGUAGE_SERVER_COMMANDS.showModuleGraph, "moduleGraph"); + forward(LANGUAGE_SERVER_COMMANDS.showLogs, "logs"); + forward(LANGUAGE_SERVER_COMMANDS.collectReport, "report"); + // `exportDiagnosticBundle` is registered above rather than forwarded: its + // answer is the zip's path, and the caller has to keep it. + forward(LANGUAGE_SERVER_COMMANDS.runBuildToolInTerminal, "runBuildTool"); + forward(LANGUAGE_SERVER_COMMANDS.installTools, "installTools"); + forward(LANGUAGE_SERVER_COMMANDS.manageConflicts, "turnOffOtherCppFeatures"); + forward(LANGUAGE_SERVER_COMMANDS.restoreConflicts, "restoreOtherCppFeatures"); + forward(LANGUAGE_SERVER_COMMANDS.reviewChanges, "review", () => [false]); + forward(LANGUAGE_SERVER_COMMANDS.toggleInWorkspace, "toggleInWorkspace", () => [!languageServerEnabled()]); + + // The legacy ids from 0.4.x keep working, pointing at the new actions. + register(LEGACY_LANGUAGE_SERVER_COMMANDS.configureLanguageServer, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "selectContext"); + }); + register("mcpp.configureClangd", async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "selectContext"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.checkModuleSupport, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "restartServer"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.showModuleGraph, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "moduleGraph"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.showLanguageServerLogs, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "logs"); + }); + + register(TOOL_COMMANDS.openLanguageServerSettings, async () => { + await vscode.commands.executeCommand("workbench.action.openSettings", "@ext:sunrisepeak.mcpp-language-server"); + }); + + context.subscriptions.push( + vscode.extensions.onDidChange(() => refresh()), + vscode.workspace.onDidChangeConfiguration((event) => { + if ( + event.affectsConfiguration("mcppls.enable") || + event.affectsConfiguration("mcpp.views.languageServer") || + event.affectsConfiguration("mcpp.languageService") || + // Another C++ service turned on or off, by mcppls's command or by hand. + OTHER_CPP_SETTINGS.some((setting) => event.affectsConfiguration(setting)) + ) { + refresh(); + applyTimer(); + } + }), + ); + + // A capability that has gone missing is worth one sentence, once — unless + // `mcpp.languageService.notifyOnDegraded` says the user does not want it. + if (degradedNoticeEnabled(read("mcpp.languageService.notifyOnDegraded")) && !languageServerInstalled()) { + deps.output.appendLine( + t("The C++ Modules extension ({0}) is not installed or is disabled.", capability("refresh")?.key ?? ""), + ); + } + + return { + input, + onDidChange: (listener: () => void): vscode.Disposable => changed.event(listener), + setVisible: (next: boolean): void => { + if (visible === next) { + return; + } + visible = next; + applyTimer(); + // Becoming visible is exactly when a stale block must be re-read. + if (visible) { + refresh(); + } + }, + }; +} + diff --git a/src/views/models.ts b/src/views/models.ts new file mode 100644 index 0000000..24040e2 --- /dev/null +++ b/src/views/models.ts @@ -0,0 +1,830 @@ +/** + * The trees, as **data**. + * + * Labels are keys, not sentences: the builder stays free of `vscode` and of the + * current language, the tests assert stable keys, and `src/views/treeProvider.ts` + * resolves them through `src/i18n/t.ts` at render time. A label's arguments may + * themselves be labels, so a phrase like `C++23 · 87 source file(s)` is composed + * from two independently translatable pieces instead of one frozen sentence. + * + * The project view has **Basics** (what this project is), folded direct dependencies + * and **Common commands** (what can be done to it). The C++ Modules block is + * folded into the first one, with the status line carrying the problem count, so + * a degraded language service is visible **without expanding anything**. The + * commands section is the only place in the view whose rows are all commands, and + * it is the one that starts open: the sidebar should open on what can be done, + * with 「基本信息」 one click away. + * + * Everything the project view needs from disk lives here as well — the declared + * dependencies, `mcpp.lock`'s resolved versions and the source-file count — so + * the whole layout can be unit tested without an editor host. Nothing here + * imports `vscode`; `test/architecture.test.ts` enforces that. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { readProjectDeclarations } from "../library/projectState"; + +export type LabelArgument = string | number | Label; + +export interface Label { + key: string; + args?: readonly LabelArgument[]; +} + +export interface TreeCommand { + command: string; + title: Label; + arguments?: readonly unknown[]; +} + +export interface TreeNode { + id: string; + label: Label; + description?: Label; + tooltip?: Label; + /** A codicon id without the `$()`, e.g. `"database"`. */ + icon?: string; + /** + * A `ThemeColor` id the icon is drawn in, e.g. `"charts.blue"`. + * + * Only ids the colour registry always carries are used (`charts.*`, + * `disabledForeground`), and a theme that omits one leaves the icon in the + * normal foreground colour rather than making the row unreadable — colour is a hint here, never the state. + */ + iconColor?: string; + /** Consumed by `when` clauses in package.json for inline actions. */ + contextValue?: string; + /** + * Render expanded on first display. A node with children is collapsed unless + * this is set. 「常用命令」 starts open because it is the part of the view that + * gets used; 「基本信息」 starts closed, so the sidebar opens on what can be + * *done* rather than on what the project happens to be. + */ + expanded?: boolean; + command?: TreeCommand; + children?: readonly TreeNode[]; +} + +const plain = (text: string): Label => ({ key: text }); + +/** `a`, `b` -> `a · b`, nested so each side keeps its own translation. */ +function joined(parts: readonly Label[]): Label | undefined { + if (parts.length === 0) { + return undefined; + } + return parts.reduce((left, right): Label => ({ key: "{0} · {1}", args: [left, right] })); +} + +export interface TargetSummary { + name: string; + kind: string; +} + +/** One dependency declared in `mcpp.toml`, before `mcpp.lock` says what it resolved to. */ +export interface DependencyDeclaration { + kind?: "normal" | "dev" | "build"; + conditional?: boolean; + /** `namespace.name` as written in the manifest. */ + name: string; + /** The declared version constraint, when the manifest states one. */ + version?: string; + /** Declared in `[dev-dependencies]`. */ + dev?: boolean; + /** A local path dependency. */ + path?: string; + /** A git dependency. */ + git?: string; +} + +export interface ProjectSummary { + root: string; + name?: string; + version?: string; + standard?: string; + profile?: string; + toolchainSpec?: string; + target?: string; + targets?: readonly TargetSummary[]; + hasTests?: boolean; + /** Declared in `[dependencies]` / `[dev-dependencies]`; absent when there are none. */ + dependencies?: readonly DependencyDeclaration[]; + /** Source files counted under the project root; absent when not measured. */ + sourceFiles?: number; + /** Set when `mcpp.toml` could not be read; the tree then says so instead of lying. */ + error?: string; +} + +/* ------------------------------------------------------------------ mcpp.toml */ + +/** Direct declarations only; share the namespace-aware parser with Libraries and the graph. */ +export function readDependencies(lines: readonly string[]): DependencyDeclaration[] { + return readProjectDeclarations(lines.join("\n")).map((entry) => ({ + name: entry.id, + ...(entry.version ? { version: entry.version } : {}), + ...(entry.dev ? { dev: true } : {}), + ...(entry.kind === "build" ? { kind: "build" as const } : {}), + ...(entry.conditional ? { conditional: true } : {}), + ...(entry.path ? { path: entry.path } : {}), + ...(entry.git ? { git: entry.git } : {}), + })); +} + +/* ------------------------------------------------------------------ mcpp.lock */ + +/** One `[package."…"]` entry of `mcpp.lock`: a resolved package, with no parent edge. */ +export interface LockPackage { + /** The table key as written: `openkal` or `compat.freetype`. */ + key: string; + namespace?: string; + version?: string; + source?: string; + hash?: string; +} + +const LOCK_HEADER = /^\[\s*package\s*\.\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\s*\]\s*$/; +const LOCK_FIELD = /^([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^#\s]+))\s*(?:#.*)?$/; +const LOCK_FIELDS: ReadonlySet = new Set(["namespace", "version", "source", "hash"]); + +/** + * `mcpp.lock`, read tolerantly. + * + * The file is a flat TOML set — `version = 2` then one `[package."…"]` table per + * resolved package — and it deliberately carries **no parent/child edges**, + * which is why the view can only ever show two levels. Anything unrecognised is + * skipped and nothing here throws: a file being written is still a valid lock + * with fewer entries, not a broken view. + */ +export function parseLockfile(text: string): LockPackage[] { + const packages: LockPackage[] = []; + try { + let current: LockPackage | undefined; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.length === 0 || line.startsWith("#")) { + continue; + } + const header = LOCK_HEADER.exec(line); + if (header !== null) { + const key = header[1] ?? header[2] ?? header[3] ?? ""; + if (key.length === 0) { + current = undefined; + continue; + } + current = { key }; + packages.push(current); + continue; + } + if (line.startsWith("[")) { + current = undefined; + continue; + } + if (current === undefined) { + continue; + } + const field = LOCK_FIELD.exec(line); + if (field === null || !LOCK_FIELDS.has(field[1])) { + continue; + } + const value = field[2] ?? field[3] ?? field[4]; + if (value === undefined || value.length === 0) { + continue; + } + if (field[1] === "namespace") current.namespace = value; + else if (field[1] === "version") current.version = value; + else if (field[1] === "source") current.source = value; + else current.hash = value; + } + } catch { + // Tolerant by construction: an unreadable lock is an empty lock. + } + return packages; +} + +/** {@link parseLockfile} for a path that may not exist. A missing lock is empty, not an error. */ +export function readLockfile(file: string): LockPackage[] { + try { + return parseLockfile(readFileSync(file, "utf8")); + } catch { + return []; + } +} + +/** + * The lock entry a declaration resolved to, matched by `namespace.name`. + * + * Real lock files are inconsistent about the table key: `[package."openkal"]` + * carries `namespace = "mcpplibs"` while `[package."compat.freetype"]` already + * folds the namespace into the key. Both resolve here; when the entry has no + * namespace of its own, the name is the only thing left to match on. + */ +export function resolveLockedPackage(entries: readonly LockPackage[], name: string): LockPackage | undefined { + const dot = name.lastIndexOf("."); + const namespace = dot > 0 ? name.slice(0, dot) : undefined; + const local = dot > 0 ? name.slice(dot + 1) : name; + for (const entry of entries) { + if (entry.key === name) { + return entry; + } + const entryLocal = entry.key.includes(".") ? entry.key.slice(entry.key.lastIndexOf(".") + 1) : entry.key; + if (entryLocal !== local) { + continue; + } + if (namespace === undefined || entry.namespace === undefined || entry.namespace === namespace) { + return entry; + } + } + return undefined; +} + +/* --------------------------------------------------------------- source files */ + +/** Extensions counted as source: module interfaces, translation units and headers. */ +const SOURCE_EXTENSIONS: ReadonlySet = new Set([ + ".c", + ".cc", + ".cpp", + ".cxx", + ".c++", + ".cppm", + ".ixx", + ".mpp", + ".h", + ".hh", + ".hpp", + ".hxx", + ".ipp", + ".inl", +]); + +/** Directories that hold build output or other people's code, never the project's own sources. */ +const SOURCE_SKIP_DIRS: ReadonlySet = new Set([ + "target", + "build", + "out", + "dist", + "node_modules", + ".git", + ".mcpp", + ".vscode", + ".cache", +]); + +const SOURCE_MAX_ENTRIES = 20_000; +const SOURCE_MAX_DEPTH = 8; + +/** + * How many source files this project has, counted under `root`. + * + * Bounded and non-throwing, like every other walk in this codebase: a huge or + * unreadable tree stops early rather than hanging the extension host, and the + * build directories are skipped so `target/` cannot flatter the number. The + * count is what makes the identity row say something about the project's size + * without running mcpp. + */ +export function countSourceFiles(root: string): number { + let count = 0; + let seen = 0; + const visit = (dir: string, depth: number): void => { + if (depth > SOURCE_MAX_DEPTH || seen >= SOURCE_MAX_ENTRIES) { + return; + } + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + seen += 1; + if (seen > SOURCE_MAX_ENTRIES) { + return; + } + if (entry.isSymbolicLink()) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SOURCE_SKIP_DIRS.has(entry.name)) { + visit(full, depth + 1); + } + } else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + count += 1; + } + } + }; + visit(root, 0); + return count; +} + +/* ---------------------------------------------------------------- keybindings */ + +export interface KeybindingHint { + key?: string; + mac?: string; +} + +export type KeyboardPlatform = "mac" | "other"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The keybindings this extension contributes, keyed by command id, read from + * `package.json` rather than copied into the source. + * + * A hint in the tree that disagrees with the manifest is worse than no hint, so + * the row asks the manifest. `context.extension.packageJSON` is what the caller + * hands in; this function itself is pure and testable. + */ +export function keybindingsFromPackage(packageJSON: unknown): Readonly> { + const bindings: Record = {}; + if (!isRecord(packageJSON) || !isRecord(packageJSON.contributes)) { + return bindings; + } + const list = packageJSON.contributes.keybindings; + if (!Array.isArray(list)) { + return bindings; + } + for (const entry of list) { + if (!isRecord(entry) || typeof entry.command !== "string" || bindings[entry.command] !== undefined) { + continue; + } + const key = typeof entry.key === "string" && entry.key.length > 0 ? entry.key : undefined; + const mac = typeof entry.mac === "string" && entry.mac.length > 0 ? entry.mac : undefined; + if (key === undefined && mac === undefined) { + continue; + } + const hint: KeybindingHint = {}; + if (key !== undefined) hint.key = key; + if (mac !== undefined) hint.mac = mac; + bindings[entry.command] = hint; + } + return bindings; +} + +function keyToken(token: string, platform: KeyboardPlatform): string { + if (platform === "mac") { + switch (token) { + case "cmd": + case "meta": + case "super": + return "⌘"; + case "ctrl": + case "control": + return "⌃"; + case "alt": + case "option": + return "⌥"; + case "shift": + return "⇧"; + default: + return token.length === 1 ? token.toUpperCase() : token; + } + } + switch (token) { + case "cmd": + case "meta": + case "super": + return "Meta"; + case "ctrl": + case "control": + return "Ctrl"; + case "alt": + case "option": + return "Alt"; + case "shift": + return "Shift"; + default: + return token.length === 1 ? token.toUpperCase() : token; + } +} + +/** `cmd+alt+b` -> `⌘⌥B` on macOS, `ctrl+alt+b` -> `Ctrl+Alt+B` elsewhere. */ +export function formatKeybinding(hint: KeybindingHint | undefined, platform: KeyboardPlatform): string | undefined { + if (hint === undefined) { + return undefined; + } + const raw = platform === "mac" ? hint.mac ?? hint.key : hint.key ?? hint.mac; + if (raw === undefined || raw.trim().length === 0) { + return undefined; + } + const parts = raw + .split("+") + .map((part) => keyToken(part.trim().toLowerCase(), platform)) + .filter((part) => part.length > 0); + if (parts.length === 0) { + return undefined; + } + return platform === "mac" ? parts.join("") : parts.join("+"); +} + +/* ------------------------------------------------------------ language service */ + +/** The part of an mcppls issue the folded block renders. `clangd` is never among them. */ +export interface LanguageServiceIssue { + code: string; + message: string; + /** mcppls's own remedy for this issue, when it offers one. */ + command?: { command: string; arguments?: unknown[]; title?: string }; +} + +/** + * The folded C++ Modules block. + * + * Only the language service's own identity, its state and its problems are + * rendered: the engines, the semantic profile and the compilation database used + * to *define* the old view, and the spec is explicit that `clangd` must not + * appear anywhere in it (§8.2). + */ +export interface LanguageServiceBlock { + /** `mcpp.views.languageServer.show`; `false` removes the block from the tree. */ + show?: boolean; + installed: boolean; + enabled?: boolean; + version?: string; + state?: { + available: boolean; + reason?: string; + state?: string; + issues?: readonly LanguageServiceIssue[]; + }; + /** Other C++ language services serving next to it (C/C++, clangd, ccls). */ + otherCppFeatures?: { + active: readonly { extensionId: string; displayName: string; settable: boolean }[]; + canRestore: boolean; + }; +} + +/** The provider line the block always carries, so "who owns this" stays answerable. */ +export const LANGUAGE_SERVICE_PROVIDER = "sunrisepeak.mcpp-language-server"; + +interface ActionRow { + id: string; + label: string; + icon: string; + command: string; +} + +/** The four actions worth a permanent row; everything else folds into 「其它 N 项…」. */ +const LANGUAGE_SERVICE_COMMON_ACTIONS: readonly ActionRow[] = [ + { id: "project.languageService.action.restart", label: "Restart language service", icon: "debug-restart", command: "mcpp.languageServer.restart" }, + { id: "project.languageService.action.selectContext", label: "Select analysis context", icon: "symbol-interface", command: "mcpp.languageServer.selectContext" }, + { id: "project.languageService.action.graph", label: "Module graph", icon: "type-hierarchy", command: "mcpp.languageServer.showModuleGraph" }, + { id: "project.languageService.action.logs", label: "Open logs", icon: "output", command: "mcpp.languageServer.showLogs" }, +]; + +const LANGUAGE_SERVICE_MORE_ACTIONS: readonly ActionRow[] = [ + { id: "project.languageService.action.refreshState", label: "Refresh this view", icon: "refresh", command: "mcpp.languageServer.refreshState" }, + { id: "project.languageService.action.restartEngine", label: "Restart the semantic engine", icon: "debug-restart", command: "mcpp.languageServer.restartEngine" }, + { id: "project.languageService.action.resetCache", label: "Reset this workspace's cache", icon: "trash", command: "mcpp.languageServer.resetWorkspaceCache" }, + { id: "project.languageService.action.report", label: "Collect a diagnostic report", icon: "report", command: "mcpp.languageServer.collectReport" }, + { id: "project.languageService.action.bundle", label: "Export a diagnostic bundle", icon: "file-zip", command: "mcpp.languageServer.exportDiagnosticBundle" }, + { id: "project.languageService.action.runBuildTool", label: "Run the build tool in a terminal", icon: "terminal", command: "mcpp.languageServer.runBuildToolInTerminal" }, + { id: "project.languageService.action.settings", label: "Open the C++ Modules settings", icon: "settings-gear", command: "mcpp.openMcpplsSettings" }, +]; + +/* -------------------------------------------------------------- project view */ + +export interface ProjectTreeOptions { + /** `mcpp.lock`'s resolved packages, matched to the declarations by name. */ + lock?: readonly LockPackage[]; + /** The folded C++ Modules block; omitted means "no block". */ + languageService?: LanguageServiceBlock; + /** Keybindings read from the extension manifest. */ + keybindings?: Readonly>; + platform?: KeyboardPlatform; +} + +/** Project facts, folded direct dependencies, then common actions. */ +export function buildProjectTree(project: ProjectSummary | undefined, options: ProjectTreeOptions = {}): TreeNode[] { + if (project === undefined) { + // An empty workspace gets the one thing that can be done in it, next to the + // sentence that says why the view is otherwise empty. 「初始化当前目录」 is + // deliberately absent: `mcpp new` refuses a destination that already exists + // (`scaffold/create.cppm`: `"'{}' already exists"`) and has no `--here`, so + // there is nothing to run for "make this folder a project" — see §17 of + // `.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md`. + return [ + { + id: "project.none", + label: plain("No mcpp project in this workspace"), + icon: "info", + tooltip: plain("Open a folder containing mcpp.toml, or create a project with mcpp: New Project."), + }, + newProjectNode(), + ]; + } + if (project.error !== undefined) { + return [ + { + id: "project.error", + label: plain("mcpp.toml could not be read"), + description: { key: "{0}", args: [project.error] }, + icon: "error", + contextValue: "mcppProjectError", + }, + ]; + } + + const facts: Label[] = []; + if (project.standard !== undefined) { + facts.push({ key: "{0}", args: [project.standard] }); + } + if (project.sourceFiles !== undefined) { + facts.push({ key: "{0} source file(s)", args: [project.sourceFiles] }); + } + const identity: TreeNode = { + id: "project.package", + label: plain(project.name ?? "mcpp project"), + icon: "package", + tooltip: + project.version === undefined + ? { key: "{0}", args: [project.root] } + : { key: "{0} · version {1}", args: [project.root, project.version] }, + contextValue: "mcppProject", + }; + const identityFacts = joined(facts); + if (identityFacts !== undefined) { + identity.description = identityFacts; + } + + const basic: TreeNode[] = [ + identity, + ...(project.target === undefined + ? [] + : [{ id: "project.target", label: plain("Target"), description: { key: "{0}", args: [project.target] }, icon: "target" }]), + { + id: "project.toolchain", + label: plain("Toolchain"), + description: { key: "{0}", args: [project.toolchainSpec ?? "host default"] }, + icon: "chip", + }, + { id: "project.moduleDependencies", label: plain("C++ Modules Deps"), icon: "type-hierarchy", iconColor: "charts.blue", command: { command: "mcpp.project.showModuleDependencyGraph", title: plain("C++ Modules Deps"), arguments: [{ root: project.root }] } }, + ...languageServiceNodes(options.languageService), + ]; + + return [ + { id: "project.section.basic", label: plain("Basics"), icon: "info", children: basic }, + ...dependencyNodes(project, options), + { id: "project.section.commands", label: plain("Common commands"), icon: "terminal", expanded: true, children: commandNodes(options) }, + ]; +} + +interface CommandRow { + id: string; + label: string; + icon: string; + /** See `TreeNode.iconColor`; the palette groups rows by what they act on. */ + iconColor: string; + command: string; +} + +/** + * The nine rows of 「常用命令」, in the order the prototype fixes them, with + * creation first: the section is what the view offers to *do*, and in a new + * workspace the only thing to do is make a project. + * + * The colours are a small, explainable palette grouped by **what the row acts + * on**, not by decoration: blue builds or adds, green runs or verifies, purple + * tests, red deletes, yellow manages the toolchain, and configuration stays in + * the ordinary foreground so it never competes with the actions. + * + * Deletion is `charts.red` and not `charts.orange` because `charts.orange` + * resolves to `minimap.findMatchHighlight` -> `editor.findMatchHighlightBackground`, + * which is `#EA5C00` at 33% alpha: as a glyph colour that is a washed-out smear + * in both themes. `charts.red` is `editorError.foreground`, a solid value in + * every theme. The quick menu uses the same words, one file per colour — see + * `src/commands/menu.ts`. + */ +const COMMON_COMMANDS: readonly CommandRow[] = [ + { id: "project.action.new", label: "New mcpp project…", icon: "new-folder", iconColor: "charts.blue", command: "mcpp.newProject" }, + { id: "project.action.build", label: "Build", icon: "tools", iconColor: "charts.blue", command: "mcpp.build" }, + { id: "project.action.run", label: "Run", icon: "play", iconColor: "charts.green", command: "mcpp.run" }, + { id: "project.action.test", label: "Test", icon: "beaker", iconColor: "charts.purple", command: "mcpp.test" }, + { id: "project.action.clean", label: "Clean", icon: "trash", iconColor: "charts.red", command: "mcpp.cleanProjectArtifacts" }, + { id: "project.action.toolchain", label: "Toolchain", icon: "chip", iconColor: "charts.yellow", command: "mcpp.showToolchains" }, + { id: "project.action.librarySearch", label: "Search and add a dependency…", icon: "library", iconColor: "charts.blue", command: "mcpp.library.search" }, + { id: "project.action.selfCheck", label: "Environment self-check", icon: "heart", iconColor: "charts.green", command: "mcpp.selfCheck" }, + { id: "project.action.settings", label: "Settings", icon: "settings-gear", iconColor: "", command: "mcpp.openSettings" }, +]; + +/** The creation row, built once so the empty state and the section agree. */ +function newProjectNode(): TreeNode { + const row = COMMON_COMMANDS[0]; + const label = plain(row.label); + return { + id: row.id, + label, + icon: row.icon, + iconColor: row.iconColor, + contextValue: "mcppProjectCommand", + command: { command: row.command, title: label }, + }; +} + +function commandNodes(options: ProjectTreeOptions): TreeNode[] { + return COMMON_COMMANDS.map((row): TreeNode => { + const hint = formatKeybinding(options.keybindings?.[row.command], options.platform ?? "other"); + const label = plain(row.label); + const node: TreeNode = { + id: row.id, + label, + icon: row.icon, + contextValue: "mcppProjectCommand", + command: { command: row.command, title: label }, + }; + if (row.iconColor.length > 0) { + node.iconColor = row.iconColor; + } + if (hint !== undefined) { + node.description = plain(hint); + } + return node; + }); +} + +function dependencyNodes(project: ProjectSummary, options: ProjectTreeOptions): TreeNode[] { + const declared = project.dependencies ?? []; + if (declared.length === 0) { + return []; + } + const lock = options.lock ?? []; + const dev = declared.filter((entry) => entry.dev === true).length; + return [ + { + id: "project.dependencies", + contextValue: "mcppProjectDependencies", + label: { key: "Dependencies ({0})", args: [declared.length] }, + ...(dev === 0 ? {} : { description: { key: "{0} dev", args: [dev] } }), + icon: "library", + tooltip: plain("Declared in mcpp.toml; the resolved version comes from mcpp.lock."), + expanded: false, + children: declared.map((entry) => dependencyNode(entry, lock, project.root)), + }, + ]; +} + +/** One clickable direct dependency; transitive edges belong exclusively to the graph. */ +function dependencyNode(declaration: DependencyDeclaration, lock: readonly LockPackage[], root: string): TreeNode { + const resolved = resolveLockedPackage(lock, declaration.name); + const version = declaration.version ?? resolved?.version; + const coordinate = declaration.name + (version ? `@${version}` : ""); + const source = declaration.path ? `path · ${declaration.path}` : declaration.git ? "git" : "index"; + const markers = [plain(source), ...(declaration.dev ? [plain("dev")] : []), ...(declaration.kind === "build" ? [plain("build")] : []), ...(declaration.conditional ? [plain("conditional")] : [])]; + return { + id: `project.dependency.${declaration.name}${declaration.dev ? ":dev" : declaration.kind === "build" ? ":build" : ""}${declaration.conditional ? ":conditional" : ""}`, + label: plain(coordinate), description: joined(markers), + tooltip: plain(coordinate + "\n" + source + (resolved?.version && resolved.version !== version ? `\nResolved ${resolved.version}` : "")), + icon: declaration.path ? "folder" : declaration.git ? "git-branch" : "package", + iconColor: declaration.path ? "charts.purple" : declaration.git ? "charts.orange" : "charts.green", + command: { command: "mcpp.project.showDependencyGraph", title: plain("Project dependency graph"), arguments: [{ root, dependency: declaration.name, kind: declaration.dev ? "dev" : declaration.kind ?? "normal", path: declaration.path }] }, + }; +} + +function languageServiceNodes(block: LanguageServiceBlock | undefined): TreeNode[] { + if (block === undefined || block.show === false) { + return []; + } + const issues = block.state?.issues ?? []; + const healthy = block.state?.available === true && block.state.state === "ready"; + const identity: Label = { key: "mcppls {0}", args: [block.version ?? "?"] }; + const node: TreeNode = { + id: "project.languageService", + label: plain("Language service"), + description: + issues.length === 0 + ? identity + : { key: "{0} · {1}", args: [identity, { key: "{0} problem(s)", args: [issues.length] }] }, + icon: healthy ? "pass" : "warning", + contextValue: "mcppLanguageService", + children: languageServiceChildren(block), + }; + if (block.state?.available === false && block.state.reason !== undefined) { + node.tooltip = plain(block.state.reason); + } + return [node]; +} + +/** + * The expanded block: state, provider, every problem, then the four common + * actions and one folded row for the rest. mcppls's engines and semantic profile + * are not read here at all — only the language service itself. + */ +function languageServiceChildren(block: LanguageServiceBlock): TreeNode[] { + if (!block.installed) { + return [ + { + id: "project.languageService.status", + label: plain("C++ Modules is not installed"), + icon: "warning", + command: { command: "mcpp.openMcpplsSettings", title: plain("Install C++ Modules") }, + }, + ]; + } + + const state = block.state; + const nodes: TreeNode[] = [ + languageServiceStatus(state), + { + id: "project.languageService.provider", + label: plain("Provided by"), + description: plain(LANGUAGE_SERVICE_PROVIDER), + icon: "beaker", + }, + ]; + if (block.otherCppFeatures !== undefined) { + nodes.push(otherCppFeaturesNode(block.otherCppFeatures)); + } + + (state?.issues ?? []).forEach((issue, index) => { + nodes.push({ + id: `project.languageService.issue.${index}.${issue.code}`, + label: plain(issue.message), + description: plain(issue.code), + icon: "warning", + // S3 hands us the remedy; use it rather than inventing one. + command: + issue.command === undefined + ? undefined + : { + command: issue.command.command, + // mcppls's own title — a sentence we did not write, shown as-is. + title: plain(issue.command.title ?? "Fix"), + arguments: issue.command.arguments, + }, + }); + }); + + for (const action of LANGUAGE_SERVICE_COMMON_ACTIONS) { + nodes.push(actionNode(action)); + } + nodes.push({ + id: "project.languageService.action.more", + label: { key: "More ({0})…", args: [LANGUAGE_SERVICE_MORE_ACTIONS.length] }, + icon: "ellipsis", + children: LANGUAGE_SERVICE_MORE_ACTIONS.map((action) => actionNode(action)), + }); + return nodes; +} + +/** + * 「其他 C++ 语言服务」: another service answering the same files shows every + * result twice, so while one is active the row says which and one click hands + * the decision to mcppls (`mcppls.turnOffOtherCppFeatures`, which asks where). + * With none, the row stays as a quiet, dimmed confirmation and does nothing. + * When mcppls remembers settings it changed, the row's context menu offers to + * put them back (`package.json`: `viewItem =~ /Restorable$/`). + */ +function otherCppFeaturesNode(features: NonNullable): TreeNode { + const restorable = features.canRestore ? "Restorable" : ""; + if (features.active.length === 0) { + return { + id: "project.languageService.otherCppFeatures", + label: plain("Only C++ Modules is serving C++"), + icon: "pass", + iconColor: "disabledForeground", + tooltip: plain("No other C++ language service (C/C++, clangd, ccls) is active, so no result is shown twice."), + contextValue: `mcppOtherCppFeatures${restorable}`, + }; + } + const label = plain("Other C++ language services"); + return { + id: "project.languageService.otherCppFeatures", + label, + description: { key: "{0}", args: [features.active.map((entry) => entry.displayName).join(", ")] }, + icon: "warning", + tooltip: plain("They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere."), + contextValue: `mcppOtherCppFeatures${restorable}`, + command: { command: "mcpp.languageServer.manageConflicts", title: label }, + }; +} + +function actionNode(action: ActionRow): TreeNode { + const label = plain(action.label); + return { + id: action.id, + label, + icon: action.icon, + contextValue: "mcppProjectCommand", + command: { command: action.command, title: label }, + }; +} + +function languageServiceStatus(state: LanguageServiceBlock["state"]): TreeNode { + if (state === undefined || !state.available) { + return { + id: "project.languageService.status", + label: plain("Unavailable"), + description: plain(state?.reason ?? "not read yet"), + icon: "warning", + }; + } + return { + id: "project.languageService.status", + label: plain(state.state === "ready" ? "Ready" : state.state === "degraded" ? "Degraded" : state.state ?? "Unknown"), + icon: state.state === "ready" ? "pass" : "warning", + }; +} diff --git a/src/views/projectView.ts b/src/views/projectView.ts new file mode 100644 index 0000000..59e5f6f --- /dev/null +++ b/src/views/projectView.ts @@ -0,0 +1,159 @@ +/** + * The project view: 「基本信息」, folded 「依赖」, then 「常用命令」. + * + * The first section is what the project is (identity, target, toolchain, the + * module graph and the folded C++ Modules block); the final section contains actions. The summary is read from `mcpp.toml` and refreshed when the + * manifest changes; anything unreadable is omitted rather than guessed, and + * `mcpp: Environment Self-check` is the place that reports what mcpp itself + * resolves. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import * as vscode from "vscode"; + +import { readProjectSummary } from "../projects/summary"; +import { MCPP_MANIFEST_GLOB } from "../projects/context"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import type { LanguageServerBlockSource } from "./languageServerView"; +import { + buildProjectTree, + countSourceFiles, + keybindingsFromPackage, + readDependencies, + readLockfile, + type KeyboardPlatform, + type LockPackage, + type ProjectSummary, +} from "./models"; +import { registerTreeView } from "./treeProvider"; +import { registerDependencyGraph } from "../projects/dependencyGraphPanel"; + +export const PROJECT_VIEW_ID = "mcpp.project"; + +export interface ProjectViewDeps { + currentProject: () => McppProjectDiscovery | undefined; + /** + * The folded C++ Modules block. Omit it and the block is never rendered, which + * is also what happens when `mcpp.views.languageServer.show` is off. + */ + languageService?: LanguageServerBlockSource; +} + +/** `mcpp.lock` sits next to `mcpp.toml`, and changes without the manifest changing. */ +const LOCKFILE_NAME = "mcpp.lock"; +const LOCKFILE_GLOB = "**/mcpp.lock"; + +/** Read one manifest into a summary; a failure becomes `error`, never a throw. */ +export function summariseProject(project: McppProjectDiscovery | undefined): ProjectSummary | undefined { + if (project === undefined) { + return undefined; + } + try { + const text = readFileSync(project.manifestPath, "utf8"); + const lines = text.split(/\r?\n/); + const summary = readProjectSummary(project.root, lines); + const dependencies = readDependencies(lines).map((entry) => { + if (!entry.path || entry.version) { return entry; } + try { + const local = readProjectSummary(path.resolve(project.root, entry.path), readFileSync(path.resolve(project.root, entry.path, "mcpp.toml"), "utf8").split(/\r?\n/)); + return { ...entry, ...(local.version ? { version: local.version } : {}) }; + } catch { return entry; } + }); + return dependencies.length === 0 ? summary : { ...summary, dependencies }; + } catch (error) { + return { + root: project.root, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +interface ProjectSnapshot { + summary: ProjectSummary | undefined; + lock: LockPackage[]; +} + +export function registerProjectView(context: vscode.ExtensionContext, deps: ProjectViewDeps): void { + // Read from the manifest rather than copied into the source: a hint in the tree + // that disagrees with `package.json` is worse than no hint. + const keybindings = keybindingsFromPackage(context.extension.packageJSON); + const platform: KeyboardPlatform = process.platform === "darwin" ? "mac" : "other"; + const bridge = deps.languageService; + + // Counting source files walks the project, so it is not repeated for every + // active-editor change: the manifest watcher and a workspace-folder change ask + // for a fresh count, everything else reuses the last one. + let measured: { root: string; count: number } | undefined; + const withSources = (summary: ProjectSummary | undefined, fresh: boolean): ProjectSummary | undefined => { + if (summary === undefined || summary.error !== undefined) { + return summary; + } + if (fresh || measured === undefined || measured.root !== summary.root) { + measured = { root: summary.root, count: countSourceFiles(summary.root) }; + } + return { ...summary, sourceFiles: measured.count }; + }; + + const snapshot = (project: McppProjectDiscovery | undefined, freshSources: boolean): ProjectSnapshot => ({ + summary: withSources(summariseProject(project), freshSources), + lock: project === undefined ? [] : readLockfile(path.join(project.root, LOCKFILE_NAME)), + }); + + let current = snapshot(deps.currentProject(), true); + registerDependencyGraph(context, () => current.summary); + + const view = registerTreeView(PROJECT_VIEW_ID, () => + buildProjectTree(current.summary, { + lock: current.lock, + keybindings, + platform, + languageService: bridge?.input(), + }), + ); + context.subscriptions.push(view.disposable); + + const reload = (freshSources: boolean): void => { + const active = vscode.window.activeTextEditor; + const project = (!active || active.document.uri.scheme !== "file") && current.summary + ? { root: current.summary.root, manifestPath: path.join(current.summary.root, "mcpp.toml") } + : deps.currentProject(); + current = snapshot(project, freshSources); + view.provider.refresh(); + }; + + const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(LOCKFILE_GLOB); + context.subscriptions.push( + manifestWatcher, + lockWatcher, + manifestWatcher.onDidCreate(() => reload(true)), + manifestWatcher.onDidChange(() => reload(true)), + manifestWatcher.onDidDelete(() => reload(true)), + lockWatcher.onDidCreate(() => reload(false)), + lockWatcher.onDidChange(() => reload(false)), + lockWatcher.onDidDelete(() => reload(false)), + vscode.window.onDidChangeActiveTextEditor(() => reload(false)), + vscode.workspace.onDidChangeWorkspaceFolders(() => { current = snapshot(deps.currentProject(), true); view.provider.refresh(); }), + vscode.workspace.onDidGrantWorkspaceTrust(() => reload(true)), + ); + + if (bridge !== undefined) { + const tree = view.view; + context.subscriptions.push( + bridge.onDidChange(() => reload(false)), + // The folded block's poller only runs while this tree is on screen. + tree.onDidChangeVisibility(() => bridge.setVisible(tree.visible)), + ); + bridge.setVisible(tree.visible); + } + + context.subscriptions.push( + vscode.commands.registerCommand("mcpp.internal.refreshProjectView", async () => { + reload(true); + }), + ); + + reload(true); +} diff --git a/src/views/treeProvider.ts b/src/views/treeProvider.ts new file mode 100644 index 0000000..e1e3435 --- /dev/null +++ b/src/views/treeProvider.ts @@ -0,0 +1,113 @@ +/** + * The tree provider for the project view. + * + * The trees are described as data (`./models.ts`); this file is the only part + * that knows about `vscode.TreeItem`. Labels carry keys, so the translation + * happens here, at render time. + */ + +import * as vscode from "vscode"; + +import { t } from "../i18n/t"; +import type { Label, LabelArgument, TreeNode } from "./models"; + +/** `{ key, args }` -> a sentence in the user's language. */ +export function resolveLabel(label: Label | undefined): string { + if (label === undefined) { + return ""; + } + return t(label.key, ...(label.args ?? []).map(resolveArgument)); +} + +/** + * An argument is usually a value, but it may be another label: `C++23 · 87 + * source file(s)` is two translatable pieces, and the join happens here rather + * than in a builder that must stay free of the current language. + */ +function resolveArgument(argument: LabelArgument): string | number { + return typeof argument === "object" ? resolveLabel(argument) : argument; +} + +export function toTreeItem(node: TreeNode): vscode.TreeItem { + const collapsible = + node.children === undefined || node.children.length === 0 + ? vscode.TreeItemCollapsibleState.None + : node.expanded === true + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + + const item = new vscode.TreeItem(resolveLabel(node.label), collapsible); + item.id = node.id; + item.description = node.description === undefined ? undefined : resolveLabel(node.description); + item.tooltip = node.tooltip === undefined ? undefined : resolveLabel(node.tooltip); + // VS Code otherwise substitutes the tooltip for the accessible tree label. + item.accessibilityInformation = { label: [resolveLabel(node.label), item.description].filter(Boolean).join(" ") }; + if (node.icon !== undefined) { + // A `ThemeColor` id is resolved by the host; an id a theme does not define + // leaves the icon in the normal foreground colour, which is why the model + // only ever names `charts.*`. + item.iconPath = + node.iconColor === undefined || node.iconColor.length === 0 + ? new vscode.ThemeIcon(node.icon) + : new vscode.ThemeIcon(node.icon, new vscode.ThemeColor(node.iconColor)); + } + item.contextValue = node.contextValue; + if (node.command !== undefined) { + item.command = { + command: node.command.command, + title: resolveLabel(node.command.title), + arguments: [...(node.command.arguments ?? [])], + }; + } + return item; +} + +/** + * A provider over a snapshot the caller recomputes whenever something changes. + * `refresh()` re-asks for the root nodes and fires the tree event. + * + * It is disposable so it can be handed straight to `createTreeView`'s + * `context.subscriptions.push`, next to the view it feeds. + */ +export class StaticTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + + public readonly onDidChangeTreeData = this.changed.event; + + public constructor(private readonly roots: () => readonly TreeNode[]) {} + + public refresh(): void { + this.changed.fire(undefined); + } + + public dispose(): void { + this.changed.dispose(); + } + + public getTreeItem(element: TreeNode): vscode.TreeItem { + return toTreeItem(element); + } + + public getChildren(element?: TreeNode): TreeNode[] { + if (element === undefined) { + return [...this.roots()]; + } + return [...(element.children ?? [])]; + } +} + +/** + * Registers a view and hands back the provider, the view and its disposable. + * + * The `TreeView` is returned (rather than using `registerTreeDataProvider`) + * because a caller may need its visibility: the project view gates the C++ + * Modules poller on whether its tree is actually on screen. + */ +export function registerTreeView( + viewId: string, + roots: () => readonly TreeNode[], +): { provider: StaticTreeProvider; view: vscode.TreeView; disposable: vscode.Disposable } { + const provider = new StaticTreeProvider(roots); + const view = vscode.window.createTreeView(viewId, { treeDataProvider: provider }); + return { provider, view, disposable: view }; +} diff --git a/src/views/viewPolicy.ts b/src/views/viewPolicy.ts new file mode 100644 index 0000000..6dffc0d --- /dev/null +++ b/src/views/viewPolicy.ts @@ -0,0 +1,46 @@ +/** + * The C++ Modules view's settings, as decisions. + * + * Three settings shape that view, and each one is a policy question rather than + * a rendering detail, so the answers live here — free of `vscode` and therefore + * testable: + * + * - `mcpp.languageService.confirmResetCache` — does resetting the workspace cache + * ask a modal of its own, or does it rely on the capability table's `danger`? + * The answer may only ever *add* a confirmation; + * - `mcpp.languageService.notifyOnDegraded` — is the one-time "the dependency is + * gone" notice emitted at all? + * + * `src/mcppls/contract.ts` stays the single source of what each capability's + * danger is; `src/views/languageServerView.ts` stays the only place that talks to + * `vscode.window`. + */ + +export type LsConfirmation = "extra-modal" | "capability" | "none"; + +export interface ResetCacheConfirmInput { + /** The capability's own `danger` level, from `contract.ts`. */ + danger: "none" | "confirm" | "destructive"; + /** `mcpp.languageService.confirmResetCache`. */ + confirmResetCache: boolean; +} + +/** + * What the reset-cache command must ask before forwarding. + * + * Both settings default to `true`, so the shipped behaviour is unchanged: + * the destructive capability asks its modal, and this setting adds a second + * explicit step. Turning the setting off falls back to the capability's own + * level — it never removes a `destructive` modal. + */ +export function resetCacheConfirmation(input: ResetCacheConfirmInput): LsConfirmation { + if (!input.confirmResetCache) { + return input.danger === "none" ? "none" : "capability"; + } + return input.danger === "none" ? "none" : "extra-modal"; +} + +/** `mcpp.languageService.notifyOnDegraded`: emit the one-time dependency notice? */ +export function degradedNoticeEnabled(notifyOnDegraded: boolean | undefined): boolean { + return notifyOnDegraded !== false; +} diff --git a/src/webview/document.ts b/src/webview/document.ts new file mode 100644 index 0000000..cdd8005 --- /dev/null +++ b/src/webview/document.ts @@ -0,0 +1,64 @@ +/** + * One webview document: the CSP nonce and the render-only-on-change rule every + * webview host in this extension shares (library view, cache view, settings + * panel, package detail page). + * + * Assigning `webview.html` reloads the document — it throws away the scroll + * position, any half-typed input and the focus. Two properties make a host + * safe against pointless and looping reloads: + * + * 1. **The nonce is per view, not per render.** The CSP nonce is embedded in + * the document, so a fresh nonce per render makes every render a *different* + * document and no comparison can ever say "unchanged" — that is exactly how + * the library view once reloaded itself forever (flicker, unclickable rows, + * a pegged CPU). + * 2. **`paint()` assigns only when the document changed**, via + * `documentNeedsRender` in `./render`. + * + * A hidden `WebviewView` loses its context (`retainContextWhenHidden` is not + * supported), so the next resolve is a brand-new, empty webview: the host + * calls `invalidate()` on resolve and on dispose, and the comparison starts + * from nothing again. + */ +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +import { documentNeedsRender } from "./render"; + +/** What an html renderer embeds: the CSP source, the script nonce, the stylesheet. */ +export interface WebviewAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** One webview's document state: its nonce and the html currently on screen. */ +export class WebviewDocument { + private readonly nonce = randomBytes(16).toString("base64"); + private current: string | undefined; + + /** @param stylesheet the stylesheet file, joined onto the media root. */ + constructor(private readonly stylesheet: string) {} + + /** The webview was (re)resolved or disposed: it is a fresh, empty document. */ + invalidate(): void { + this.current = undefined; + } + + assets(mediaRoot: vscode.Uri, webview: vscode.Webview): WebviewAssets { + return { + cspSource: webview.cspSource, + nonce: this.nonce, + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, this.stylesheet)).toString(), + }; + } + + /** Put the document on screen, but only when it says something new. */ + paint(webview: vscode.Webview, html: string): void { + if (!documentNeedsRender(this.current, html)) { + return; + } + this.current = html; + webview.html = html; + } +} diff --git a/src/webview/readOnlyDocuments.ts b/src/webview/readOnlyDocuments.ts new file mode 100644 index 0000000..b35f55a --- /dev/null +++ b/src/webview/readOnlyDocuments.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +/** Content-addressed text documents. A content provider exposes them without a writable filesystem. */ +export class ReadOnlyDocuments { + private readonly documents = new Map(); + constructor(private readonly scheme: string, private readonly captured?: (uri: string) => void) {} + capture(file: { path: string; text: string }): string { + const digest = createHash("sha256").update(file.text).digest("hex"); + const uri = `${this.scheme}:/${file.path.split(/[\\/]/).map(encodeURIComponent).join("/")}?snapshot=${digest}`; + this.documents.set(digest, file.text); + this.captured?.(uri); + return uri; + } + read(uri: string): string | undefined { + try { + const digest = /[?&]snapshot=([a-f0-9]{64})(?:$|&)/.exec(decodeURIComponent(uri))?.[1]; + return digest ? this.documents.get(digest) : undefined; + } catch { return undefined; } + } +} diff --git a/src/webview/render.ts b/src/webview/render.ts new file mode 100644 index 0000000..0de9fe8 --- /dev/null +++ b/src/webview/render.ts @@ -0,0 +1,18 @@ +/** + * The pure half of the webview render kit. + * + * This module deliberately imports nothing — not even `vscode` — so the unit + * tests can state its semantics directly under `node --test`. + */ + +/** + * Whether a freshly rendered document should replace the one on screen. + * + * Assigning `webview.html` reloads the view, so an identical document must not + * be pushed. This is the guard that keeps a "render again" request from + * turning into a reload loop; it is the one rule of the kit that a unit test + * can state in one line. + */ +export function documentNeedsRender(rendered: string | undefined, next: string): boolean { + return rendered !== next; +} diff --git a/src/workflow.ts b/src/workflow.ts deleted file mode 100644 index 3def1fb..0000000 --- a/src/workflow.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -import type { CheckResult, ModuleCapability } from "./analysis"; - -export type RefreshOutcomeLevel = "information" | "warning" | "error"; -export type ModuleSupportState = "available" | "unavailable"; - -export interface RefreshOutcome { - level: RefreshOutcomeLevel; - message: string; -} - -export interface CompilationDatabaseWatcher { - onDidCreate(listener: (event: E) => void): T; - onDidChange(listener: (event: E) => void): T; - onDidDelete(listener: (event: E) => void): T; -} - -export function registerCompilationDatabaseReconciliation( - watcher: CompilationDatabaseWatcher, - requestReconciliation: (event: E, forceRestart: boolean) => void, -): T[] { - return [ - watcher.onDidCreate((event) => requestReconciliation(event, true)), - watcher.onDidChange((event) => requestReconciliation(event, true)), - watcher.onDidDelete((event) => requestReconciliation(event, false)), - ]; -} - -export function shouldRestartClangd( - configurationChanged: boolean, - interactive: boolean, - forceRestart: boolean, -): boolean { - return configurationChanged || interactive || forceRestart; -} - -export function configurationReadyAfterRestart( - restartRequired: boolean, - restartSucceeded: boolean, -): boolean { - return !restartRequired || restartSucceeded; -} - -export function createSingleFlightReconciler( - operation: (forceRestart: boolean) => Promise, -): (forceRestart?: boolean) => Promise { - let active: Promise | undefined; - let requested = false; - let pendingForceRestart = false; - - return (forceRestart = false): Promise => { - requested = true; - pendingForceRestart ||= forceRestart; - if (active !== undefined) { - return active; - } - - active = (async () => { - let result: T | undefined; - let completed = false; - do { - const currentForceRestart = pendingForceRestart; - requested = false; - pendingForceRestart = false; - try { - result = await operation(currentForceRestart); - completed = true; - } catch (error) { - if (!requested) { - throw error; - } - } - } while (requested); - if (!completed) { - throw new Error("重协调没有产生结果"); - } - return result as T; - })().finally(() => { - active = undefined; - requested = false; - pendingForceRestart = false; - }); - return active as Promise; - }; -} - -export function createKeyedSingleFlightReconciler( - operation: (key: K, forceRestart: boolean) => Promise, -): (key: K, forceRestart?: boolean) => Promise { - const reconcilerByKey = new Map Promise>(); - - return (key: K, forceRestart = false): Promise => { - let reconciler = reconcilerByKey.get(key); - if (reconciler === undefined) { - reconciler = createSingleFlightReconciler( - (pendingForceRestart) => operation(key, pendingForceRestart), - ); - reconcilerByKey.set(key, reconciler); - } - return reconciler(forceRestart); - }; -} - -export function createSerialExecutor(): (operation: () => Promise) => Promise { - let tail: Promise = Promise.resolve(); - // `running` is scoped to the async context of the in-flight operation via - // AsyncLocalStorage instead of a global counter, so only genuinely reentrant - // calls bypass the queue: - // - a call made from inside an operation's own async chain (e.g. - // wizard → build → reconcile → executor) sees `running` and executes - // immediately — the outer operation is awaiting it, so queuing would hang; - // - an unrelated concurrent caller (a separate command/event that has its - // own async context) does not see it and queues behind the in-flight - // operation, which the previous `depth > 0` check would wrongly let run - // concurrently. - const state = { running: false }; - const storage = new AsyncLocalStorage<{ running: boolean }>(); - - return (operation: () => Promise): Promise => { - if (storage.getStore() === state && state.running) { - return operation(); - } - - const wrapped = async (): Promise => { - state.running = true; - try { - return await storage.run(state, operation); - } finally { - state.running = false; - } - }; - - const result = tail.then(wrapped, wrapped); - tail = result.then( - () => undefined, - () => undefined, - ); - return result; - }; -} - -export interface LatestOperationTracker { - begin(key: K): number; - invalidate(key: K): void; - isCurrent(key: K, token: number): boolean; - clear(): void; -} - -export function createLatestOperationTracker(): LatestOperationTracker { - const latestTokenByKey = new Map(); - let nextToken = 0; - - return { - begin(key: K): number { - nextToken += 1; - latestTokenByKey.set(key, nextToken); - return nextToken; - }, - invalidate(key: K): void { - latestTokenByKey.delete(key); - }, - isCurrent(key: K, token: number): boolean { - return latestTokenByKey.get(key) === token; - }, - clear(): void { - latestTokenByKey.clear(); - }, - }; -} - -export function shouldRenderProjectStatus( - currentProjectRoot: string | undefined, - statusProjectRoot: string, -): boolean { - return currentProjectRoot === statusProjectRoot; -} - -export function configurationAffectsModuleSupport( - affectsConfiguration: (section: string) => boolean, -): boolean { - return ["mcpp.clangd.path", "mcpp.modulesSupport"].some(affectsConfiguration); -} - -export function workspaceAllowsToolExecution(trusted: boolean): boolean { - return trusted; -} - -export function shouldUseWorkspaceClangd( - currentProjectRoot: string | undefined, - projectRoot: string, -): boolean { - return currentProjectRoot === projectRoot; -} - -export function describeRefreshOutcome( - buildExitCode: number, - databaseFound: boolean, - configured: boolean, -): RefreshOutcome { - if (configured) { - return buildExitCode === 0 - ? { - level: "information", - message: "mcpp 构建完成,clangd 配置已刷新。", - } - : { - level: "warning", - message: "mcpp 构建失败,但检测到可用的编译数据库,clangd 配置已刷新。请继续修复源码构建错误。", - }; - } - - if (!databaseFound) { - return buildExitCode === 0 - ? { - level: "error", - message: "mcpp 构建完成,但没有生成 compile_commands.json。", - } - : { - level: "error", - message: "mcpp 构建失败,且没有生成 compile_commands.json。请查看 mcpp 输出频道。", - }; - } - - return { - level: "error", - message: buildExitCode === 0 - ? "已检测到 compile_commands.json,但 clangd 配置未完成。请查看 mcpp 输出频道。" - : "mcpp 构建失败;已检测到 compile_commands.json,但 clangd 配置未完成。请查看 mcpp 输出频道。", - }; -} - -export function statusCommandForCapability(capability: ModuleCapability): string { - return capability === "unavailable" - ? "mcpp.refreshCompilationDatabase" - : "mcpp.checkModuleSupport"; -} - -export function shouldCheckModuleSupport( - capability: ModuleCapability, - configured: boolean, - sourceFile?: string, -): boolean { - return capability === "full" && configured && sourceFile !== undefined && sourceFile.length > 0; -} - -export function moduleSupportState(classification: CheckResult): ModuleSupportState { - return classification === "ready" ? "available" : "unavailable"; -} diff --git a/src/workflows/moduleSetup.ts b/src/workflows/moduleSetup.ts new file mode 100644 index 0000000..6810dde --- /dev/null +++ b/src/workflows/moduleSetup.ts @@ -0,0 +1,95 @@ +import { t } from "../i18n/t"; + +export type ModuleSetupStage = "build" | "language-server"; +export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled" | "not-started"; +export type ModuleSetupBlockedReason = "untrusted" | "busy"; + +export type ModuleSetupDecision = + | { kind: "ready" } + | { kind: "blocked"; reason: ModuleSetupBlockedReason }; + +export function buildModuleSetupPlan( + trusted: boolean, + busy: boolean, +): ModuleSetupDecision { + if (!trusted) { + return { kind: "blocked", reason: "untrusted" }; + } + if (busy) { + return { kind: "blocked", reason: "busy" }; + } + return { kind: "ready" }; +} + +export interface ModuleSetupConfirmation { + message: string; + detail: string; +} + +export function moduleSetupConfirmation(): ModuleSetupConfirmation { + return { + message: t("Build the current mcpp project and refresh the C++ Modules language service?"), + detail: t("This runs mcpp build and has the C++ Modules extension re-read the build description; it does not change the toolchain, mcpp.toml or any language service setting."), + }; +} + +export interface ModuleSetupStepResult { + stage: ModuleSetupStage; + state: ModuleSetupStepState; + detail?: string; + exitCode?: number; +} + +export interface ModuleSetupOperations { + build(): Promise; + refreshLanguageServer(): Promise; +} + +export type ModuleSetupOutcome = + | { + state: "succeeded"; + degraded: boolean; + steps: ModuleSetupStepResult[]; + } + | { + state: "failed" | "cancelled"; + stage: ModuleSetupStage; + degraded: false; + steps: ModuleSetupStepResult[]; + }; + +function stoppedOutcome( + result: ModuleSetupStepResult, + steps: ModuleSetupStepResult[], +): Exclude { + return { + state: result.state === "cancelled" ? "cancelled" : "failed", + stage: result.stage, + degraded: false, + steps, + }; +} + +export async function executeModuleSetup( + _decision: Extract, + operations: ModuleSetupOperations, +): Promise { + const steps: ModuleSetupStepResult[] = []; + const buildResult = await operations.build(); + steps.push(buildResult); + if (buildResult.state === "cancelled" || buildResult.state === "not-started") { + return stoppedOutcome(buildResult, steps); + } + + const languageServerResult = await operations.refreshLanguageServer(); + steps.push(languageServerResult); + if (languageServerResult.state !== "succeeded") { + return stoppedOutcome(languageServerResult, steps); + } + + return { + state: "succeeded", + degraded: buildResult.state === "failed", + steps, + }; +} diff --git a/syntaxes/mcpp-build-language-configuration.json b/syntaxes/mcpp-build-language-configuration.json deleted file mode 100644 index b49c932..0000000 --- a/syntaxes/mcpp-build-language-configuration.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "comments": { - "lineComment": "//", - "blockComment": ["/*", "*/"] - }, - "brackets": [ - ["{", "}"], - ["[", "]"], - ["(", ")"] - ], - "autoClosingPairs": [ - {"open": "{", "close": "}"}, - {"open": "[", "close": "]"}, - {"open": "(", "close": ")"}, - {"open": "\"", "close": "\""}, - {"open": "'", "close": "'"} - ], - "surroundingPairs": [ - ["{", "}"], - ["[", "]"], - ["(", ")"], - ["\"", "\""], - ["'", "'"] - ] -} diff --git a/syntaxes/mcpp-build.tmLanguage.json b/syntaxes/mcpp-build.tmLanguage.json deleted file mode 100644 index 3635680..0000000 --- a/syntaxes/mcpp-build.tmLanguage.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", - "name": "mcpp build script", - "scopeName": "source.mcpp-build", - "patterns": [ - { - "include": "source.cpp" - } - ] -} diff --git a/syntaxes/mcpp-modules.tmLanguage.json b/syntaxes/mcpp-modules.tmLanguage.json index 28c69e3..39bd45d 100644 --- a/syntaxes/mcpp-modules.tmLanguage.json +++ b/syntaxes/mcpp-modules.tmLanguage.json @@ -2,7 +2,7 @@ "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "name": "mcpp C++ Modules", "scopeName": "source.cpp.mcpp-modules", - "injectionSelector": "L:source.cpp, L:source.mcpp-build", + "injectionSelector": "L:source.cpp", "patterns": [ { "include": "#module-declaration" diff --git a/test/analysis.test.ts b/test/analysis.test.ts deleted file mode 100644 index 521b429..0000000 --- a/test/analysis.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - analyzeCompilationDatabase, - buildClangdArguments, - classifyCheckResult, - compareToolIdentities, - parseToolIdentity, -} from "../src/analysis"; -import * as analysisModule from "../src/analysis"; - -function cdb(compiler: string, flags: string[]): string { - return JSON.stringify([ - { - directory: "/work/app", - file: "/work/app/src/main.cpp", - arguments: [compiler, "-std=c++23", ...flags, "-c", "/work/app/src/main.cpp"], - }, - ]); -} - -test("classifies an mcpp LLVM CDB as full module support", () => { - const result = analyzeCompilationDatabase(cdb("/tools/xim-x-llvm/22.1.8/bin/clang++", [ - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - ])); - - assert.equal(result.kind, "llvm"); - assert.equal(result.capability, "full"); - assert.equal(result.compilerPath, "/tools/xim-x-llvm/22.1.8/bin/clang++"); - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); - assert.equal(result.hasPrebuiltModules, true); -}); - -test("prefers a project module interface over an earlier external command", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/deps/gtest.cc", - arguments: ["/tools/clang++", "-std=c++23", "-c", "/deps/gtest.cc"], - }, - { - directory: "/work/app", - file: "/work/app/src/demo.cppm", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-c", - "/work/app/src/demo.cppm", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/demo.cppm"); - assert.equal(result.hasPrebuiltModules, true); -}); - -test("prefers a project source over an external module interface", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/deps/fmt_module.cppm", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-c", - "/deps/fmt_module.cppm", - ], - }, - { - directory: "/work/app", - file: "/work/app/src/main.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-c", - "/work/app/src/main.cpp", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); -}); - -test("prefers a project source over an mcpp dependency cache source", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/work/app/.mcpp/packages/redis/src/transaction.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "/work/app/.mcpp/packages/redis/src/transaction.cpp", - ], - }, - { - directory: "/work/app", - file: "/work/app/src/main.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "/work/app/src/main.cpp", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); -}); - -test("resolves relative CDB source files from their command directory", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/work/app/.mcpp/packages/redis/src/transaction.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "/work/app/.mcpp/packages/redis/src/transaction.cpp", - ], - }, - { - directory: "/work/app", - file: "src/main.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "src/main.cpp", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); -}); - -test("keeps Windows separators when reading a command-form CDB entry", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "C:\\work\\app", - file: "C:\\work\\app\\main.cpp", - command: "C:\\LLVM\\bin\\clang++.exe -std=c++23 -c C:\\work\\app\\main.cpp", - }, - ])); - - assert.equal(result.kind, "llvm"); - assert.equal(result.compilerPath, "C:\\LLVM\\bin\\clang++.exe"); -}); - -test("degrades GCC module databases to syntax-only", () => { - const result = analyzeCompilationDatabase(cdb("/tools/g++", ["-fmodules"])); - - assert.equal(result.kind, "gcc"); - assert.equal(result.capability, "syntax-only"); -}); - -test("degrades MSVC module databases to syntax-only", () => { - const result = analyzeCompilationDatabase(cdb("C:\\LLVM\\cl.exe", [ - "/reference", - "std=C:\\work\\ifc.cache\\std.ifc", - ])); - - assert.equal(result.kind, "msvc"); - assert.equal(result.capability, "syntax-only"); -}); - -test("reports malformed compilation databases without throwing", () => { - const result = analyzeCompilationDatabase("not-json"); - - assert.equal(result.kind, "unknown"); - assert.equal(result.capability, "unavailable"); - assert.match(result.reason, /JSON/i); -}); - -test("auto module arguments enable experimental support for clangd 22", () => { - const args = buildClangdArguments( - ["--background-index", "--query-driver=/old/compiler"], - { - compilerPath: "/tools/clang++", - modulesSupport: "auto", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "darwin", - }, - ); - - assert.deepEqual(args, [ - "--background-index", - "--query-driver=/tools/clang++", - "--experimental-modules-support", - ]); -}); - -test("auto module arguments avoid the known Windows clangd 20 crash", () => { - const args = buildClangdArguments([], { - compilerPath: "C:\\tools\\clang++.exe", - modulesSupport: "auto", - clangdIdentity: { major: 20, minor: 1, patch: 7 }, - platform: "win32", - }); - - assert.deepEqual(args, ["--query-driver=C:\\tools\\clang++.exe"]); -}); - -test("auto consumes explicit mcpp PCMs without invoking clangd's module builder", () => { - const args = buildClangdArguments([], { - compilerPath: "/tools/clang++", - modulesSupport: "auto", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "darwin", - hasPrebuiltModules: true, - }); - - assert.deepEqual(args, ["--query-driver=/tools/clang++"]); -}); - -test("hermetic mcpp commands do not use query-driver discovery", () => { - const args = buildClangdArguments( - ["--background-index", "--query-driver=/old/compiler"], - { - compilerPath: "/tools/clang++", - compilationArguments: [ - "/tools/clang++", - "--no-default-config", - "-nostdinc++", - "-isystem", - "/tools/llvm/include/c++/v1", - "-isystem/tools/llvm/include/x86_64-unknown-linux-gnu", - "-isystem/tools/llvm/include", - "-c", - "/work/app/src/main.cpp", - ], - modulesSupport: "off", - platform: "darwin", - }, - ); - - assert.deepEqual(args, ["--background-index"]); -}); - -test("non-hermetic commands retain query-driver discovery", () => { - const args = buildClangdArguments([], { - compilerPath: "/tools/clang++", - compilationArguments: [ - "/tools/clang++", - "-std=c++23", - "-c", - "/work/app/src/main.cpp", - ], - modulesSupport: "off", - platform: "darwin", - }); - - assert.deepEqual(args, ["--query-driver=/tools/clang++"]); -}); - -test("expands workspace folder variables before invoking clangd", () => { - const options = { - compilerPath: "/tools/clang++", - modulesSupport: "off" as const, - platform: "darwin" as const, - workspaceFolder: "/work/app", - }; - const args = buildClangdArguments([ - "--compile-commands-dir=${workspaceFolder}", - "--background-index", - ], options); - - assert.deepEqual(args, [ - "--compile-commands-dir=/work/app", - "--background-index", - "--query-driver=/tools/clang++", - ]); -}); - -test("off removes an existing experimental module flag", () => { - const args = buildClangdArguments(["--experimental-modules-support"], { - compilerPath: "/tools/clang++", - modulesSupport: "off", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "linux", - }); - - assert.deepEqual(args, ["--query-driver=/tools/clang++"]); -}); - -test("plans replacing the default clangd shim with the matched executable", () => { - const planner = (analysisModule as typeof analysisModule & { - buildClangdConfigurationPlan?: ( - currentPath: string, - currentArguments: string[], - resolvedPath: string, - options: { - compilerPath: string; - modulesSupport: "auto"; - clangdIdentity: { major: number; minor: number; patch: number }; - platform: NodeJS.Platform; - hasPrebuiltModules: boolean; - workspaceFolder: string; - }, - ) => { path: string; arguments: string[]; changed: boolean }; - }).buildClangdConfigurationPlan; - - assert.equal(typeof planner, "function"); - const plan = planner?.( - "clangd", - ["--compile-commands-dir=${workspaceFolder}", "--background-index"], - "/tools/xim-x-llvm-tools/22.1.8/bin/clangd", - { - compilerPath: "/tools/xim-x-llvm/22.1.8/bin/clang++", - modulesSupport: "auto", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "darwin", - hasPrebuiltModules: true, - workspaceFolder: "/work/app", - }, - ); - - assert.deepEqual(plan, { - path: "/tools/xim-x-llvm-tools/22.1.8/bin/clangd", - arguments: [ - "--compile-commands-dir=/work/app", - "--background-index", - "--query-driver=/tools/xim-x-llvm/22.1.8/bin/clang++", - ], - changed: true, - }); -}); - -test("parses LLVM version and revision identities", () => { - const identity = parseToolIdentity( - "clangd version 22.1.8 (https://github.com/llvm/llvm-project ca7933e47d3a3451d81e72ac174dcb5aa28b59d1)", - ); - - assert.deepEqual(identity, { - major: 22, - minor: 1, - patch: 8, - revision: "ca7933e47d3a3451d81e72ac174dcb5aa28b59d1", - }); -}); - -test("rejects matching semantic versions from different LLVM revisions", () => { - const result = compareToolIdentities( - { major: 22, minor: 1, patch: 8, revision: "aaaa" }, - { major: 22, minor: 1, patch: 8, revision: "bbbb" }, - ); - - assert.equal(result.compatible, false); - assert.match(result.reason, /revision/i); -}); - -test("does not claim exact identity when an LLVM revision is unavailable", () => { - const result = compareToolIdentities( - { major: 22, minor: 1, patch: 8 }, - { major: 22, minor: 1, patch: 8 }, - ); - - assert.equal(result.compatible, false); - assert.match(result.reason, /revision/i); -}); - -test("classifies clangd check failures into actionable states", () => { - assert.equal( - classifyCheckResult(1, "error: ast_file_different_branch"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "error: module file '.../std.pcm' uses a newer format that cannot be read"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "Module file uses a newer format that cannot be read ast_file_version_too_new"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "error: module file uses an older format that is no longer supported"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "Failed to build module std; Don't get the module unit"), - "module-unavailable", - ); - assert.equal( - classifyCheckResult(3, "unknown type name 'import'"), - "wrong-language-mode", - ); - assert.equal( - classifyCheckResult(0, "All checks completed, 0 errors"), - "ready", - ); - assert.equal( - classifyCheckResult(3, "tweak: ExtractFunction ==> FAIL\nAll checks completed, 4 errors"), - "ready", - ); - assert.equal( - classifyCheckResult( - 3, - "E[12:00:00] [undeclared_var_use] Line 4: use of undeclared identifier 'missing'\nAll checks completed, 1 errors", - ), - "check-failed", - ); -}); diff --git a/test/architecture.test.ts b/test/architecture.test.ts new file mode 100644 index 0000000..a07876f --- /dev/null +++ b/test/architecture.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * The architectural rule this codebase is built on: **pure modules never import + * `vscode`**. + * + * It is what makes the logic testable under plain `node --test`, and it is easy + * to break by accident — a convenience `vscode.window.showWarningMessage` in a + * policy helper, or an `import * as vscode` at the top of a translation module, + * silently takes the whole file out of the unit test's reach. So the rule is + * checked two ways: the import is absent from the source, and the module can + * actually be loaded outside an editor host. + */ + +/** The modules that must stay importable without VS Code. */ +const PURE_MODULES = [ + "src/i18n/t.ts", + "src/i18n/translate.ts", + "src/cli/clean.ts", + "src/cli/cache.ts", + "src/cli/artifacts.ts", + "src/cli/process.ts", + "src/cli/protocol.ts", + "src/cli/search.ts", + "src/cli/tasks.ts", + "src/cli/newProject.ts", + "src/cli/toolchain.ts", + "src/cli/labels.ts", + "src/cli/statusBar.ts", + "src/cli/selfCheck.ts", + "src/config/registry.ts", + "src/config/validate.ts", + "src/config/panelHtml.ts", + "src/mcppls/contract.ts", + "src/mcppls/state.ts", + "src/mcppls/messages.ts", + "src/mcppls/timers.ts", + "src/projects/discovery.ts", + "src/projects/summary.ts", + "src/projects/moduleGraph.ts", + "src/graphs/model.ts", + "src/graphs/panelHtml.ts", + "src/cli/moduleGraph.ts", + "src/webview/readOnlyDocuments.ts", + "src/toml/completion.ts", + "src/toml/diagnostics.ts", + "src/toml/parser.ts", + "src/toml/schema.ts", + "src/toml/hover.ts", + "src/toml/navigation.ts", + "src/views/models.ts", + "src/cache/cacheState.ts", + "src/library/indexModel.ts", + "src/library/xpkg.ts", + "src/library/libraryHtml.ts", + "src/library/detailHtml.ts", + "src/cache/cachePanelHtml.ts", + "src/views/viewPolicy.ts", + "src/buildscript/analysis.ts", + "src/buildscript/api.ts", + "src/buildscript/modules.ts", + "src/buildscript/settings.ts", + "src/buildscript/snippets.ts", + "src/util/format.ts", + "src/util/log.ts", + "src/util/text.ts", + "src/webview/render.ts", + "src/workflows/moduleSetup.ts", +]; + +const root = process.cwd(); + +test("a pure module does not import vscode", () => { + const offenders: string[] = []; + for (const relative of PURE_MODULES) { + const text = readFileSync(path.join(root, relative), "utf8"); + // `import type` is fine: it is erased at compile time and costs nothing at + // runtime, which is exactly how `src/i18n/t.ts` reads the l10n API lazily. + for (const match of text.matchAll(/^\s*import\s+(?!type\b)[^;]*from\s+"vscode";/gm)) { + offenders.push(`${relative}: ${match[0].trim()}`); + } + assert.ok(!/require\(\s*["']vscode["']\s*\)/.test(text.replace(/require\(\s*["']vscode["']\s*\)\s*as\s+typeof\s+vscode/g, "")), + `${relative} requires vscode at module scope`); + } + assert.deepEqual(offenders, [], `these pure modules must not import vscode:\n ${offenders.join("\n ")}`); +}); + +test("a pure module can actually be loaded without an editor host", async () => { + // The source check above proves the absence of an import; this proves the + // consequence, which is the property the unit tests depend on. + const failed: string[] = []; + for (const relative of PURE_MODULES) { + const compiled = path.join(root, "dist", relative.replace(/\.ts$/, ".js")); + try { + await import(compiled); + } catch (error) { + failed.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`); + } + } + assert.deepEqual(failed, [], `these pure modules could not load outside VS Code:\n ${failed.join("\n ")}`); +}); + +test("the list is not so short that the rule stops meaning anything", () => { + assert.ok(PURE_MODULES.length >= 40, `only ${PURE_MODULES.length} modules are checked`); +}); + +test("every listed module exists", () => { + for (const relative of PURE_MODULES) { + readFileSync(path.join(root, relative), "utf8"); + } +}); + +test("runProcess stays inside src/cli — everything else runs mcpp through the trust-gated seam", () => { + // The external review's P0 (2026-10-03): `mcpp.path` is a resource-scoped + // setting, so any call site that skips the workspace-trust gate lets an + // untrusted workspace name the program that runs. The seam is `runMcpp`; + // `src/cli/` keeps `runProcess` because its controller wraps whole commands + // in `requireTrusted()` prompts of its own. + const offenders: string[] = []; + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.name.endsWith(".ts")) { + continue; + } + const relative = path.relative(root, full); + if (relative.split(path.sep)[1] === "cli") { + continue; + } + const text = readFileSync(full, "utf8"); + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/\brunProcess\b/.test(code)) { + offenders.push(relative); + } + } + }; + walk(path.join(root, "src")); + assert.deepEqual(offenders, [], "these files call runProcess directly; use runMcpp(trusted, …) instead"); +}); diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 4ab77e4..5f94740 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -1,60 +1,110 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; +import { contributedCommandIds } from "../src/commands/ids"; + interface PackageManifest { version?: string; + displayName?: string; + description?: string; icon?: string; + dependencies?: Record; + devDependencies?: Record; repository?: { url?: string }; homepage?: string; bugs?: { url?: string }; + engines?: { vscode?: string }; extensionDependencies?: string[]; activationEvents?: string[]; - capabilities?: { untrustedWorkspaces?: { supported?: string; description?: string } }; + capabilities?: { untrustedWorkspaces?: { supported?: string; description?: string; restrictedConfigurations?: string[] } }; contributes?: { - commands?: Array<{ command: string; icon?: string }>; + commands?: Array<{ command: string; title?: string; category?: string; icon?: string }>; menus?: { "editor/title"?: Array<{ command: string; group?: string; when?: string }> }; - configuration?: { properties?: Record }; + configuration?: { title?: string; properties?: Record }; configurationDefaults?: Record; languages?: Array<{ id: string; aliases?: string[]; filenames?: string[]; configuration?: string }>; + viewsContainers?: { activitybar?: Array<{ id: string; title?: string; icon?: string }> }; + views?: Record< + string, + Array<{ + id: string; + name?: string; + description?: string; + when?: string; + type?: string; + visibility?: string; + initialSize?: number; + }> + >; + viewsWelcome?: Array<{ view: string; contents: string; when?: string }>; + colors?: Array<{ id: string; description?: string }>; + keybindings?: Array<{ command: string; key?: string; mac?: string; when?: string }>; grammars?: Array<{ language?: string; scopeName: string; injectTo?: string[]; path: string }>; }; } const root = path.resolve(process.cwd()); -test("declares the official clangd dependency and mcpp commands", () => { +test("declares mcpp-language-server as the C++ modules language service", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.2.5"); - 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")); + // The version lives in three places; they must agree or a release tag is a lie. + const lock = JSON.parse(readFileSync(path.join(root, "package-lock.json"), "utf8")) as { + version?: string; + packages?: Record; + }; + assert.match(manifest.version ?? "", /^\d+\.\d+\.\d+$/); + assert.equal(lock.version, manifest.version); + assert.equal(lock.packages?.[""]?.version, manifest.version); + // User-visible strings live in the nls bundle; the manifest only names them. + assert.equal(manifest.description, "%description%"); + assert.equal(manifest.displayName, "%displayName%"); + assert.equal(manifest.engines?.vscode, "^1.91.0"); + assert.deepEqual(manifest.extensionDependencies, ["sunrisepeak.mcpp-language-server"]); + assert.ok(!manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); + // Since VS Code 1.74 every contributes.commands entry implies its own + // onCommand activation, so only the file- and folder-based triggers remain. + assert.deepEqual(manifest.activationEvents, [ + "workspaceContains:mcpp.toml", + "onLanguage:mcpp-toml", + ]); assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); - assert.equal( - manifest.capabilities?.untrustedWorkspaces?.description, - "未受信任工作区仅启用模块语法高亮,不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。", + assert.equal(manifest.capabilities?.untrustedWorkspaces?.description, "%untrustedWorkspaces.description%"); + // The `limited` promise says "no mcpp command runs" in an untrusted + // workspace; a resource-scoped `mcpp.path` can only be kept out of the + // workspace's reach by naming it here (external review P0, 2026-10-03). + assert.deepEqual(manifest.capabilities?.untrustedWorkspaces?.restrictedConfigurations, [ + "mcpp.path", + "mcpp.clangd.path", + ]); + // Same set, not necessarily the same order: the manifest's order is the + // palette's presentation order, which `ids.ts` has no business dictating. + assert.deepEqual( + (manifest.contributes?.commands?.map((command) => command.command) ?? []).slice().sort(), + contributedCommandIds().slice().sort(), + ); + assert.deepEqual( + manifest.contributes?.viewsContainers?.activitybar?.map((container) => container.id), + ["mcpp"], + ); + assert.deepEqual( + manifest.contributes?.views?.mcpp?.map((view) => view.id), + ["mcpp.project", "mcpp.library", "mcpp.cache"], ); assert.deepEqual( - manifest.contributes?.commands?.map((command) => command.command), - [ - "mcpp.showMenu", - "mcpp.newProject", - "mcpp.build", - "mcpp.run", - "mcpp.test", - "mcpp.clean", - "mcpp.showToolchains", - "mcpp.installToolchain", - "mcpp.selectDefaultToolchain", - "mcpp.configureClangd", - "mcpp.refreshCompilationDatabase", - "mcpp.checkModuleSupport", - "mcpp.autoConfigureModules", - ], + manifest.contributes?.colors?.map((color) => color.id), + ["mcpp.cacheOkForeground", "mcpp.cacheStaleForeground"], ); assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.path"]); - assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.modulesSupport"]); + assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.tomlCompletion"]); + assert.equal(manifest.contributes?.configuration?.title, "%mcpp.configuration.title%"); + for (const command of manifest.contributes?.commands ?? []) { + assert.match(command.title ?? "", /^%command\.[^%]+\.title%$/, `command ${command.command} title must be an nls key`); + assert.equal(command.category, "%category%"); + } + assert.equal(manifest.dependencies?.["vscode-languageclient"], undefined); + assert.equal(manifest.devDependencies?.["vscode-languageclient"], undefined); assert.deepEqual(manifest.contributes?.configurationDefaults?.["files.associations"], { "*.ccm": "cpp", "*.cppm": "cpp", @@ -63,48 +113,232 @@ test("declares the official clangd dependency and mcpp commands", () => { }); }); -test("自动模块配置使用非交互复合操作和单一全局锁", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); - assert.match(source, /public async readToolchainInventory\s*\(/); - const start = source.indexOf("public async runAutomaticModuleSetup"); - 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, /mcppModuleSetupCommands\(plan\)/); - assert.match(method, /beginGlobal\(token\)/); - assert.match(method, /finally\s*\{[\s\S]*finishGlobal\(token\)/); - assert.doesNotMatch(method, /pickInstallSpec|selectDefaultToolchainFromInventory|showQuickPick|showWarningMessage/); -}); - -test("一键向导只有一次确认并在构建后重载上下文", () => { +test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服务", () => { + const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + // Slice up to a marker that exists for its own sake, not as a test hook: the + // wizard is the last function before `activate`. const start = source.indexOf("async function autoConfigureModulesWizard"); - const end = source.indexOf("export async function activate", start); + const end = source.indexOf("export async function activate(", start); assert.notEqual(start, -1); assert.notEqual(end, -1); const wizard = source.slice(start, end); for (const expected of [ - "readToolchainInventory", "buildModuleSetupPlan", "moduleSetupConfirmation", - "runAutomaticModuleSetup", "executeModuleSetup", - "loadProjectContext", + 'runProjectTask("build", { notify: false })', + "refreshLanguageServerAfterBuild", ]) { - assert.match(wizard, new RegExp(expected)); + assert.ok(wizard.includes(expected), `wizard missing ${expected}`); } assert.match(wizard, /modal:\s*true/); - assert.match(wizard, /let currentContext/); assert.doesNotMatch(wizard, /CLI_COMMANDS\.(installToolchain|selectDefaultToolchain)/); - assert.doesNotMatch(wizard, /刷新编译数据库|showQuickPick|showInputBox|maybeDisableCppTools/); - assert.doesNotMatch(wizard, /configureClangd\([^\n]*"interactive"/); + assert.doesNotMatch(wizard, /readToolchainInventory|runAutomaticModuleSetup|ensureClangd|loadProjectContext/); + assert.doesNotMatch(controller, /runAutomaticModuleSetup|executeAutomaticModuleSetupCommand/); +}); + +test("the promised keybindings are contributed and scoped to a project", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const bindings = manifest.contributes?.keybindings ?? []; + assert.deepEqual( + bindings.map((binding) => binding.command), + ["mcpp.build", "mcpp.run", "mcpp.test", "mcpp.cleanProjectArtifacts", "mcpp.showMenu"], + ); + for (const binding of bindings) { + assert.match(binding.key ?? "", /^ctrl\+alt\+[a-z]$/); + assert.match(binding.mac ?? "", /^cmd\+alt\+[a-z]$/); + assert.equal(binding.when, "mcpp.inProject"); + } +}); + +test("each view is gated by its own visibility setting", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // The master switch comes first, in negated form: with `mcpp.views.enabled` off + // every view is hidden and VS Code drops the container from the activity bar. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.when), [ + "!mcpp.sidebarHidden && mcpp.views.project", + "!mcpp.sidebarHidden && mcpp.views.library", + "!mcpp.sidebarHidden && mcpp.views.cache", + ]); + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.type), [ + undefined, + "webview", + "webview", + ]); + // The cache view starts folded, so the sidebar opens on the library list; the + // two above it start open. `visibility` is the only way a view can say this — + // VS Code keeps whatever the user does to the header afterwards. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.visibility), [ + undefined, + undefined, + "collapsed", + ]); + // `initialSize` becomes the view's split-view `weight`, and VS Code hands out + // the container height in proportion to it — defaulting every view to 20 + // (`computeInitialSizes()`: `dimension.height * (weight || 20) / total`). Twice + // the default is what makes the library take the bottom two thirds and leaves + // the tree above it its third, which is the layout the sidebar is for. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.initialSize), [ + undefined, + 40, + undefined, + ]); +}); + +test("every webview view registers the provider that fills it", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const webviews = (manifest.contributes?.views?.mcpp ?? []).filter((view) => view.type === "webview"); + assert.ok(webviews.length > 0, "this gate is pointless without a webview view"); + for (const view of webviews) { + // The view id is declared once, in the module that owns the view (other + // modules re-export the constant), and that module must also call + // `registerWebviewViewProvider`. Without it VS Code never asks the view for + // a document and every `refresh()` silently does nothing — which is exactly + // the bug this test exists for: the library view declared its id, exported a + // provider, wired its listeners, and never registered. + const declaration = new RegExp(`export const \\w*_VIEW_ID = "${view.id}"`); + const owners = sourceFiles().filter(([, text]) => declaration.test(text)); + assert.equal(owners.length, 1, `${view.id} must have exactly one declaring module, found ${owners.length}`); + assert.ok( + owners[0][1].includes("registerWebviewViewProvider("), + `${owners[0][0]} declares ${view.id} but never calls registerWebviewViewProvider`, + ); + } +}); + +test("the library view cannot reload itself in a loop", () => { + const view = readFileSync(path.join(root, "src/library", "libraryView.ts"), "utf8"); + const html = readFileSync(path.join(root, "src/library", "libraryHtml.ts"), "utf8"); + // Assigning `webview.html` reloads the document. The library document used to + // announce its own load with a `ready` message and the host answered it with a + // repaint, so the view reloaded itself forever: flicker, rows that could not be + // clicked, a pegged CPU. Both halves of that handshake are gone. + assert.doesNotMatch(html, /post\(\{\s*type:\s*"ready"\s*\}\)/, "the document must not announce its own load"); + assert.doesNotMatch(view, /case "ready"/, "the host must not answer a load with a re-render"); + assert.doesNotMatch(html, /\| \{ type: "ready" \}/); + // Two guards keep it shut: the render-only-on-change comparison, and a nonce + // that is per *view* — a nonce per render would make every render a different + // document, so the comparison could never say "unchanged". Both now live in + // the shared WebviewDocument kit, so the gate holds the host to the kit + // instead of to one implementation of it. Comments are stripped first: the + // prose explains the rule with the very assignment it forbids. + const viewCode = view.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + assert.match(viewCode, /new WebviewDocument\(/, "the host must hold its document through the kit"); + assert.doesNotMatch(viewCode, /randomNonce\(\)/, "the host must not mint its own nonce"); + assert.doesNotMatch(viewCode, /webview\.html\s*=/, "the host must assign html only through the kit"); + // A freshly resolved view is a new, empty webview: the last document says + // nothing about it, so it must be forgotten when the old view goes away. + assert.match(view, /\.invalidate\(\);/); +}); + +test("every webview host holds its document through the kit", () => { + // The nonce and the html assignment are the two halves of the reload-loop + // accident (see the library gate above); since the kit extraction they are + // owned once. Every host — library, cache, settings panel, detail page — + // must go through `WebviewDocument`, and a nonce may be minted nowhere but + // the kit. + const hosts = [ + "src/library/libraryView.ts", + "src/cache/cachePanel.ts", + "src/config/panel.ts", + "src/library/detailPanel.ts", + ]; + for (const host of hosts) { + const text = readFileSync(path.join(root, host), "utf8"); + // Comments are stripped: the prose explains the rules with the very code + // it forbids. + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + assert.match(code, /new WebviewDocument\(/, `${host} must hold its document through the kit`); + assert.doesNotMatch(code, /\.html\s*=/, `${host} must assign html only through the kit`); + } + const offenders: string[] = []; + for (const [file, text] of sourceFiles()) { + if (file === path.join("src", "webview", "document.ts")) { + continue; + } + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/randomBytes/.test(code)) { + offenders.push(file); + } + } + assert.deepEqual(offenders, [], "a webview nonce is minted outside src/webview/document.ts"); +}); + +test("no module drives a second language client", () => { + // The same rule the `package` job enforces, but runnable here: the CI grep used + // to match the bare name, and the compiled comments that explain why this + // extension does *not* use a language client made it fail on a clean build. + const offenders: string[] = []; + for (const [file, text] of sourceFiles()) { + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/from\s+["']vscode-languageclient["']|require\(["']vscode-languageclient["']\)/.test(code)) { + offenders.push(file); + } + } + assert.deepEqual(offenders, [], "a language client is imported again"); +}); + +test("the committed snapshots record a clone-independent commit", () => { + // `git rev-parse --short` shortens to the shortest unique prefix *for that + // clone*, so the same mcpp produced a 7-character hash in a fresh CI checkout + // and 8 in a developer's — and the drift gate went red on an unchanged API. + for (const file of ["data/buildscript-api.json", "data/toml-schema.json"]) { + const snapshot = JSON.parse(readFileSync(path.join(root, file), "utf8")) as { + sourceVersion?: string; + sourceCommit?: string; + }; + assert.match(snapshot.sourceVersion ?? "", /^\d{4}\.\d+\.\d+/, `${file}: no mcpp version`); + assert.match( + snapshot.sourceCommit ?? "", + /^[0-9a-f]{40}$/, + `${file}: the provenance hash must be full length, not an abbreviation`, + ); + } +}); + +test("opening a link always produces an answer", () => { + // The comment above `openExternal` quotes the old call, so comments are + // stripped before the check — the point is the code, not the prose. + const panel = readFileSync(path.join(root, "src", "library", "detailPanel.ts"), "utf8").replace( + /\/\*[\s\S]*?\*\//g, + "", + ); + // `void vscode.env.openExternal(…)` drops the boolean it resolves to, so a host + // that cannot open a browser answered a click with nothing at all. The call is + // awaited, both outcomes are posted to the page, and a failure leaves the URL + // somewhere the reader can use it. + assert.doesNotMatch(panel, /void vscode\.env\.openExternal/); + assert.match(panel, /opened = await vscode\.env\.openExternal\(/); + assert.match(panel, /type: "linkResult"/); + assert.match(panel, /opened \? t\("Opened in your browser\."/); + assert.match(panel, /vscode\.env\.clipboard\.writeText\(message\.url\)/); + assert.match(panel, /copied \? t\("Could not open the browser\. Link copied to clipboard\."/); + const html = readFileSync(path.join(root, "src", "library", "detailHtml.ts"), "utf8"); + assert.match(html, /detail-link-result/); }); +/** `src/**\/*.ts`, relative path and text, for the source-level gates. */ +function sourceFiles(directory = path.join(root, "src")): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(full)); + } else if (entry.name.endsWith(".ts")) { + out.push([path.relative(root, full), readFileSync(full, "utf8")]); + } + } + return out; +} + test("shows editor title buttons only inside mcpp projects", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // Both conditions matter: a project must be open, and the user must not have + // turned the buttons off with `mcpp.task.editorTitleButtons`. assert.deepEqual(manifest.contributes?.menus?.["editor/title"], [ - { command: "mcpp.run", group: "navigation@1", when: "mcpp.inProject" }, - { command: "mcpp.test", group: "navigation@2", when: "mcpp.inProject" }, + { command: "mcpp.run", group: "navigation@1", when: "mcpp.inProject && mcpp.editorTitleButtons" }, + { command: "mcpp.test", group: "navigation@2", when: "mcpp.inProject && mcpp.editorTitleButtons" }, ]); const commands = manifest.contributes?.commands ?? []; @@ -113,31 +347,36 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); }); -test("ships syntax-only C++ highlighting for the exact build.mcpp filename", () => { +test("build.mcpp is a cpp document, not a language of its own", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; const associations = manifest.contributes?.configurationDefaults?.["files.associations"] as | Record | undefined; - const language = manifest.contributes?.languages?.find((item) => item.id === "mcpp-build"); - const grammar = manifest.contributes?.grammars?.find((item) => item.language === "mcpp-build"); - + // 0.6.0 claimed `build.mcpp` as `mcpp-build`. A filename match beats an + // extension match in VS Code, so that hid the file from C++ Modules (which + // selects `cpp`), and the build program got a text check instead of a compiler. + const languages = manifest.contributes?.languages ?? []; + assert.equal(languages.find((item) => item.id === "mcpp-build"), undefined); + assert.deepEqual(languages.find((item) => item.id === "cpp"), { id: "cpp", filenames: ["build.mcpp"] }); + assert.equal(manifest.contributes?.grammars?.find((item) => item.language === "mcpp-build"), undefined); assert.equal(associations?.["build.mcpp"], undefined); assert.equal(associations?.["*.mcpp"], undefined); - assert.deepEqual(language, { - id: "mcpp-build", - aliases: ["mcpp build script", "build.mcpp"], - filenames: ["build.mcpp"], - configuration: "./syntaxes/mcpp-build-language-configuration.json", - }); - assert.deepEqual(grammar, { - language: "mcpp-build", - scopeName: "source.mcpp-build", - path: "./syntaxes/mcpp-build.tmLanguage.json", - }); + assert.ok(!existsSync(path.join(root, "syntaxes/mcpp-build.tmLanguage.json"))); + assert.ok(!existsSync(path.join(root, "syntaxes/mcpp-build-language-configuration.json"))); +}); - const grammarText = readFileSync(path.join(root, "syntaxes/mcpp-build.tmLanguage.json"), "utf8"); - const grammarDocument = JSON.parse(grammarText) as { patterns?: Array<{ include?: string }> }; - assert.ok(grammarDocument.patterns?.some((pattern) => pattern.include === "source.cpp")); +test("restoring other C++ services is offered only on a row that says it can be", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { + contributes?: { menus?: Record> }; + }; + const entries = manifest.contributes?.menus?.["view/item/context"] ?? []; + const restore = entries.find((entry) => entry.command === "mcpp.languageServer.restoreConflicts"); + assert.ok(restore?.when?.includes("view == mcpp.project")); + const pattern = /viewItem =~ \/(.*)\/$/.exec(restore?.when ?? ""); + assert.ok(pattern, "the menu matches the row's contextValue"); + const matches = new RegExp(pattern[1]); + assert.ok(matches.test("mcppOtherCppFeaturesRestorable")); + assert.ok(!matches.test("mcppOtherCppFeatures")); }); test("ships TOML highlighting for the exact mcpp.toml filename", () => { @@ -189,7 +428,7 @@ test("ships an injection grammar with module-specific scopes", () => { const grammar = manifest.contributes?.grammars?.find((item) => item.scopeName === "source.cpp.mcpp-modules"); assert.deepEqual(grammar, { scopeName: "source.cpp.mcpp-modules", - injectTo: ["source.cpp", "source.mcpp-build"], + injectTo: ["source.cpp"], path: "./syntaxes/mcpp-modules.tmLanguage.json", }); @@ -198,7 +437,7 @@ test("ships an injection grammar with module-specific scopes", () => { injectionSelector?: string; repository?: Record; }; - assert.match(grammarDocument.injectionSelector ?? "", /source\.mcpp-build/); + assert.equal(grammarDocument.injectionSelector, "L:source.cpp"); for (const key of ["module-declaration", "import-declaration", "module-name"]) { assert.ok(grammarDocument.repository?.[key], `missing grammar rule: ${key}`); } @@ -220,7 +459,7 @@ test("ships an injection grammar with module-specific scopes", () => { }); test("设置全局默认后先释放工具链锁再提供立即构建", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("private async selectDefaultToolchainFromInventory"); const end = source.indexOf("private async pickInstallSpec", start); assert.notEqual(start, -1); @@ -232,8 +471,8 @@ test("设置全局默认后先释放工具链锁再提供立即构建", () => { assert.ok(unlock >= 0 && unlock < immediateBuild); }); -test("项目任务结束后先释放项目锁再重新协调 IDE", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); +test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async runProjectTask"); const end = source.indexOf("public async showToolchains", start); assert.notEqual(start, -1); @@ -241,12 +480,12 @@ test("项目任务结束后先释放项目锁再重新协调 IDE", () => { const method = source.slice(start, end); const unlock = method.indexOf("this.operations.finishProject(project.root, token)"); - const reconcile = method.indexOf("this.options.afterProjectTask(project, kind, completion)"); - assert.ok(unlock >= 0 && unlock < reconcile); + const refresh = method.indexOf("this.options.afterProjectTask(project, kind, completion)"); + assert.ok(unlock >= 0 && unlock < refresh); }); test("安装流程把系统工具链和 target 兼容 spec 交给 mcpp 解析", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async installToolchain"); const end = source.indexOf("public async selectDefaultToolchain", start); assert.notEqual(start, -1); @@ -261,16 +500,16 @@ test("安装流程把系统工具链和 target 兼容 spec 交给 mcpp 解析", }); test("泛化 triple 工具链由 mcpp 最终校验", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async installToolchain"); const end = source.indexOf("public async selectDefaultToolchain", start); const method = source.slice(start, end); - assert.match(method, /可能携带 target 语义.*最终由 mcpp 校验/s); + assert.match(method, /may carry target semantics.*mcpp validates it in the end/s); }); test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async newProject"); const end = source.indexOf("private guarded", start); assert.notEqual(start, -1); @@ -292,7 +531,7 @@ test("新建工程先校验目标路径再确认创建,成功后只打开不 }); test("新建工程契约是创建并打开,不自动构建", () => { - const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const extension = readFileSync(path.join(root, "src/extension.ts"), "utf8"); assert.doesNotMatch(controller, /globalState|PENDING_NEW_PROJECT/); assert.doesNotMatch(extension, /PENDING_NEW_PROJECT/); @@ -300,28 +539,71 @@ 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.icon, "media/logo.png"); + 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]); }); -test("README 区分 mcpp 的 MSVC 构建能力和 clangd 的 IFC 限制", () => { +test("the activity bar gets the stencil, not the marketplace badge", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const container = manifest.contributes?.viewsContainers?.activitybar?.[0]; + // VS Code masks a container icon with the theme's foreground colour + // (`mask: url(icon)` at 24 px), so what is painted is the icon's *alpha + // channel*. The official badge is an opaque rounded square with the wordmark + // on it: as a stencil that is a solid block, which is what shipped. The + // marketplace icon keeps the real logo; the activity bar gets its derived + // stencil, and `npm run check:icon` proves the derivation is current. + assert.equal(manifest.icon, "media/logo.png"); + assert.equal(container?.icon, "media/activity-bar.png"); + const stencil = readFileSync(path.join(root, "media", "activity-bar.png")); + assert.deepEqual([...stencil.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); + // Read from IHDR: 8-bit RGBA, and wider than tall, because the wordmark is. + assert.equal(stencil.readUInt32BE(16), 96); + assert.equal(stencil.readUInt32BE(20), 56); +}); + +test("the READMEs state the responsibility split, the boundary and the limits", () => { + // The English README is the Marketplace listing; the Chinese one is its mirror. const readme = readFileSync(path.join(root, "README.md"), "utf8"); - assert.match(readme, /0\.0\.90 起 native `cl\.exe` 后端支持/); - assert.match(readme, /mcpp 可以正常构建它们,但 clangd\s+不能直接消费/); - assert.doesNotMatch(readme, /native MSVC\s+构建仍提示/); + const chinese = readFileSync(path.join(root, "README.zh-CN.md"), "utf8"); + + for (const text of [readme, chinese]) { + assert.match(text, /sunrisepeak\.mcpp-language-server/); + assert.match(text, /darwin-x64/); + assert.match(text, /mcpp\.path/); + } + assert.match(readme, /README\.zh-CN\.md/); + assert.match(chinese, /README\.md/); + + // The claims that must not creep back in: this extension is not a language + // client, does not read clangd settings, and does not claim LLVM-only support. + for (const text of [readme, chinese]) { + assert.doesNotMatch(text, /starts? (a|its own) (second )?LSP client/i); + assert.doesNotMatch(text, /reads your `clangd\./); + } + assert.doesNotMatch(readme, /only LLVM/i); }); test("嵌套工程提示不猜测它一定是 mcpp 工作区成员", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); assert.doesNotMatch(source, /isWorkspaceMember/); assert.doesNotMatch(source, /当前是工作区成员/); }); +test("the rename offer is only marked answered after the user answered it", () => { + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + const body = /async function offerSettingRenames[\s\S]*?\n\}/.exec(source)?.[0] ?? ""; + assert.ok(body.length > 0, "offerSettingRenames must exist"); + assert.ok( + body.indexOf("showInformationMessage") < body.indexOf('workspaceState.update("mcpp.renamesOffered", true)'), + "the marker is written before the prompt again: a dismissed popup would strand the user's renamed settings forever (external review P2-9)", + ); +}); + test("tag release 工作流校验版本并发布 VSIX", () => { const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); assert.match(workflow, /push:\s*\n\s+tags:\s*\n\s+- "v\*"/); @@ -335,18 +617,74 @@ test("tag release 工作流校验版本并发布 VSIX", () => { assert.match(workflow, /gh release upload.*--clobber/s); }); -test("PR CI 分离单元打包和 Extension Host E2E", () => { +test("the release workflow guards the published artefact against local leakage", () => { + const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); + assert.match(workflow, /校验 VSIX 体积与内容/); + const forbidden = /for forbidden in ([^;\n]+)/.exec(workflow)?.[1].trim().split(/\s+/) ?? []; + for (const directory of [".dev-profile/", ".agents/", ".agnets/", "test/", "tools/", "src/", "node_modules/"]) { + assert.ok(forbidden.includes(directory), `${directory} must be excluded from the VSIX`); + } + assert.match(workflow, /unzip -t/); +}); + +test("tag release 同时发布两个市场:Open VSX 必发,Marketplace 未配 token 则跳过", () => { + const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); + // 两个市场收到的都是 GitHub Release 上的那只 VSIX,不重新打包。Open VSX 的 + // secret 名就是仓库实际配置的 OPENVSX_TOKEN(曾写成 OVSX_PAT,首版必然失败)。 + assert.match(workflow, /npx ovsx publish "mcpp-vscode-\$\{PACKAGE_VERSION\}\.vsix" --pat "\$OPENVSX_TOKEN"/); + assert.match(workflow, /npx vsce publish --packagePath "mcpp-vscode-\$\{PACKAGE_VERSION\}\.vsix"/); + // Open VSX 是必发目标:缺 token 显式失败。 + assert.match(workflow, /OPENVSX_TOKEN: \$\{\{ secrets\.OPENVSX_TOKEN \}\}/); + assert.match(workflow, /if \[ -z "\$\{OPENVSX_TOKEN:-\}" \]; then[\s\S]*?exit 1/); + // Marketplace 机会发布:缺 token 打一行说明就过,Release 不失败。 + assert.match(workflow, /VSCE_PAT: \$\{\{ secrets\.VSCE_PAT \}\}/); + assert.match(workflow, /if \[ -z "\$\{VSCE_PAT:-\}" \]; then[\s\S]*?exit 0/); + // 发布用的是仓库锁定的 ovsx:npx 不在发布中途去网络解析。 + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + assert.ok(manifest.devDependencies?.ovsx, "ovsx must be a devDependency the release resolves offline"); +}); + +test("CI runs the gates, the package checks, the drift check and every e2e variant", () => { const workflow = readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8"); assert.match(workflow, /pull_request:/); assert.match(workflow, /push:\s*\n\s+branches:\s*\n\s+- main/); assert.match(workflow, /permissions:\s*\n\s+contents: read/); assert.match(workflow, /concurrency:[\s\S]*cancel-in-progress: true/); - assert.match(workflow, /node-version: 22/); - assert.match(workflow, /unit-and-package:/); + assert.match(workflow, /node-version: \$\{\{ env.NODE_VERSION \}\}/); + assert.match(workflow, /NODE_VERSION: 22/); + // Cross-platform confidence: the unit gates, the Extension Host e2e and the + // isolated install all run the same three-OS matrix — Linux, macOS ARM64 and + // Windows, where the spawn and path handling actually differ. + const osMatrixes = workflow.match(/os: \[ubuntu-latest, macos-14, windows-latest\]/g) ?? []; + assert.equal( + osMatrixes.length, + 3, + "gates, extension-host-e2e and isolated-install must all run on Linux, macOS and Windows", + ); + assert.match(workflow, /gates:/); assert.match(workflow, /extension-host-e2e:/); + assert.match(workflow, /package:/); assert.match(workflow, /npm ci/); assert.match(workflow, /npm test/); assert.match(workflow, /npm run package/); assert.match(workflow, /unzip -t/); - assert.match(workflow, /xvfb-run -a npm run test:e2e/); + // A local dev profile once leaked into the VSIX (64 MB); the guard is now explicit. + assert.match(workflow, /The VSIX stays small and free of local state/); + const forbidden = /for forbidden in ([^;\n]+)/.exec(workflow)?.[1].trim().split(/\s+/) ?? []; + for (const directory of [".dev-profile/", ".agents/", ".agnets/", "test/", "tools/", "src/", "node_modules/"]) { + assert.ok(forbidden.includes(directory), `${directory} must be excluded from the VSIX`); + } + assert.match(workflow, /xvfb-run -a npm run test:e2e:one/); + // The snapshot drift gate only means something with an mcpp checkout present. + assert.match(workflow, /generated-drift:/); + assert.match(workflow, /repository: mcpp-community\/mcpp/); + assert.match(workflow, /MCPP_REPO: \$\{\{ github.workspace \}\}\/\.mcpp-source/); + // The end-to-end dependency resolution check. + assert.match(workflow, /isolated-install:/); + assert.match(workflow, /sunrisepeak.mcpp-language-server@/); + // The ubuntu runner ships no `code` on PATH, so the job downloads the CLI via + // the e2e tooling instead. A step that calls bare `code` again is the exact + // `code: command not found` failure this gate was added after. + assert.match(workflow, /tools\/ci-vscode-cli\.mjs/); + assert.doesNotMatch(workflow, /^\s+code --/m); }); diff --git a/test/buildscript/analysis.test.ts b/test/buildscript/analysis.test.ts new file mode 100644 index 0000000..96358cd --- /dev/null +++ b/test/buildscript/analysis.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DIAGNOSTIC_CODES, analyseBuildScript } from "../../src/buildscript/analysis"; + +function codes(lines: readonly string[]): string[] { + return analyseBuildScript(lines, "warning").map((entry) => entry.code); +} + +test("a clean, realistic build script earns nothing", () => { + const lines = [ + "import std;", + "import mcpp;", + "", + "int main() {", + ' const std::string out = std::string(mcpp::out_dir()) + "/foo.pb.cc";', + ' mcpp::cxxflag("-DHAVE_BANNER=1");', + ' mcpp::link_lib("m");', + ' mcpp::link_search("vendor/lib");', + ' mcpp::link_flag("-Wl,--version-script=gen.map");', + ' mcpp::define("HAVE_FEATURE");', + ' mcpp::generated("src/gen.cpp");', + ' mcpp::include_dir("vendor/include");', + ' mcpp::runtime_search_dir("lib");', + ' mcpp::rerun_if_changed("config.h");', + ' mcpp::rerun_if_env_changed("USE_FAST");', + " mcpp::action a;", + ' a.id = "protoc:foo";', + " a.role = mcpp::roles::source;", + ' a.arg(mcpp::dep_bin("protobuf", "protoc"))', + ' .arg("--cpp_out=gen")', + ' .output("gen/foo.pb.cc")', + " .submit();", + " return 0;", + "}", + ]; + assert.deepEqual(analyseBuildScript(lines, "warning"), []); +}); + +test("rule 1 flags an unknown mcpp:: name and only that", () => { + assert.deepEqual(codes(["int main() {", ' mcpp::cxxflagk("-O2");', "}"]), [ + DIAGNOSTIC_CODES.unknownSymbol, + ]); + assert.deepEqual( + codes([ + ' mcpp::cxxflag("-O2");', + " mcpp::roles::prepare;", + ' mcpp::warning("x");', + ' mcpp::xpkg_source("a", "b");', + ' mcpp::toolchain("k", "v");', + ' mcpp::has_feature("f");', + ' mcpp::pack_format("dir");', + ' mcpp::windows_subsystem("app", "windows");', + ]), + [], + ); +}); + +test("rule 1 ignores mcpp:: inside strings and comments", () => { + assert.deepEqual( + codes([ + ' const char* s = "mcpp::bogus";', + " // mcpp::bogus();", + " /* mcpp::bogus(); */", + " const char* t = R\"(mcpp::bogus;)\";", + ]), + [], + ); +}); + +test("rule 1 reports 1-based line and column, at the requested severity", () => { + const diagnostics = analyseBuildScript(["mcpp::bogus();"], "info"); + assert.equal(diagnostics.length, 1); + assert.deepEqual( + { + code: diagnostics[0].code, + line: diagnostics[0].line, + startCharacter: diagnostics[0].startCharacter, + endCharacter: diagnostics[0].endCharacter, + severity: diagnostics[0].severity, + }, + { + code: DIAGNOSTIC_CODES.unknownSymbol, + line: 1, + startCharacter: 1, + endCharacter: 12, + severity: "info", + }, + ); +}); + +test("rule 2 flags a role spelled as a string literal", () => { + assert.deepEqual(codes(["mcpp::action a;", 'a.role = "source";', "a.submit();"]), [ + DIAGNOSTIC_CODES.roleLiteral, + ]); + assert.deepEqual(codes(["mcpp::action a;", "a.role = mcpp::roles::source;", "a.submit();"]), []); + // The frozen printf surface is allowed to spell the string (docs/30). + assert.deepEqual(codes(['printf("mcpp:action={\\"role\\":\\"source\\"}");']), []); +}); + +test("rule 3 flags a prepare action with no output_dir", () => { + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::prepare;", + 'a.arg("install");', + "a.submit();", + ]), + [DIAGNOSTIC_CODES.prepareNeedsOutputDir], + ); + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::prepare;", + "a.output_dir(prefix.c_str());", + "a.submit();", + ]), + [], + ); + // The raw payload carries the same obligation. + assert.deepEqual(codes(['printf("mcpp:action={\\"role\\":\\"prepare\\"}");']), [ + DIAGNOSTIC_CODES.prepareNeedsOutputDir, + ]); +}); + +test("rule 4 flags a run path spelled in link_flag", () => { + assert.deepEqual(codes(['mcpp::link_flag("-Wl,-rpath,$ORIGIN/../lib");']), [ + DIAGNOSTIC_CODES.rpathInLinkFlag, + ]); + assert.deepEqual( + codes([ + 'mcpp::link_flag("-Wl,--version-script=x.map");', + 'mcpp::runtime_search_dir("lib");', + ]), + [], + ); +}); + +test("rule 5 flags -I and -L spelled in raw flags", () => { + assert.deepEqual(codes(['mcpp::cxxflag("-Ivendor/include");']), [DIAGNOSTIC_CODES.rawPathFlag]); + assert.deepEqual(codes(['mcpp::link_flag("-Lvendor/lib");']), [DIAGNOSTIC_CODES.rawPathFlag]); + assert.deepEqual( + codes(['mcpp::include_dir("vendor/include");', 'mcpp::link_search("vendor/lib");']), + [], + ); +}); + +test("rule 6 flags shell syntax in an action command", () => { + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::source;", + 'a.arg("CFLAGS=-O2").arg("make");', + "a.submit();", + ]), + [DIAGNOSTIC_CODES.shellSyntaxInAction], + ); + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::source;", + 'a.arg("cd tools && make");', + "a.submit();", + ]), + [DIAGNOSTIC_CODES.shellSyntaxInAction], + ); + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::source;", + 'a.env("GEN_MODE", "release").cwd("tools");', + 'a.arg("--out=build");', + "a.submit();", + ]), + [], + ); +}); + +test("rule 7 flags a missing or unknown action role", () => { + assert.deepEqual(codes(["mcpp::action a;", 'a.arg("gen");', "a.submit();"]), [ + DIAGNOSTIC_CODES.actionNeedsRole, + ]); + assert.deepEqual(codes(["mcpp::action a;", 'a.role = "banana";', "a.submit();"]), [ + DIAGNOSTIC_CODES.actionNeedsRole, + ]); + assert.deepEqual(codes(["mcpp::action a;", "a.role = mcpp::roles::object;", "a.submit();"]), []); +}); + +test("each rule id is the SPEC-007 code the plan names", () => { + assert.deepEqual(Object.values(DIAGNOSTIC_CODES), [ + "mcpp.buildscript.unknownSymbol", + "mcpp.buildscript.roleLiteral", + "mcpp.buildscript.prepareNeedsOutputDir", + "mcpp.buildscript.rpathInLinkFlag", + "mcpp.buildscript.rawPathFlag", + "mcpp.buildscript.shellSyntaxInAction", + "mcpp.buildscript.actionNeedsRole", + ]); +}); + +test("rule 1 leaves a namespace the file imports to the module that brings it", () => { + // The qt-demo shape: `import mcpp.rules.qt;` and then `mcpp::rules::qt::…`. + const lines = [ + "import std;", + "import mcpp;", + "import mcpp.rules.qt;", + "", + "int main() {", + " auto qt = mcpp::rules::qt::options{};", + " mcpp::rules::qt::apply(qt);", + " mcpp::nope();", + "}", + ]; + assert.deepEqual(codes(lines), [DIAGNOSTIC_CODES.unknownSymbol]); + assert.match(analyseBuildScript(lines, "warning")[0].message, /mcpp::nope/); +}); + +test("rule 1 does not count an import that is commented out", () => { + const lines = ["// import mcpp.rules.qt;", "int main() { mcpp::rules::qt::apply(); }"]; + assert.deepEqual(codes(lines), [DIAGNOSTIC_CODES.unknownSymbol]); +}); + +test("with rule 1 off, the SPEC-007 rule checks still run", () => { + const lines = [ + "import mcpp;", + "int main() {", + " mcpp::nope();", + ' mcpp::link_flag("-Wl,-rpath,/opt/lib");', + "}", + ]; + const found = analyseBuildScript(lines, "warning", { unknownSymbols: false }).map((entry) => entry.code); + assert.deepEqual(found, [DIAGNOSTIC_CODES.rpathInLinkFlag]); +}); diff --git a/test/buildscript/api.test.ts b/test/buildscript/api.test.ts new file mode 100644 index 0000000..efab84d --- /dev/null +++ b/test/buildscript/api.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import apiJson from "../../data/buildscript-api.json"; +import { + ACTION_ROLES, + API, + apiProblems, + apiSource, + directive, + directives, +} from "../../src/buildscript/api"; + +// Compiled layout: /dist/test/buildscript/*.js. +const repositoryRoot = path.resolve(__dirname, "..", "..", ".."); + +/** The mcpp checkout to compare against, when one is reachable. */ +function findMcppRepo(): string | undefined { + const candidates = [ + process.env.MCPP_REPO, + path.resolve(process.cwd(), "..", "mcpp"), + path.resolve(repositoryRoot, "..", "mcpp"), + ].filter((candidate): candidate is string => typeof candidate === "string"); + return candidates.find((candidate) => + fs.existsSync(path.join(candidate, "modules", "buildmcpp", "src", "directives.cppm")), + ); +} + +const mcppRepo = findMcppRepo(); +const skipReason = + mcppRepo === undefined + ? "no mcpp checkout found (set MCPP_REPO to compare against the source)" + : false; + +test("the snapshot loads with a usable shape", () => { + assert.equal(typeof API.sourceVersion, "string"); + assert.ok(API.sourceVersion.length > 0); + assert.equal(typeof API.sourceCommit, "string"); + assert.ok(API.protocolVersion >= 1); + assert.ok(API.cacheEpoch >= 1); + assert.equal(API.directives.length, 31); + assert.equal(directives().length, apiJson.directives.length); +}); + +test("the snapshot's own invariants hold", () => { + assert.deepEqual(apiProblems(), []); +}); + +test("every role the engine knows is present", () => { + assert.equal(API.roles.length, ACTION_ROLES.length); + for (const role of ACTION_ROLES) { + assert.ok(API.roles.includes(role), `missing role ${role}`); + } +}); + +test("directive() resolves wire names and only wire names", () => { + const cxxflag = directive("cxxflag"); + assert.ok(cxxflag); + assert.equal(cxxflag.slot, "CxxFlags"); + assert.equal(cxxflag.scope, "PackagePrivate"); + assert.equal(cxxflag.transform, "Verbatim"); + assert.equal(cxxflag.mustExist, false); + assert.ok(cxxflag.since >= 1); + assert.ok(Array.isArray(cxxflag.rules)); + assert.equal(directive("cxx_flag"), undefined); + assert.equal(directive("no-such-directive"), undefined); +}); + +test("apiSource() reports the generated provenance", () => { + assert.deepEqual(apiSource(), { + version: API.sourceVersion, + commit: API.sourceCommit, + }); +}); + +test("provisions carry the kind and the wire name", () => { + assert.ok(API.provisions.length >= 3); + const tool = API.provisions.find((entry) => entry.wire === "tool"); + assert.ok(tool); + assert.equal(tool.kind, "tool"); + for (const entry of API.provisions) { + assert.equal(typeof entry.kind, "string"); + assert.ok(entry.wire.length > 0); + } +}); + +test( + "the table size and every wire name match the mcpp source", + { skip: skipReason }, + () => { + assert.ok(mcppRepo); + const source = fs.readFileSync( + path.join(mcppRepo, "modules", "buildmcpp", "src", "directives.cppm"), + "utf8", + ); + const declared = /inline constexpr\s+std::array\s+kTable\{\{/.exec(source); + assert.ok(declared, "the declared kTable size is missing"); + + const open = source.indexOf("kTable{{"); + const body = source.slice(open, source.indexOf("}};", open)); + const rows = body + .split(/\r?\n/) + .filter((line) => /Slot::\w+\s*,\s*Scope::\w+\s*,\s*Transform::\w+/.test(line)); + + assert.equal(Number(declared[1]), directives().length); + assert.equal(rows.length, directives().length); + for (const entry of directives()) { + assert.ok(body.includes(`"${entry.wire}"`), `source has no row for ${entry.wire}`); + } + }, +); diff --git a/test/buildscript/modules.test.ts b/test/buildscript/modules.test.ts new file mode 100644 index 0000000..0df8cff --- /dev/null +++ b/test/buildscript/modules.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isKnownModule, + knownModule, + knownModules, + scanImports, +} from "../../src/buildscript/modules"; + +test("scans plain, exported and dotted imports", () => { + const sites = scanImports([ + "import std;", + "export import mcpp;", + "import mcpp.plugins.tool; // trailing comment", + ]); + assert.deepEqual( + sites.map((site) => site.module), + ["std", "mcpp", "mcpp.plugins.tool"], + ); + assert.deepEqual( + sites.map((site) => site.line), + [0, 1, 2], + ); +}); + +test("reports the module name's own span, not the statement's", () => { + const [site] = scanImports(["import mcpp.core; "]); + assert.equal(site.module, "mcpp.core"); + assert.equal(site.startCharacter, 9); + assert.equal(site.endCharacter, 18); +}); + +test("finds two imports on one line", () => { + const sites = scanImports(["import a; import b.c;"]); + assert.deepEqual( + sites.map((site) => site.module), + ["a", "b.c"], + ); +}); + +test("ignores import inside strings, line comments and block comments", () => { + assert.deepEqual( + scanImports([ + "// import fake;", + 'const char* s = "import fake;";', + "/* import fake; */", + "/*", + "import fake;", + "*/", + 'const char* t = R"(import fake;)";', + "important(); // import fake;", + ]), + [], + ); +}); + +test("ignores header units, header imports and partitions", () => { + assert.deepEqual(scanImports(['import ;', 'import "vector.h";', "import :part;"]), []); +}); + +test("a comment after a real import does not hide it", () => { + const sites = scanImports(["import std; // import fake;"]); + assert.deepEqual( + sites.map((site) => site.module), + ["std"], + ); +}); + +test("recognises the standard library, the engine and mcpp plugins", () => { + for (const name of [ + "std", + "std.compat", + "mcpp", + "mcpp.core", + "mcpp.plugins.tool", + "mcpp.plugins.a.b", + "mcpp.deps.vcpkg", + "mcpp.rules.qt", + ]) { + assert.equal(isKnownModule(name), true, `${name} should be known`); + } +}); + +test("does not claim names that are not modules", () => { + for (const name of [ + "mcpp.toml", + "mcppx", + "stdlib", + "std.compat.x", + "mcpp.plugins", + "mcpp.plugins.", + "mcpp.core.x", + "", + ]) { + assert.equal(isKnownModule(name), false, `${name} should not be known`); + } +}); + +test("knownModule() describes a wildcard match with the queried name", () => { + const plugin = knownModule("mcpp.plugins.tool"); + assert.ok(plugin); + assert.equal(plugin.name, "mcpp.plugins.tool"); + assert.ok(plugin.description.length > 0); + assert.ok(plugin.docsUrl?.startsWith("https://github.com/mcpp-community/mcpp")); + assert.equal(knownModule("mcpp.toml"), undefined); + assert.equal(knownModule("nope"), undefined); +}); + +test("knownModules() lists the exact names a build program can import", () => { + const names = knownModules().map((entry) => entry.name); + for (const name of ["std", "std.compat", "mcpp", "mcpp.core", "mcpp.plugins.*"]) { + assert.ok(names.includes(name), `missing ${name}`); + } + for (const entry of knownModules()) { + assert.ok(entry.description.length > 0, `${entry.name} has no description`); + } +}); diff --git a/test/buildscript/settings.test.ts b/test/buildscript/settings.test.ts new file mode 100644 index 0000000..6d122a3 --- /dev/null +++ b/test/buildscript/settings.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { analyseBuildScript } from "../../src/buildscript/analysis"; +import { isKnownModule } from "../../src/buildscript/modules"; +import { buildScriptContributions } from "../../src/buildscript/settings"; + +/** + * The four boolean keys `settings.ts` resolves, with the registry's defaults. + * A stub reader records what was asked for, so a wiring change that stops + * reading a key fails the first test instead of silently keeping a default. + */ +const DEFAULTS: Readonly> = { + "mcpp.buildScript.intelligence": true, + "mcpp.buildScript.diagnostics": true, + "mcpp.buildScript.snippets": true, + "mcpp.buildScript.imports.knownModules": true, +}; + +function reader(overrides: Record = {}): { + read: (key: string) => T; + keys: string[]; +} { + const keys: string[] = []; + const values: Record = { ...DEFAULTS, ...overrides }; + return { + keys, + read: (key: string): T => { + keys.push(key); + return values[key] as T; + }, + }; +} + +test("all four switches are resolved, each from its own registry key", () => { + const { read, keys } = reader(); + const contributions = buildScriptContributions(read, () => "warning"); + assert.deepEqual(contributions, { + intelligence: true, + diagnosticSeverity: "warning", + snippets: true, + knownModules: true, + }); + // The four literal keys matter: `test/config/wiring.test.ts` scans the sources + // for exactly this accessor shape to prove each declared setting is read. + assert.deepEqual(keys.sort(), [ + "mcpp.buildScript.diagnostics", + "mcpp.buildScript.imports.knownModules", + "mcpp.buildScript.intelligence", + "mcpp.buildScript.snippets", + ]); +}); + +test("mcpp.buildScript.snippets=false drops snippets and nothing else", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.snippets": false }).read, + () => "warning", + ); + assert.equal(contributions.snippets, false); + assert.equal(contributions.intelligence, true, "snippets must not turn the whole layer off"); + assert.equal(contributions.diagnosticSeverity, "warning", "diagnostics must stay"); + assert.equal(contributions.knownModules, true, "import completion must stay"); +}); + +test("mcpp.buildScript.imports.knownModules=false drops only the completion list", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.imports.knownModules": false }).read, + () => "warning", + ); + assert.equal(contributions.knownModules, false); + assert.equal(contributions.intelligence, true); + assert.equal(contributions.snippets, true); + assert.equal(contributions.diagnosticSeverity, "warning"); +}); + +test("mcpp.buildScript.diagnostics=false stops diagnostics but keeps completion and hover", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.diagnostics": false }).read, + () => "warning", + ); + assert.equal(contributions.diagnosticSeverity, undefined); + assert.equal(contributions.intelligence, true); + assert.equal(contributions.snippets, true); + assert.equal(contributions.knownModules, true); +}); + +test("mcpp.buildScript.intelligence=false leaves no build.mcpp intelligence at all", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.intelligence": false }).read, + () => "off", + ); + assert.equal(contributions.intelligence, false); + assert.equal(contributions.diagnosticSeverity, undefined); + assert.equal(contributions.snippets, false); + assert.equal(contributions.knownModules, false); +}); + +test("a severity of off publishes nothing, and a valid severity is passed through", () => { + const off = buildScriptContributions(reader().read, () => "off"); + assert.equal(off.diagnosticSeverity, undefined); + assert.equal(off.intelligence, true, "severity=off must not disable completion or hover"); + + const info = buildScriptContributions(reader().read, () => "info"); + assert.equal(info.diagnosticSeverity, "info"); +}); + +test("known modules stay recognised and are never reported missing with every switch off", () => { + const { read } = reader({ + "mcpp.buildScript.intelligence": false, + "mcpp.buildScript.diagnostics": false, + "mcpp.buildScript.snippets": false, + "mcpp.buildScript.imports.knownModules": false, + }); + const contributions = buildScriptContributions(read, () => "off"); + assert.equal(contributions.diagnosticSeverity, undefined, "no diagnostics with everything off"); + + // §3.2.1: mcpp keeps `build.mcpp` out of the compilation database, so this + // extension can never validate an import. The "never report std/std.compat/ + // mcpp.* as missing" rule is therefore absolute and lives in the analyser and + // the recognition table, not behind `mcpp.buildScript.imports.knownModules` + // (that key removes the *completion list* only). The two `mcpp.toml. + // indexCompletion*` switches act on a different language and cannot reach + // this path, so "every switch off" here is the five `mcpp.buildScript.*` keys. + const lines = [ + "import std;", + "import std.compat;", + "import mcpp;", + "import mcpp.core;", + "import mcpp.plugins.tool;", + "import mcpp.deps.something;", + ]; + assert.deepEqual(analyseBuildScript(lines, "warning"), []); + for (const name of ["std", "std.compat", "mcpp", "mcpp.core", "mcpp.plugins.tool"]) { + assert.equal(isKnownModule(name), true, `${name} stopped being recognised`); + } +}); diff --git a/test/buildscript/snippets.test.ts b/test/buildscript/snippets.test.ts new file mode 100644 index 0000000..d99bbcf --- /dev/null +++ b/test/buildscript/snippets.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ACTION_ROLES, API } from "../../src/buildscript/api"; +import { + actionSnippets, + buildScriptSnippets, + directiveCallSnippets, + snippetsForScope, +} from "../../src/buildscript/snippets"; + +test("every snapshot directive but action has exactly one call snippet", () => { + const expected = new Set( + API.directives.map((entry) => entry.wire.replace(/-/g, "_")).filter((name) => name !== "action"), + ); + const snippets = directiveCallSnippets(); + assert.equal(snippets.length, expected.size); + assert.deepEqual( + [...new Set(snippets.map((snippet) => snippet.filterText))].sort(), + [...expected].sort(), + ); + // A declaration is not a call: `mcpp::action` must not get an `action(...)` stub. + assert.ok(!snippets.some((snippet) => snippet.filterText === "action")); +}); + +test("a directive snippet is the typed spelling with one editable argument", () => { + const snippet = directiveCallSnippets().find((entry) => entry.filterText === "link_script"); + assert.ok(snippet !== undefined, "link_script disappeared from the snippet list"); + assert.equal(snippet.label, "link_script(...)"); + assert.equal(snippet.insertText, 'link_script("${1:value}")'); + assert.match(snippet.detail, /mcpp:link-script=/); +}); + +test("the action snippets carry a role, and the prepare one carries output_dir", () => { + const snippets = actionSnippets(); + assert.equal(snippets.length, 2); + for (const snippet of snippets) { + assert.equal(snippet.filterText, "action"); + assert.match(snippet.insertText, /\.submit\(\);/); + assert.match(snippet.insertText, /mcpp::roles::/); + } + const prepare = snippets.find((snippet) => snippet.label.includes("prepare")); + assert.ok(prepare !== undefined); + assert.match(prepare.insertText, /mcpp::roles::prepare/); + assert.match(prepare.insertText, /\.output_dir\("/); + assert.match(prepare.detail, /R3\.3/); + + const general = snippets.find((snippet) => snippet.label.includes("typed")); + assert.ok(general !== undefined); + // The role choice lists the engine's five roles. + for (const role of ACTION_ROLES) { + assert.ok(general.insertText.includes(role), `${role} is missing from the role choice`); + } +}); + +test("snippetsForScope narrows on the typed mcpp:: prefix", () => { + const all = buildScriptSnippets(); + assert.equal(snippetsForScope("").length, all.length); + const link = snippetsForScope("link_"); + assert.ok(link.length > 0); + assert.ok(link.every((snippet) => snippet.filterText.startsWith("link_"))); + assert.ok(!link.some((snippet) => snippet.filterText === "action")); + assert.equal(snippetsForScope("action").length, actionSnippets().length); + assert.deepEqual(snippetsForScope("zzz_not_a_directive"), []); +}); + +test("every snippet is insertable: it has a placeholder and a non-empty body", () => { + for (const snippet of buildScriptSnippets()) { + assert.ok(snippet.insertText.length > 0, `${snippet.label} has no insert text`); + assert.match(snippet.insertText, /\$\{1[:|]/, `${snippet.label} has no first placeholder`); + assert.ok(snippet.label.length > 0); + assert.ok(snippet.detail.length > 0); + } +}); diff --git a/test/cache/cacheCss.test.ts b/test/cache/cacheCss.test.ts new file mode 100644 index 0000000..1c7c97f --- /dev/null +++ b/test/cache/cacheCss.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * The cache view's stylesheet, checked as text. + * + * Geometry cannot be measured outside a browser, but the properties §8.1 + * actually asks for can be: theme tokens only, tabular figures, the 26 / 12 / 11 + * scale, one 6 px primary bar, a legend that flows as one line, and a narrow-width + * answer. These assertions are what stops the next edit from quietly turning the + * sidebar back into three 14 px bars and a card per kind. + */ + +const css = readFileSync(path.join(process.cwd(), "media", "cache.css"), "utf8"); + +/** Every declaration that can carry a colour. */ +const COLOUR_DECLARATION = + /(?:^|[;{\s])(border-bottom|border-top|border-left|border-right|border-color|border|background-color|background|box-shadow|outline-color|outline|text-decoration-color|text-decoration|fill|stroke|color)\s*:\s*([^;}]+)/gi; + +/** Values that are not a colour at all, so they cannot be a hard-coded one. */ +const NOT_A_COLOUR = new Set([ + "transparent", + "currentColor", + "inherit", + "initial", + "unset", + "none", + "solid", + "dashed", + "dotted", + "double", + "underline", + "line-through", + "overline", + "wavy", + "0", + "1px", + "2px", + "3px", + "1", +]); + +test("every colour comes from a --vscode-* token", () => { + const offenders: string[] = []; + for (const match of css.matchAll(COLOUR_DECLARATION)) { + const property = match[1]; + const value = match[2].trim(); + // Strip the theme references: what is left must be geometry or a keyword. + const rest = value.replace(/var\([^)]*\)/g, " "); + for (const token of rest.split(/[\s,()]+/).filter((part) => part.length > 0)) { + if (NOT_A_COLOUR.has(token)) continue; + if (/^[\d.]+(px|em|rem|%|fr|deg)?$/.test(token)) continue; + offenders.push(`${property}: ${value} (offending token "${token}")`); + } + } + assert.deepEqual(offenders, [], `these declarations carry a literal colour:\n ${offenders.join("\n ")}`); +}); + +test("and no colour is hidden in a hex or function form either", () => { + assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b/i, "a hex colour literal"); + assert.doesNotMatch(css, /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix|light-dark)\(/i, "a functional colour"); +}); + +test("the type scale is §8.1's 26 / 12 / 11 and the figures are tabular", () => { + assert.match(css, /font-variant-numeric:\s*tabular-nums/); + assert.match(css, /\.metric-value\s*\{[^}]*font-size:\s*26px/); + assert.match(css, /font-size:\s*12px/); + assert.match(css, /font-size:\s*11px/); +}); + +test("one 14 px composition bar and 6 px everywhere else", () => { + assert.match(css, /\.viz-bar\s*\{[^}]*height:\s*14px/); + assert.match(css, /\.viz-bar-thin\s*\{[^}]*height:\s*6px/); + // No third bar height sneaks in. + const heights = [...css.matchAll(/height:\s*(\d+)px/g)].map((match) => Number(match[1])); + assert.deepEqual([...new Set(heights)].sort((a, b) => a - b), [1, 6, 8, 14]); +}); + +test("the legend flows as one wrapped line separated by ·", () => { + assert.match(css, /\.legend-inline\s*\{[^}]*display:\s*block/); + assert.match(css, /\.legend-inline\s+\.legend-item\s*\{[^}]*display:\s*inline/); + assert.match(css, /content:\s*" · "/); + assert.doesNotMatch(css, /\.legend[^{]*\{[^}]*display:\s*grid/); +}); + +test("nothing in the stylesheet can force a 170 px sidebar to scroll sideways", () => { + const minimums = [...css.matchAll(/min-width:\s*(\d+)px/g)].map((match) => Number(match[1])); + for (const value of minimums) { + assert.ok(value <= 170, `min-width: ${value}px is wider than the narrowest sidebar`); + } + assert.match(css, /\.actions\s*\{[^}]*flex-wrap:\s*wrap/); + assert.match(css, /overflow-wrap:\s*anywhere/); + assert.match(css, /@media \(max-width: 260px\)/, "there is a rule for a narrow sidebar"); + // At 200 px the table stops being a table and each cell names its column. + assert.match(css, /td\[data-head\]::before\s*\{[^}]*content:\s*attr\(data-head\)/); +}); + +test("the house rules survive: hidden, the focus ring and the dashed disabled state", () => { + assert.match(css, /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/); + assert.match(css, /:focus-visible/); + assert.match(css, /button\[disabled\]/); + assert.match(css, /button\[disabled\][^{]*\{[^}]*border-style:\s*dashed/); +}); + +test("the sections are separated by space, not by nested boxes", () => { + // §8.1: "去掉多余色块边框,靠留白分节" — no block, card or viz draws a border. + for (const selector of ["\\.block\\b", "\\.viz\\b", "\\.card\\b"]) { + const rule = new RegExp(selector + "\\s*\\{[^}]*\\}", "g"); + for (const match of css.matchAll(rule)) { + assert.doesNotMatch(match[0], /\bborder(-[a-z]+)?\s*:/, `${match[0]} draws a border`); + } + } +}); diff --git a/test/cache/cacheLegacy.test.ts b/test/cache/cacheLegacy.test.ts new file mode 100644 index 0000000..e082386 --- /dev/null +++ b/test/cache/cacheLegacy.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { measureDirectory } from "../../src/cli/artifacts"; + +/** + * The bounded walk behind `mcpp.cache.showLegacy`: the pre-v1 cache lives + * outside the workspace, so it is measured by path rather than as `target/`. + * + * Each case creates one throwaway directory and removes it in the test's own + * cleanup hook. + */ + +function withScratch(t: { after(callback: () => void): void }, body: (root: string) => void): void { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-legacy-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + body(root); +} + +test("a missing legacy directory measures as absent with zeros, without throwing", (t) => { + withScratch(t, (root) => { + const estimate = measureDirectory(path.join(root, "does-not-exist")); + assert.equal(estimate.exists, false); + assert.equal(estimate.totalBytes, 0); + assert.equal(estimate.files, 0); + assert.deepEqual(estimate.byTopLevel, []); + }); +}); + +test("a non-empty legacy directory gets a byte figure", (t) => { + withScratch(t, (root) => { + mkdirSync(path.join(root, "pkg", "deep"), { recursive: true }); + writeFileSync(path.join(root, "pkg", "a.bin"), Buffer.alloc(700)); + writeFileSync(path.join(root, "pkg", "deep", "b.bin"), Buffer.alloc(300)); + const estimate = measureDirectory(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 1000); + assert.equal(estimate.files, 2); + }); +}); + +test("an empty legacy directory measures as zero bytes, not as absent", (t) => { + withScratch(t, (root) => { + const estimate = measureDirectory(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 0); + }); +}); + +test("the walk never follows a symlink back into its own ancestor", (t) => { + withScratch(t, (root) => { + mkdirSync(path.join(root, "pkg"), { recursive: true }); + writeFileSync(path.join(root, "pkg", "a.bin"), Buffer.alloc(64)); + symlinkSync(root, path.join(root, "pkg", "loop"), "dir"); + const estimate = measureDirectory(root, { maxEntries: 1000, maxDepth: 8 }); + // The link is counted at zero bytes, so the total stays the 64 it really holds. + assert.equal(estimate.totalBytes, 64); + }); +}); + +test("a walk that runs out of budget reports a floor instead of hanging", (t) => { + withScratch(t, (root) => { + for (let index = 0; index < 20; index += 1) { + writeFileSync(path.join(root, `f${index}.bin`), Buffer.alloc(10)); + } + const estimate = measureDirectory(root, { maxEntries: 5 }); + assert.equal(estimate.truncated, "entries"); + assert.ok(estimate.totalBytes <= 200); + }); +}); diff --git a/test/cache/cachePanelHtml.test.ts b/test/cache/cachePanelHtml.test.ts new file mode 100644 index 0000000..f87e138 --- /dev/null +++ b/test/cache/cachePanelHtml.test.ts @@ -0,0 +1,456 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { + CACHE_PANEL_UI, + ageRampStep, + decodeCachePanelMessage, + renderCachePanelHtml, + type CachePanelAssets, + type CachePanelModel, +} from "../../src/cache/cachePanelHtml"; + +const ASSETS: CachePanelAssets = { + cspSource: "vscode-webview://cache", + nonce: "nonce-cache123", + styleUri: "vscode-webview://cache/media/cache.css", +}; + +const UI: Record = { + [CACHE_PANEL_UI.htmlLang]: "en", + [CACHE_PANEL_UI.title]: "Cache statistics", + [CACHE_PANEL_UI.boundary]: "Sizes are estimates.", + [CACHE_PANEL_UI.projectTitle]: "Project cache", + [CACHE_PANEL_UI.projectFiles]: "{0} file(s) · {1} group(s)", + [CACHE_PANEL_UI.projectStale]: "Stale artifacts: about {0}", + [CACHE_PANEL_UI.sharedTitle]: "Global build cache", + [CACHE_PANEL_UI.sharedEntries]: "{0} entries", + [CACHE_PANEL_UI.sharedRoot]: "Root: {0}", + [CACHE_PANEL_UI.legacyTitle]: "Pre-v1 cache", + [CACHE_PANEL_UI.legacyPath]: "Path: {0}", + [CACHE_PANEL_UI.unknown]: "not recorded", + [CACHE_PANEL_UI.projectUnavailable]: "Project artifacts could not be measured.", + [CACHE_PANEL_UI.sharedUnavailable]: "The shared build cache could not be read.", + [CACHE_PANEL_UI.actions]: "Cache actions", + [CACHE_PANEL_UI.cleanStale]: "Clean stale artifacts", + [CACHE_PANEL_UI.cleanProject]: "Clean project artifacts", + [CACHE_PANEL_UI.prune]: "Drop entries unused for a while", + [CACHE_PANEL_UI.verify]: "Verify the cache", + [CACHE_PANEL_UI.cleanLegacy]: "Remove the pre-v1 cache", + [CACHE_PANEL_UI.collect]: "Collect to this budget", + [CACHE_PANEL_UI.detailsFor]: "Show cache entry details for {0}", + [CACHE_PANEL_UI.reasonShared]: "The shared build cache is not available.", + [CACHE_PANEL_UI.reasonProject]: "Project artifacts are not available.", + [CACHE_PANEL_UI.reasonLegacy]: "There is no pre-v1 cache to remove.", + [CACHE_PANEL_UI.composition]: "Composition by kind", + [CACHE_PANEL_UI.compositionEmpty]: "No cache entries were found.", + [CACHE_PANEL_UI.age]: "Last use", + [CACHE_PANEL_UI.ageEmpty]: "No entry has a recorded last use.", + [CACHE_PANEL_UI.ageUnder]: "under {0} day(s)", + [CACHE_PANEL_UI.ageRange]: "{0}–{1} day(s)", + [CACHE_PANEL_UI.ageOverflow]: "more than {0} day(s)", + [CACHE_PANEL_UI.ageUnknown]: "{0} entries have no recorded last use.", + [CACHE_PANEL_UI.top]: "Largest packages (top {0})", + [CACHE_PANEL_UI.topEmpty]: "No cache label was read.", + [CACHE_PANEL_UI.colLabel]: "Label", + [CACHE_PANEL_UI.colEntries]: "Entries", + [CACHE_PANEL_UI.colBytes]: "Size", + [CACHE_PANEL_UI.colOldest]: "Oldest use", + [CACHE_PANEL_UI.budgetLabel]: "Keep the shared build cache under", + [CACHE_PANEL_UI.budgetHint]: "Simulates mcpp cache gc --max-size.", + [CACHE_PANEL_UI.budgetUnit]: "GiB", + [CACHE_PANEL_UI.incompleteWarning]: "{0} cache entries are incomplete.", + [CACHE_PANEL_UI.sizeWarning]: "The shared build cache is {0}, at or above the {1} warning threshold.", +}; + +interface PageOptions { + project?: Partial; + shared?: Partial; + legacy?: CachePanelModel["legacy"]; + limits?: Partial; + estimate?: string; +} + +/** + * A known cache: 500 B in two kinds (300 B `pkg`, 200 B `std`) and, when asked + * for, a 500 B pre-v1 cache — so the composition percentages are exactly + * 30 / 20 / 50 and the SVG widths are 300 / 200 / 500 out of 1000. The project + * block is a 1000 B `target/` in 3 files over 1 group. + * + * The byte formatter keeps its unit after a space, which is what the metric + * split relies on: `1000 B` -> value `1000`, unit `B`. + */ +function makeModel(options: PageOptions = {}): CachePanelModel { + return { + ui: UI, + project: { available: true, totalBytes: 1000, files: 3, groups: 1, ...options.project }, + shared: { + available: true, + totalBytes: 500, + totalEntries: 4, + byKind: [ + { kind: "pkg", entries: 3, bytes: 300 }, + { kind: "std", entries: 1, bytes: 200 }, + ], + buckets: [ + { fromDays: 0, toDays: 1, entries: 1, bytes: 100 }, + { fromDays: 1, toDays: 7, entries: 1, bytes: 100 }, + { fromDays: 7, toDays: 30, entries: 1, bytes: 100 }, + { fromDays: 30, entries: 1, bytes: 200 }, + ], + top: [{ label: "zlib", entries: 2, bytes: 200, oldestAccessed: 1_758_636_000 }], + incomplete: 0, + ...options.shared, + }, + format: { bytes: (value) => `${value} B`, count: (value) => `c${value}` }, + limits: { topN: 5, warnAboveGiB: 0, ...options.limits }, + ...(options.legacy === undefined ? {} : { legacy: options.legacy }), + ...(options.estimate === undefined ? {} : { estimate: options.estimate }), + }; +} + +function page(options: PageOptions = {}): string { + return renderCachePanelHtml(makeModel(options), ASSETS); +} + +/** The single source line a one-line legend has to fit on. */ +function legendLine(html: string, id: string): string { + const line = html.split("\n").find((candidate) => candidate.includes(`data-legend="${id}"`)); + assert.ok(line !== undefined, `no legend line for ${id}`); + return line; +} + +test("the document carries the nonce and the strict CSP", () => { + const html = page(); + assert.ok(html.includes("default-src 'none'")); + assert.ok(html.includes("style-src vscode-webview://cache;")); + assert.ok(html.includes("script-src 'nonce-nonce-cache123'")); + assert.ok(html.includes('img-src vscode-webview://cache"')); + assert.ok(html.includes('', entries: 1, bytes: 10 }] }, + }); + assert.ok(!html.includes("', + description: "A bold description", + }), + ]); + assert.ok(!html.includes("', "import a.b;", ""], + usageLine: 2, + }, + ], + }), + ASSETS, + ); + assert.match(hostile, /class="tok-punctuation">#<\/span>/); + assert.match(hostile, /class="tok-keyword">include<\/span>/); + assert.match(hostile, /class="tok-comment">\/\/ <\/script><b><\/span>/); + assert.match(hostile, /class="tok-keyword">import<\/span>/); + assert.match(hostile, //); + assert.match(hostile, /a\.cpp · line 1/); + assert.match(hostile, /Example project: argparse/); + assert.equal(hostile.split("").length - 1, 1, "the hostile comment cannot close our script"); + + const none = renderDetailHtml(model({ snippets: [], exampleProject: undefined }), ASSETS); + assert.match(none, /This package has no test project in the index\./); +}); + +test("dependencies are listed as declared, and the resolved column is left out when unknown", () => { + const html = renderDetailHtml(model(), ASSETS); + assert.match(html, /compat\.vulkan<\/code> — 1\.4\.357\.3/); + assert.match(html, /What the descriptor declares\./); + const resolved = renderDetailHtml( + model({ dependencies: [{ id: "compat.zlib", version: "1.3", resolved: "1.2.13" }] }), + ASSETS, + ); + assert.match(resolved, /compat\.zlib<\/code> — 1\.3 · resolved 1\.2\.13/); + const none = renderDetailHtml(model({ dependencies: [] }), ASSETS); + assert.doesNotMatch(none, /data-section="dependencies"/); +}); + +test("the current-environment selector is the single source of the add command", () => { + const html = renderDetailHtml(model(), ASSETS); + assert.match(html, /]*placeholder="Search packages"/); + assert.doesNotMatch(html, /class="chip/); + assert.doesNotMatch(html, /data-chip/); + assert.doesNotMatch(html, /class="chips"/); + // The toggle still follows the search box, and no chip markup is left between. + const toolbar = html.slice(html.indexOf('class="toolbar"'), html.indexOf("", html.indexOf('class="toolbar"'))); + assert.match(toolbar, /library-search/); + assert.match(toolbar, /library-network/); +}); +test("the network toggle is off by default and names the setting it drives", () => { + const off = renderLibraryHtml(model(), ASSETS); + assert.match(off, //); + assert.match(off, /Search all registries/); + assert.doesNotMatch(off, /id="library-network"[^>]*checked/); + + const on = renderLibraryHtml(model({ networkSearch: true }), ASSETS); + assert.match(on, /id="library-network"[^>]*checked/); +}); + +test("the header count starts at what the first render keeps, and the footer names the source", () => { + // The count is the *query*'s count now: the filter chips that used to narrow it + // are gone, so an empty search keeps every row. + const html = renderLibraryHtml(model(), ASSETS); + assert.match(html, /id="library-count">2 of 2 packages { + const html = renderLibraryHtml(model({ rows: [], total: 0, notice: "No mcpp index was found." }), ASSETS); + assert.match(html, /id="library-empty" class="empty">No mcpp index was found\. { + assert.deepEqual( + badgeLabels(["examples", "cn", "openkal-compat", "openkal-posix"], (key) => UI[key] ?? key), + ["✓ Has examples", "China mirror", "openkal-compat", "POSIX environment"], + ); +}); + +test("decodeLibraryMessage accepts exactly four shapes", () => { + assert.deepEqual(decodeLibraryMessage({ type: "refresh" }), { type: "refresh" }); + assert.deepEqual(decodeLibraryMessage({ type: "search", query: "" }), { type: "search", query: "" }); + assert.deepEqual(decodeLibraryMessage({ type: "networkSearch", enabled: true }), { + type: "networkSearch", + enabled: true, + }); + assert.deepEqual(decodeLibraryMessage({ type: "open", id: "compat.argparse" }), { + type: "open", + id: "compat.argparse", + }); + + for (const raw of [ + undefined, + null, + "ready", + [], + { type: "unknown" }, + // The chips are gone, so the `filter` message they sent is not a shape. + { type: "filter", chip: "added" }, + { type: "open" }, + { type: "open", id: "" }, + { type: "networkSearch", enabled: "yes" }, + { type: "search" }, + // The document used to announce itself with this, and the host used to answer + // by re-rendering — which reloads the view, which announced itself again. + { type: "ready" }, + { type: "ready", extra: "field" }, + ]) { + assert.equal(decodeLibraryMessage(raw), undefined, `unexpected decode of ${JSON.stringify(raw)}`); + } +}); + +test("the document announces nothing, so it cannot make the host re-render it", () => { + const html = renderLibraryHtml(model(), ASSETS); + // The client script posts search / filter / networkSearch / open / refresh and + // nothing else. A "ready" post is what turned a render into a reload loop: + // `webview.html = …` reloads the document, so a document that answers its own + // load with "render me again" never stops loading. + assert.doesNotMatch(html, /post\(\{\s*type:\s*"ready"/); + assert.match(html, /post\(\{\s*type:\s*"mounted"\s*\}\)/); + assert.match(html, /libraryUpdate/); +}); + +test("the stylesheet uses theme tokens only, and clamps the description to two lines", () => { + const css = readFileSync(path.join(process.cwd(), "media", "library.css"), "utf8"); + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const literals = withoutComments.match(/#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/g) ?? []; + assert.deepEqual(literals, [], `library.css must not hard-code a colour: ${literals.join(", ")}`); + assert.match(withoutComments, /-webkit-line-clamp: 2/); + assert.match(withoutComments, /var\(--vscode-/); + // Colour is never the only signal: the added state is a bordered badge class. + assert.match(withoutComments, /\.badge-added/); +}); + +test("the client script is syntactically valid JavaScript", () => { + // A syntax error in the inline script would silently break the webview, and no + // other test would notice: the document still contains all the right markup. + const html = renderLibraryHtml(model({ query: "compat" }), ASSETS); + const script = /