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..c213cf5 --- /dev/null +++ b/.agents/docs/architecture.md @@ -0,0 +1,77 @@ +# mcpp-vscode 架构 + +> 状态:0.4.0(mcppls 语言服务迁移之后)。本文记录**当前**结构;变动请同步更新。 + +## 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/` 下的评审报告。 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..fe4ecbb --- /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/newProject.ts b/src/cli/newProject.ts similarity index 70% rename from src/newProject.ts rename to src/cli/newProject.ts index e7b6f0c..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; @@ -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 68% rename from src/tasks.ts rename to src/cli/tasks.ts index 912e692..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,18 +14,36 @@ 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], }; } 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 79e9fc9..0000000 --- a/src/cliController.ts +++ /dev/null @@ -1,759 +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, - shouldRefreshLanguageServerAfterTask, - type ProjectTaskKind, - type TaskCompletion, -} from "./tasks"; -import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; -import { runNewProjectFlow, validateNewProjectName } from "./newProject"; - -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; - -export interface ProjectTaskRunOptions { - /** Set to false when the caller performs its own post-task action. */ - notify?: boolean; -} - -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 runProjectTask( - kind: ProjectTaskKind, - options: ProjectTaskRunOptions = {}, - ): Promise { - const project = this.requireProject(); - if (project === undefined || !this.requireTrusted()) { - return undefined; - } - - if (kind === "clean") { - const choice = await vscode.window.showWarningMessage( - `将删除当前工程的 target 目录:${project.root}/target`, - { modal: true, detail: "此操作不会清理全局 BMI 缓存。" }, - CONFIRM_CLEAN, - ); - if (choice !== CONFIRM_CLEAN) { - return undefined; - } - } - - 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 undefined; - } - - 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 (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( - "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 工具链管理" : "C++ Modules 语言服务", - 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; - } - - 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("查看工具链", 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 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 eb60afe..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,45 +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", - configureLanguageServer: "mcpp.configureLanguageServer", - refreshCompilationDatabase: "mcpp.refreshCompilationDatabase", - checkModuleSupport: "mcpp.checkModuleSupport", - autoConfigureModules: "mcpp.autoConfigureModules", - showModuleGraph: "mcpp.showModuleGraph", - showLanguageServerLogs: "mcpp.showLanguageServerLogs", -} as const; - -export const DEPRECATED_COMMANDS = { - configureClangd: "mcpp.configureClangd", -} 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) 选择 C++ 模块分析上下文", command: CLI_COMMANDS.configureLanguageServer, group: "ide" }, - { label: "$(database) 刷新模块构建描述", command: CLI_COMMANDS.refreshCompilationDatabase, group: "ide" }, - { label: "$(check) 重启 C++ Modules 语言服务", command: CLI_COMMANDS.checkModuleSupport, group: "ide" }, - { label: "$(type-hierarchy) 查看模块图", command: CLI_COMMANDS.showModuleGraph, group: "ide" }, - { label: "$(output) 打开 C++ Modules 日志", command: CLI_COMMANDS.showLanguageServerLogs, 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..fc08ce9 --- /dev/null +++ b/src/commands/ids.ts @@ -0,0 +1,114 @@ +/** + * 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", + manageConflicts: "mcpp.languageServer.manageConflicts", + 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 = { + 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/configureOnly.ts b/src/configureOnly.ts deleted file mode 100644 index cf233ae..0000000 --- a/src/configureOnly.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; - -export const configureOnlyArguments = ["build", "--configure-only"] as const; -// 配置阶段可能解析工具链和依赖,但不能无限期占住 IDE 操作队列。 -export const configureOnlyTimeoutMs = 5 * 60_000; - -export function runConfigureOnly( - projectRoot: string, - executable = "mcpp", - runner: ProcessRunner = runProcess, -): Promise { - return runner( - executable, - [...configureOnlyArguments], - projectRoot, - { timeoutMs: configureOnlyTimeoutMs }, - ); -} diff --git a/src/extension.ts b/src/extension.ts index b4d875e..db251be 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,23 +1,73 @@ import * as vscode from "vscode"; -import { McppCliController } from "./cliController"; -import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "./commands"; -import { findNearestMcppProject, type McppProjectDiscovery } from "./discovery"; -import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./inProject"; +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 { createLanguageServerBridge, type LanguageServerBridge, type LanguageServerCommandResult, -} from "./languageServer"; -import { computeMcppTomlCompletions } from "./mcppTomlCompletion"; +} 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 { + languageServerEnabled, + languageServerInstalled, + languageServerVersion, + readLanguageServerState, +} from "./mcppls/stateSource"; +import { registerBuildScriptProviders } from "./buildscript/providers"; +import { registerTomlProviders } from "./toml/providers"; import { buildModuleSetupPlan, executeModuleSetup, moduleSetupConfirmation, type ModuleSetupDecision, type ModuleSetupStepResult, -} from "./moduleSetup"; -import type { TaskCompletion } from "./tasks"; +} 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 } from "./library/indexLocator"; +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"); +} + +/** When the language service was last asked to reload, for the self-check. */ +let lastRefresh: { at: string; state: string; command?: string } | undefined; + +/** `mcpp.ui.language` decides which of our strings the user sees. */ +function applyLanguagePreference(): void { + setLanguagePreference(read("mcpp.ui.language")); +} + function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; @@ -30,11 +80,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; } @@ -51,35 +109,41 @@ function outputText(output: vscode.OutputChannel, line: string): void { } function resultText(output: vscode.OutputChannel, result: LanguageServerCommandResult): void { - outputText(output, `[C++ Modules] ${result.message}`); + const formatted = formatResult(result); + outputText(output, `[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); if (result.state === "unavailable") { - void vscode.window.showWarningMessage(result.message, "安装扩展").then((choice) => { - if (choice === "安装扩展") { - void vscode.commands.executeCommand("workbench.extensions.search", "@id:sunrisepeak.mcpp-language-server"); + 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}`); } }); - } else if (result.state === "failed") { - void vscode.window.showErrorMessage(result.message); + return; + } + if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } else if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); } } function moduleSetupBlockedMessage(reason: Extract["reason"]): string { switch (reason) { case "untrusted": - return "当前工作区未受信任,不会执行 mcpp 或刷新 C++ 模块语言服务。请先信任工作区。"; + 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 "已有 mcpp 操作正在运行,请等待完成后再试。"; + return t("An mcpp operation is already running; wait for it to finish and try again."); } } function taskCompletionText(completion: TaskCompletion): string { if (completion.state === "cancelled") { - return "构建已取消;C++ 模块语言服务未刷新。"; + return t("The build was cancelled; the C++ Modules language service was not refreshed."); } if (completion.state === "failed") { - return `mcpp 构建失败(退出码 ${completion.exitCode ?? "未知"});C++ 模块语言服务已重新读取现有构建描述。`; + 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 "mcpp 构建完成,C++ 模块语言服务已刷新。"; + return t("The mcpp build finished; the C++ Modules language service has been refreshed."); } async function autoConfigureModulesWizard( @@ -102,12 +166,14 @@ async function autoConfigureModulesWizard( return; } 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; } @@ -118,7 +184,7 @@ async function autoConfigureModulesWizard( return { stage: "build", state: "not-started", - detail: "未启动 mcpp 构建。", + detail: t("mcpp build was not started."), }; } return { @@ -133,7 +199,7 @@ async function autoConfigureModulesWizard( return { stage: "language-server", state: result.state === "completed" ? "succeeded" : "failed", - detail: result.state === "completed" ? undefined : result.message, + detail: result.state === "completed" ? undefined : formatResult(result).message, }; }, }); @@ -146,53 +212,20 @@ async function autoConfigureModulesWizard( } else if (outcome.state === "cancelled") { await vscode.window.showWarningMessage(taskCompletionText({ state: "cancelled" })); } else { - await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? "C++ 模块语言服务配置失败。"); + await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? t("Configuring the C++ Modules language service failed.")); } } -// mcpp.toml structural completion is deliberately local text analysis. It never executes mcpp or -// the language server, so it remains available in restricted workspaces. -const mcppTomlCompletionKinds = { - section: vscode.CompletionItemKind.Folder, - template: vscode.CompletionItemKind.Snippet, -} as const; - -const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { - provideCompletionItems(document, position) { - if (!vscode.workspace.getConfiguration("mcpp", document.uri).get("tomlCompletion", true)) { - return undefined; - } - const lines: string[] = []; - for (let line = 0; line <= position.line; line += 1) { - lines.push(document.lineAt(line).text); - } - return computeMcppTomlCompletions(lines, position.line, position.character).map((suggestion) => { - const item = new vscode.CompletionItem( - suggestion.label, - mcppTomlCompletionKinds[suggestion.kind], - ); - item.detail = suggestion.detail; - if (suggestion.documentation !== undefined) { - item.documentation = new vscode.MarkdownString(suggestion.documentation); - } - if (suggestion.insertSnippet !== undefined) { - item.insertText = new vscode.SnippetString(suggestion.insertSnippet); - } - item.range = new vscode.Range( - position.line, - suggestion.range.startCharacter, - position.line, - suggestion.range.endCharacter, - ); - return item; - }); - }, -}; - export async function activate(extensionContext: vscode.ExtensionContext): Promise { const output = vscode.window.createOutputChannel("mcpp"); + + // 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(); }, @@ -203,8 +236,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await operation(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - outputText(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 = ( @@ -233,7 +266,14 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (completion.state === "cancelled") { return; } - const result = await bridge.refreshLanguageServerAfterBuild(); + const mode = read("mcpp.languageService.refreshAfterBuild"); + if (mode === "off") { + lastRefresh = { at: new Date().toISOString(), state: "skipped (mcpp.languageService.refreshAfterBuild = off)" }; + 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)); @@ -244,9 +284,145 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi 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. + const openLibraryDetail = createLibraryDetailOpener(extensionContext, { + mcppExecutable: () => cliController.mcppExecutable(findCurrentProject()), + projectRoot: () => findCurrentProject()?.root, + output, + isTrusted: () => vscode.workspace.isTrusted, + onDependenciesChanged: () => void library.refresh(), + }); + const library = registerLibraryView(extensionContext, { + projectRoot: () => findCurrentProject()?.root, + mcppExecutable: () => cliController.mcppExecutable(findCurrentProject()), + openDetail: openLibraryDetail, + output, + // The cross-registry search runs `mcpp search`, and `mcpp.path` is + // resource-scoped: an untrusted workspace must not name the program. + isTrusted: () => vscode.workspace.isTrusted, + }); + // `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 project = findCurrentProject(); + // A refresh that reaches the network is worth a progress notification, and + // a refresh that succeeds has to *say so*: the command produced no visible + // change at all before, which reads exactly like a broken button. + 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, + }), + ); + await library.refresh(); + if (result === undefined || result.exitCode !== 0) { + void vscode.window.showErrorMessage( + result === undefined + ? t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first.") + : t("mcpp index update failed with exit code {0}", result.exitCode), + ); + return; + } + // `library.refresh()` has just filled the snapshot cache, so this second + // look is the count the view is showing, not another read of the index. + const snapshot = await loadSnapshot(project === undefined ? {} : { projectRoot: project.root }); + void vscode.window.showInformationMessage( + t( + "mcpp Package Index refreshed: {0} package(s) from {1} index folder(s).", + snapshot.entries.length, + snapshot.roots.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, + 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"); }); extensionContext.subscriptions.push( @@ -254,40 +430,35 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi manifestWatcher, inProjectContext, ...cliController.register(), - vscode.languages.registerCompletionItemProvider( - { language: "mcpp-toml" }, - mcppTomlCompletionProvider, - "[", - ), - vscode.commands.registerCommand(CLI_COMMANDS.configureLanguageServer, invokeLanguageServer( - () => bridge.selectContext(), - )), - vscode.commands.registerCommand(DEPRECATED_COMMANDS.configureClangd, invokeLanguageServer( - () => bridge.selectContext(), - )), - vscode.commands.registerCommand(CLI_COMMANDS.refreshCompilationDatabase, runGuarded(async () => { - await cliController.runProjectTask("build"); - })), - vscode.commands.registerCommand(CLI_COMMANDS.checkModuleSupport, invokeLanguageServer( - () => bridge.restartLanguageServer(), - )), - vscode.commands.registerCommand(CLI_COMMANDS.showModuleGraph, invokeLanguageServer( - () => bridge.showModuleGraph(), - )), - vscode.commands.registerCommand(CLI_COMMANDS.showLanguageServerLogs, invokeLanguageServer( - () => bridge.showLanguageServerLogs(), - )), + + // 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 autoConfigureModulesWizard(bridge, cliController, output); })), + vscode.commands.registerCommand(LEGACY_LANGUAGE_SERVER_COMMANDS.refreshCompilationDatabase, runGuarded(async () => { + await cliController.runProjectTask("build"); + })), + + // 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.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(() => cliController.refreshStatus()), manifestWatcher.onDidChange(() => cliController.refreshStatus()), @@ -295,6 +466,114 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi ); } +/** + * 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 { // VS Code disposes everything registered in activate(). } 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/ideWorkflow.ts b/src/ideWorkflow.ts deleted file mode 100644 index 954ae55..0000000 --- a/src/ideWorkflow.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ProcessResult } from "./process"; - -export interface IdeConfigurationRequest { - projectRoot: string; - compilationDatabasePath: string; - trusted: boolean; - force?: boolean; - databaseValid: () => boolean; - configure: () => Promise; -} - -export type IdeConfigurationOutcome = - | { state: "configured"; compileCommands: string } - | { state: "existing"; compileCommands: string } - | { state: "failed"; compileCommands?: string; exitCode: number } - | { state: "untrusted"; compileCommands?: string }; - -/** - * 决定是否需要执行 mcpp build --configure-only;不在这里读写 VS Code 设置, - * 便于 Extension Host 和纯 Node 测试共享同一套生命周期边界。 - */ -export async function ensureIdeConfigured( - request: IdeConfigurationRequest, -): Promise { - const validBefore = request.databaseValid(); - if (!request.trusted) { - return { - state: "untrusted", - ...(validBefore ? { compileCommands: request.compilationDatabasePath } : {}), - }; - } - if (validBefore && !request.force) { - return { state: "existing", compileCommands: request.compilationDatabasePath }; - } - const result = await request.configure(); - const validAfter = request.databaseValid(); - if (result.exitCode === 0 && validAfter) { - return { state: "configured", compileCommands: request.compilationDatabasePath }; - } - return { - state: "failed", - ...(validAfter ? { compileCommands: request.compilationDatabasePath } : {}), - exitCode: result.exitCode, - }; -} diff --git a/src/languageServer.ts b/src/languageServer.ts deleted file mode 100644 index 509cde8..0000000 --- a/src/languageServer.ts +++ /dev/null @@ -1,80 +0,0 @@ -export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; - -export const MCPPLS_COMMANDS = { - restart: "mcppls.restartServer", - selectContext: "mcppls.selectContext", - graph: "mcppls.showModuleGraph", - logs: "mcppls.showLogs", -} as const; - -export type LanguageServerCommandState = "completed" | "unavailable" | "failed"; - -export interface LanguageServerCommandResult { - state: LanguageServerCommandState; - message: string; -} - -export interface LanguageServerCommandExecutor { - extensionInstalled(id: string): boolean; - /** Activate the dependency before forwarding a command when VS Code has not activated it yet. */ - activateExtension?(id: string): Thenable; - executeCommand(command: string, ...args: unknown[]): Thenable; -} - -export interface LanguageServerBridge { - restartLanguageServer(): Promise; - refreshLanguageServerAfterBuild(): Promise; - selectContext(): Promise; - showModuleGraph(): Promise; - showLanguageServerLogs(): Promise; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -export function createLanguageServerBridge( - executor: LanguageServerCommandExecutor, -): LanguageServerBridge { - let refreshInFlight: Promise | undefined; - - async function invoke(command: string, successMessage: string): Promise { - if (!executor.extensionInstalled(MCPPLS_EXTENSION_ID)) { - return { - state: "unavailable", - message: `C++ 模块语言服务依赖未安装或已禁用:${MCPPLS_EXTENSION_ID}。`, - }; - } - try { - await executor.activateExtension?.(MCPPLS_EXTENSION_ID); - await executor.executeCommand(command); - return { state: "completed", message: successMessage }; - } catch (error) { - return { - state: "failed", - message: `C++ Modules 命令执行失败:${errorMessage(error)}`, - }; - } - } - - async function restartLanguageServer(): Promise { - return invoke(MCPPLS_COMMANDS.restart, "C++ 模块语言服务已重启。"); - } - - function refreshLanguageServerAfterBuild(): Promise { - if (refreshInFlight !== undefined) { - return refreshInFlight; - } - refreshInFlight = invoke(MCPPLS_COMMANDS.restart, "C++ 模块语言服务已刷新。") - .finally(() => { refreshInFlight = undefined; }); - return refreshInFlight; - } - - return { - restartLanguageServer, - refreshLanguageServerAfterBuild, - selectContext: () => invoke(MCPPLS_COMMANDS.selectContext, "已打开 C++ 模块上下文选择。"), - showModuleGraph: () => invoke(MCPPLS_COMMANDS.graph, "已打开 C++ 模块图。"), - showLanguageServerLogs: () => invoke(MCPPLS_COMMANDS.logs, "已打开 C++ Modules 日志。"), - }; -} diff --git a/src/library/addDependency.ts b/src/library/addDependency.ts new file mode 100644 index 0000000..d761851 --- /dev/null +++ b/src/library/addDependency.ts @@ -0,0 +1,142 @@ +/** + * `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; +} + +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); + void vscode.window.showWarningMessage(message); + return failure(message); + } + const root = deps.projectRoot(); + if (root === undefined) { + const message = t("This workspace has no mcpp.toml."); + 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."); + 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}`); + 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 with exit code {0}: {1}", result.exitCode, detail); + 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..1c6d292 --- /dev/null +++ b/src/library/detailHtml.ts @@ -0,0 +1,713 @@ +/** + * 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 { + 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; +} + +/** One window of real example code. */ +export interface DetailSnippet { + file: string; + startLine: number; + lines: string[]; + usageLine: number; +} + +/** 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; + 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; + /** + * 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", + 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, +}; + +/** + * What the page says to the host. + * + * There is no `ready`: the document is already the whole model, and a page that + * announces its own load only invites the host to render it again — which is + * exactly the reload loop the *library sidebar* shipped with (see the note in + * `libraryHtml.ts`). This host happened to answer `ready` with an empty `return`, + * so the trap never fired here; it is gone all the same. + */ +export type DetailMessage = + | { type: "add"; version: string; dev: boolean } + | { type: "openUrl"; url: string } + | { type: "copy"; text: 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 renderSnippets(model: DetailModel, label: UiLabel): string { + if (model.snippets.length === 0) { + return `

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

`; + } + const blocks = model.snippets.map((snippet) => { + 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 [ + `
${lines}
`, + `

${escapeHtml(caption)}

`, + ].join("\n"); + }); + return blocks.join("\n"); +} + +/** + * The version matrix, as the page's **selector**: each version is a button that + * aims the command at the top of the page at itself. + * + * A `${escapeHtml(label(DETAIL_UI.addDev))}`, + ...(model.repo === undefined ? [] : [link(model.repo, label(DETAIL_UI.openRepo))]), + ...(model.indexUrl === undefined ? [] : [link(model.indexUrl, label(DETAIL_UI.indexLink))]), + ` `, + // The copy button sits beside the command, not inside it: `command.textContent` + // is what a click copies, and a button inside the paragraph would be copied too. + `
`, + `

${escapeHtml(command)}

`, + ` `, + `
`, + disabled + ? `

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

` + : `

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

`, + ...usage, + `

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

`, + `
`, + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +/** + * The client. It posts two things — `add` and `openUrl` — and applies + * the host's `{type:"result"}` in place, so running `mcpp add` never rebuilds the + * document and never resets the version the reader picked. + */ +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var devInput = document.getElementById("detail-dev"); + var command = document.getElementById("detail-command"); + var result = document.getElementById("detail-result"); + var addButton = document.getElementById("detail-add"); + var version = command ? (command.getAttribute("data-selected-version") || "") : ""; + var labels = (state && state.labels) || {}; + var installed = state && state.installed ? state.installed : ""; + + function post(message) { + if (api) { api.postMessage(message); } + } + + function fillTemplate(template, values) { + var out = template; + for (var index = 0; index < values.length; index += 1) { + out = out.split("{" + index + "}").join(String(values[index])); + } + return out; + } + + function updateCommand() { + if (!command) { return; } + var dev = devInput ? devInput.checked === true : false; + var template = dev + ? (command.getAttribute("data-template-dev") || "") + : (command.getAttribute("data-template") || ""); + command.setAttribute("data-selected-version", version); + command.textContent = fillTemplate(template, [state.id, version || "?"]); + } + + /** + * The button's label and enabled state follow the selection: adding a new + * package, switching the version of one that is already there, or nothing to do + * because this is the version the manifest already asks for. The inert state + * carries the data-installed attribute, which is what paints it the thinned + * green of a thing already done. + */ + function updateButton() { + if (!addButton) { return; } + if (!version) { + addButton.textContent = labels.add || ""; + addButton.disabled = true; + addButton.removeAttribute("data-installed"); + return; + } + if (installed && version === installed) { + addButton.textContent = labels.alreadyAdded || ""; + addButton.disabled = true; + addButton.setAttribute("data-installed", ""); + return; + } + addButton.textContent = installed + ? (labels.switchTo || "{0}").split("{0}").join(version) + : (labels.add || ""); + addButton.disabled = false; + addButton.removeAttribute("data-installed"); + } + + /** One version button is the selection; the rest are alternatives. */ + function selectVersion(next) { + version = next; + var buttons = document.querySelectorAll("[data-version]"); + for (var index = 0; index < buttons.length; index += 1) { + var button = buttons[index]; + if (button.getAttribute("data-version") === next) { + button.setAttribute("data-selected", ""); + } else { + button.removeAttribute("data-selected"); + } + } + updateCommand(); + updateButton(); + } + + function showResult(payload) { + if (!result) { return; } + result.hidden = false; + result.setAttribute( + "data-state", + payload.state === "ok" ? "ok" : payload.state === "pending" ? "pending" : "error", + ); + result.textContent = payload.message || ""; + } + + if (devInput) { devInput.addEventListener("change", updateCommand); } + + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var link = target.closest("[data-open-url]"); + if (link) { + event.preventDefault(); + var url = link.getAttribute("data-open-url") || ""; + // Say something the instant the click lands: the host's answer replaces + // this line, and if it never arrives the reader can see that too. + showResult({ state: "pending", message: (labels.opening || "{0}").split("{0}").join(url) }); + post({ type: "openUrl", url: url }); + return; + } + var copy = target.closest("[data-copy-command], [data-copy]"); + if (copy) { + // The command's own text is what gets copied — the button beside it, not + // inside it, is why the command's textContent is exactly the command. + var text = copy.hasAttribute("data-copy-command") + ? (command ? String(command.textContent || "") : "") + : String(copy.getAttribute("data-copy") || ""); + if (!text) { return; } + showResult({ state: "pending", message: labels.copying || "" }); + post({ type: "copy", text: text }); + return; + } + var pick = target.closest("[data-version]"); + if (pick) { + selectVersion(pick.getAttribute("data-version") || ""); + return; + } + var add = target.closest("#detail-add"); + if (add && !add.disabled) { + if (!version) { return; } + post({ type: "add", version: version, dev: devInput ? devInput.checked === true : false }); + } + }); + + /** Move the "added" marker onto the version the project now asks for. */ + function markInstalled(next) { + installed = next; + var marks = document.querySelectorAll(".detail-installed"); + for (var index = 0; index < marks.length; index += 1) { marks[index].parentNode.removeChild(marks[index]); } + var buttons = document.querySelectorAll("[data-version]"); + for (var index2 = 0; index2 < buttons.length; index2 += 1) { + var button = buttons[index2]; + if (button.getAttribute("data-version") !== next) { + button.removeAttribute("data-installed"); + continue; + } + button.setAttribute("data-installed", ""); + var mark = document.createElement("span"); + mark.className = "detail-installed"; + mark.textContent = labels.installed || ""; + button.parentNode.insertBefore(mark, button.nextSibling); + } + updateButton(); + } + + window.addEventListener("message", function (event) { + var data = event.data; + if (data && data.type === "result" && data.result) { showResult(data.result); } + if (data && data.added && data.added.version) { markInstalled(data.added.version); } + }); + + updateCommand(); + updateButton(); +})();`; +} + +/** The little state the client needs; the page is already rendered from the rest. */ +function clientState(model: DetailModel): Record { + return { + id: model.id, + ...(model.latest === undefined ? {} : { latest: model.latest }), + // The three labels the button can wear, and the version the project already + // has: the client re-decides the label every time the selection changes, so + // the host cannot be the only one that knows what the button means. The + // `installed` word belongs here too — `markInstalled()` writes it next to + // the version button after a successful add, and an empty string there is + // a marker nobody can read (§22). + labels: { + add: model.ui[DETAIL_UI.add] ?? "", + switchTo: model.ui[DETAIL_UI.switchTo] ?? "", + alreadyAdded: model.ui[DETAIL_UI.alreadyAdded] ?? "", + opening: model.ui[DETAIL_UI.opening] ?? "", + installed: model.ui[DETAIL_UI.installed] ?? "", + copying: model.ui[DETAIL_UI.copying] ?? "", + }, + ...(model.installed === undefined ? {} : { installed: model.installed.version }), + }; +} + +/** 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)}

+ ${model.description === undefined ? "" : `

${escapeHtml(model.description)}

`} + ${renderMeta(model, label)} +
+
+ ${renderActions(model, label)} +
+

${escapeHtml(versionsHeading)}

+ ${renderVersions(model, label)} +
+
+

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

+ ${renderDependencies(model, label)} +
+
+

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

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

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

`} +
+
+

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

+ ${renderExtras(model, label)} + ${model.parseNotice === undefined ? "" : `

${escapeHtml(model.parseNotice)}

`} +
+
+
${escapeHtml(model.dataSource)}
+ + + +`; +} + +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 "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 } + : undefined; + default: + return undefined; + } +} diff --git a/src/library/detailPanel.ts b/src/library/detailPanel.ts new file mode 100644 index 0000000..b784b26 --- /dev/null +++ b/src/library/detailPanel.ts @@ -0,0 +1,531 @@ +/** + * 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 { 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 { addDependency } from "./addDependency"; +import { + badgesOf, + codeSnippets, + descriptorDependencies, + installedFor, + lockPackageVersions, + mergeSurfaces, + parseXpkgJson, + platformKey, + surfaceLabel, + syntheticUsageLines, + usageLinesFor, + type LibraryEntry, + type Surface, +} from "./indexModel"; +import { cachedDescriptorText, loadSnapshot, readDescriptorText, readExampleFiles } from "./indexLocator"; +import { + DETAIL_UI, + decodeDetailMessage, + renderDetailHtml, + type DetailInstalled, + 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: () => string; + 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; +} + +/** Build the opener once; `extension.ts` passes `openLibraryDetail` to the view. */ +export function createLibraryDetailOpener( + context: vscode.ExtensionContext, + deps: DetailPanelDeps, +): (id: string) => Promise { + const session: DetailSession = { + context, + deps, + panel: undefined, + id: undefined, + busy: false, + webviewDocument: new WebviewDocument(STYLESHEET), + }; + context.subscriptions.push({ + dispose: () => { + session.panel?.dispose(); + session.panel = undefined; + }, + }); + return (id: string) => open(session, id); +} + +/** One panel per window, remembered so a second `open` reveals rather than stacks. */ +export interface DetailSession { + 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; + /** + * 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): 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; + // 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; + session.id = id; + panel.title = id; + 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) { + return; + } + switch (message.type) { + case "openUrl": { + // `decodeDetailMessage` already restricted this to https. + await openExternal(session, message.url); + return; + } + case "copy": { + // The command preview and the usage lines: both are text a reader pastes + // somewhere else, and a click that answers with nothing looks like a dead + // button (the lesson `openExternal` learned in §20.2). + try { + await vscode.env.clipboard.writeText(message.text); + postResult(session, { state: "ok", message: t("Copied to the clipboard.") }); + } catch (error) { + session.deps.output.appendLine( + `mcpp library: copying to the clipboard failed: ${error instanceof Error ? error.message : String(error)}`, + ); + postResult(session, { state: "error", message: t("Could not write the clipboard.") }); + } + return; + } + case "add": { + if (session.busy || session.id === undefined) { + return; + } + session.busy = true; + let result: DetailResult; + try { + const outcome = await addDependency( + { + mcppExecutable: session.deps.mcppExecutable, + projectRoot: session.deps.projectRoot, + output: session.deps.output, + isTrusted: session.deps.isTrusted, + }, + { id: session.id, version: message.version, dev: message.dev }, + ); + result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; + if (outcome.ok) { + session.deps.onDependenciesChanged?.(); + } + } finally { + session.busy = false; + } + if (session.panel === undefined) { + return; + } + postResult(session, result, result.state === "ok" ? message.version : undefined); + return; + } + default: + return; + } +} + +/** + * Open a link, and say what happened. + * + * This used to be `void vscode.env.openExternal(…)`: the boolean it resolves to + * was dropped, so a host with no browser (or no way to reach one) answered a + * click with nothing at all — indistinguishable from a dead button. Now the page + * is told either way, a failure is copied to the clipboard so the click is still + * worth something, and the output channel keeps a record. + */ +async function openExternal(session: DetailSession, url: string): Promise { + let opened = false; + try { + opened = await vscode.env.openExternal(vscode.Uri.parse(url)); + } catch (error) { + session.deps.output?.appendLine( + `mcpp library: opening ${url} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (opened) { + postResult(session, { state: "ok", message: t("Opened {0} in your browser.", url) }); + return; + } + session.deps.output?.appendLine(`mcpp library: VS Code could not open ${url}; copying it instead.`); + try { + await vscode.env.clipboard.writeText(url); + } catch { + // A clipboard that refuses is not worth a second error. + } + postResult(session, { + state: "error", + message: t("VS Code could not open {0}. The link is on your clipboard.", url), + }); +} + +/** + * The page's status line, in the same shape the add flow uses. + * + * `added` tells the page that the project now depends on that version, so it can + * move its "added" marker and re-label the button without rebuilding the + * document — which would throw away the reader's scroll position and their + * version selection, the one thing running `mcpp add` must not do. + */ +function postResult(session: DetailSession, result: DetailResult, added?: string): void { + void session.panel?.webview.postMessage({ + type: "result", + result, + ...(added === undefined ? {} : { added: { version: added } }), + }); +} + +async function render(session: DetailSession, id: string): Promise { + const panel = session.panel; + if (panel === undefined) { + return; + } + const model = await buildModel(session, id); + if (session.panel !== panel || session.id !== id) { + return; + } + const html = renderDetailHtml( + model, + session.webviewDocument.assets( + vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), + panel.webview, + ), + ); + session.webviewDocument.paint(panel.webview, html); +} + +async function buildModel(session: DetailSession, id: string): Promise { + const projectRoot = session.deps.projectRoot(); + const snapshot = await loadSnapshot(projectRoot === undefined ? {} : { projectRoot }); + const entry = snapshot.entries.find((candidate) => 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) => entry.file.startsWith(candidate.path)); + const ui = labels(); + if (entry === undefined) { + return emptyModel(id, ui, t("This package is not in the local index any more.")); + } + const text = cachedDescriptorText(id) ?? (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}`; + let info = parsed.get(cacheKey); + if (!parsed.has(cacheKey)) { + info = await readXpkg(session, entry); + parsed.set(cacheKey, info); + } + + 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 exampleFiles = entry.example === undefined ? [] : await readExampleFiles(snapshot.roots, entry.example); + const snippets = codeSnippets(exampleFiles, { context: 2, maxSnippets: 3, maxLines: 20 }); + // §22: the example project's real `import`/`#include` lines when it states + // them, else the honest synthetic form for the surface the parse resolved. + const realUsage = usageLinesFor(exampleFiles, entry.id); + const usage = realUsage.length > 0 ? realUsage : syntheticUsageLines(mergeSurfaces(info, text), entry.id); + + // The lock answers what a build actually resolved for each declared edge — + // the same matching rule `installedFor` uses for this package itself. + const lock = lockPackageVersions(readInstalledLock(session)); + const dependencies = descriptorDependencies(text).map((dependency) => { + const short = dependency.id.slice(dependency.id.lastIndexOf(".") + 1); + const resolved = lock.find((entry) => entry.id === dependency.id || entry.id === short)?.version; + return resolved === undefined ? { ...dependency } : { ...dependency, resolved }; + }); + const model: DetailModel = { + ui, + id: entry.id, + name: entry.name, + ...(entry.description === undefined ? {} : { description: entry.description }), + licenses: entry.licenses, + ...(entry.repo === undefined ? {} : { repo: entry.repo }), + registry: entry.registry, + ...(entry.surface === undefined ? {} : { surface: entry.surface }), + surfaces: entry.surfaces, + badges: badgesOf(entry), + versions: groups, + currentVersions: sortVersions(currentVersions), + ...(latest === undefined ? {} : { latest }), + // §22: the page finally learns what the project already has. Without this + // the button said "Add" to a package mcpp.toml listed all along. + ...readInstalled(session, entry.id), + ...(info?.standard === undefined ? {} : { standard: info.standard }), + dependencies, + includeDirs: info?.includeDirs ?? [], + targets: (info?.targets ?? []).map((target) => target.name ?? "").filter((name) => name.length > 0), + snippets, + ...(entry.example === undefined ? {} : { 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): 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(), ["xpkg", "parse", entry.file, "--json"], session.deps.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."), + }; +} + +/** + * The workspace's own answer about one package (§20.1, wired in §22): + * `mcpp.toml` first, `mcpp.lock` second, both read tolerantly — a missing or + * unreadable file is simply "not installed", never a broken page. The spread + * sets `installed` only when there is one. + */ +function readInstalled(session: DetailSession, id: string): { installed?: DetailInstalled } { + const root = session.deps.projectRoot(); + if (root === undefined) { + return {}; + } + const toml = readTextTolerantly(path.join(root, "mcpp.toml")); + const lock = readTextTolerantly(path.join(root, "mcpp.lock")); + const installed = installedFor(id, toml, lock); + return installed === undefined ? {} : { installed }; +} + +/** An unreadable or missing file reads as empty text; the view degrades, not breaks. */ +function readTextTolerantly(file: string): string { + try { + return readFileSync(file, "utf8"); + } catch { + return ""; + } +} + +/** The workspace's `mcpp.lock` text, empty when there is none to read. */ +function readInstalledLock(session: DetailSession): string { + const root = session.deps.projectRoot(); + return root === undefined ? "" : readTextTolerantly(path.join(root, "mcpp.lock")); +} + +/** Every visible string, resolved once per model. */ +function labels(): Record { + return { + [DETAIL_UI.htmlLang]: htmlLanguage(), + [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( + "What the descriptor declares. The resolved version lives in a project's mcpp.lock, not here.", + ), + [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 to mcpp.toml"), + [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 added"), + [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("Bring it into your code"), + [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/indexLocator.ts b/src/library/indexLocator.ts new file mode 100644 index 0000000..a29770a --- /dev/null +++ b/src/library/indexLocator.ts @@ -0,0 +1,559 @@ +/** + * Where the index lives, and how it is read **cheaply**. + * + * The catalog is a directory of Lua descriptors: + * `/.mcpp/registry/data//pkgs//.lua`. Three + * registries are installed on the reference machine (`mcpplibs` with 239 + * descriptors, `xim-pkgindex`, `xim-pkgindex-local`), and `mcpp index status` + * prints a table of them — which is deliberately **not** parsed: `--format json` + * is an unknown option there, and a second human-output parser is exactly what + * this project forbids. Globbing the data directory under `` gives the + * same answer without parsing anything. + * + * `` is `$MCPP_HOME`, then `~/.mcpp` — every one of them that holds a data + * directory — and when none does, whatever `mcpp self env --format json` + * reports, which is a machine shape and therefore allowed to be read. + * + * Two budgets shape this file: + * + * - **No process per package.** The list is read from the descriptor text + * (`src/library/indexModel.ts`); `mcpp xpkg parse --json` runs on demand, from + * the detail panel, never here. + * - **One read per index revision.** A snapshot is cached and invalidated by a + * cheap revision key (the `pkgs` directory's mtime, the `.mcpp-index-updated` + * marker and the descriptor count), so re-rendering the view after a filter + * change costs a few stats, not 239 file reads. + * + * A missing index is **not** an error: `locateIndexRoots()` returns `[]` and the + * caller renders the message this module hands it. + */ + +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 { + descriptorEntry, + descriptorId, + declaredDependencies, + 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; + /** 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): Promise { + const pkgs = path.join(directory, "pkgs"); + if (!(await isDirectory(pkgs))) { + return undefined; + } + const registry = path.basename(directory); + const generator = await isFile(path.join(directory, ".xpkgindex", "plugins", "mcpp.py")); + return { + registry, + path: directory, + pkgs, + hasExamples: await isDirectory(path.join(directory, "tests", "examples")), + hasOpenkal: await isFile(path.join(directory, ".xpkgindex", "openkal-compat.json")), + ...(registry === "mcpplibs" || generator ? { site: INDEX_SITE } : {}), + }; +} + +/** + * The index roots to read. + * + * `mcpp.library.indexPath` wins when the user set it — it may name one index + * root (a directory with `pkgs/`), a `pkgs` directory itself, or a whole + * `/.mcpp/registry/data` directory of registries. With no setting, the + * default glob is the data directory under each known mcpp home. + * + * **Finding ``.** Two are tried, in order: `$MCPP_HOME` (the variable mcpp + * itself honours) and `~/.mcpp`. Both are listed when both hold something, and + * the view's footer names the registries it read, so a stale second home shows + * up as an extra source rather than as a wrong answer. When *neither* has a + * `registry/data` with anything in it, mcpp is asked directly — `mcpp self env + * --format json` reports `mcppHome`, which is the only authoritative answer for + * an install in a place nothing can guess. That probe costs one process **per + * session** and only runs when the cheap globs came up empty, so the ordinary + * machine never pays for it; it is also skipped in an untrusted workspace, + * because `mcpp.path` is a resource-scoped setting and a workspace must not + * choose a binary to run. + * + * `[]` means "no index found"; the caller turns that into the message rather + * than into an error, because a machine without the index is a normal machine. + * + * (`locateIndexRoots` is kept as an alias: the name reads better at the call + * site, the exported name is the one `extension.ts` uses.) + */ +export async function readIndexRoots(): Promise { + const configured = read("mcpp.library.indexPath"); + const candidates: string[] = []; + if (typeof configured === "string" && configured.trim().length > 0) { + const configured_path = path.resolve(expandHome(configured.trim())); + // A `pkgs` directory was named directly. + if (path.basename(configured_path) === "pkgs" && (await isDirectory(configured_path))) { + candidates.push(path.dirname(configured_path)); + } else { + const own = await rootAt(configured_path); + if (own !== undefined) { + candidates.push(own.path); + } else { + // A data directory: every registry inside it. + for (const entry of await readdirSafe(configured_path)) { + candidates.push(path.join(configured_path, entry)); + } + } + } + } else { + for (const data of await defaultDataDirectories()) { + for (const entry of await readdirSafe(data)) { + candidates.push(path.join(data, entry)); + } + } + } + + const roots: IndexRoot[] = []; + for (const candidate of candidates) { + const root = await rootAt(candidate); + if (root !== undefined && !roots.some((seen) => seen.path === root.path)) { + roots.push(root); + } + } + return roots.sort((a, b) => (a.registry < b.registry ? -1 : a.registry > b.registry ? 1 : 0)); +} + +/** Set once per session, so `mcpp self env` runs at most once. */ +let probedHome: string | undefined; +let probeAttempted = false; + +/** The homes mcpp may have used, most specific first, without duplicates. */ +function candidateHomes(): string[] { + const homes: string[] = []; + const fromEnvironment = process.env.MCPP_HOME?.trim(); + if (fromEnvironment !== undefined && fromEnvironment.length > 0) { + homes.push(fromEnvironment); + } + homes.push(path.join(os.homedir(), ".mcpp")); + if (probedHome !== undefined) { + homes.push(probedHome); + } + return [...new Set(homes)]; +} + +/** + * The `/registry/data` directories worth listing. + * + * The cheap answer first: a data directory that already holds something is the + * answer, and no process is started. Only when all of them are missing or empty + * is `mcpp self env` asked where its home is — and its answer is picked up by + * re-reading the candidate list, because the probe is what adds the home to it. + */ +async function defaultDataDirectories(): Promise { + const candidates = (): string[] => candidateHomes().map((home) => path.join(home, "registry", "data")); + const present: string[] = []; + for (const data of candidates()) { + if ((await readdirSafe(data)).length > 0) { + present.push(data); + } + } + if (present.length > 0) { + return present; + } + await mcppHomeFromCli(); + return [...new Set(candidates())]; +} + +/** + * `mcpp self env --format json`, for the one field that says where the index is. + * + * It runs at most once per session and never in an untrusted workspace. A + * missing or failing mcpp is not an error here: the caller falls back to the + * directories it already has, and the view says "no index" the way it does when + * there is none. + */ +async function mcppHomeFromCli(): Promise { + if (probeAttempted) { + return probedHome; + } + probeAttempted = true; + const executable = read("mcpp.path").trim(); + // `mcpp.path` is resource-scoped, so the probe goes through the trust-gated + // seam; a refusal is the same "no answer" as a failing mcpp. + const result = await runMcpp( + vscode.workspace.isTrusted, + executable.length === 0 ? "mcpp" : executable, + ["self", "env", "--format", "json"], + undefined, + { timeoutMs: SELF_ENV_TIMEOUT_MS, maxBufferMiB: 1 }, + ); + if (result === undefined || result.exitCode !== 0) { + return undefined; + } + const env = parseSelfEnv(result.stdout); + if (env === undefined) { + return undefined; + } + if (env.mcppHome !== undefined) { + probedHome = env.mcppHome; + } else if (env.registry !== undefined) { + // `registry` is `/registry`; the home is what the other paths hang off. + probedHome = path.dirname(env.registry); + } + return probedHome; +} + +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(); +} + +/** + * A cheap key that changes when the index does. + * + * The `pkgs` directory's own mtime catches an added or removed letter + * directory; `.mcpp-index-updated` is written whenever mcpp refreshes the index, + * which catches a changed descriptor body. The descriptor count is included so a + * tree that stops being updated still costs one full read when its size changes. + */ +async function revisionOf(roots: readonly IndexRoot[]): Promise { + const parts: string[] = []; + for (const root of roots) { + const pkgs = await statSafe(root.pkgs); + const marker = await statSafe(path.join(root.path, ".mcpp-index-updated")); + const letters = await readdirSafe(root.pkgs); + parts.push(`${root.registry}:${pkgs?.mtimeMs ?? 0}:${marker?.mtimeMs ?? 0}:${letters.length}`); + } + return parts.join("|"); +} + +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; +} + +/** The package ids the workspace's own `mcpp.toml` declares. */ +async function readAdded(projectRoot: string | undefined): Promise> { + if (projectRoot === undefined) { + return new Set(); + } + try { + const text = await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8"); + return new Set(declaredDependencies(text)); + } catch { + return new Set(); + } +} + +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. + * + * `projectRoot` is the workspace folder whose `mcpp.toml` decides the "already + * declared" state; it is part of the cache key, because switching workspaces + * changes the answer without changing the index. + */ +export async function loadSnapshot(options: { projectRoot?: string; platform?: string } = {}): Promise { + const roots = await readIndexRoots(); + const revision = `${await revisionOf(roots)}|${options.projectRoot ?? ""}`; + if (cache !== undefined && cache.revision === revision) { + return cache.snapshot; + } + 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 added = await readAdded(options.projectRoot); + 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))) { + if (!exampleRefs.has(id)) { + exampleRefs.set(id, ref); + } + } + } + const openkal = await readOpenkal(roots); + + for (const root of roots) { + 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(id); + const facet = openkalFacetFor(openkal, id); + const entry = descriptorEntry({ + fileName, + registry: root.registry, + file, + text, + platform, + added: added.has(id), + ...(example === undefined ? {} : { example }), + ...(facet === undefined ? {} : { openkal: facet }), + }); + entries.push(entry); + texts.set(entry.id, 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 snapshot; +} + +/** 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(id: string): string | undefined { + return cache?.texts.get(id); +} + +/** + * 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 (!absolute.startsWith(root.path)) { + 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..3df6488 --- /dev/null +++ b/src/library/indexModel.ts @@ -0,0 +1,1520 @@ +/** + * 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 demonstrate one package; the first (sorted) wins, which is what + * the generator does too. + */ +export function exampleCatalog(inputs: readonly ExampleInput[]): Map { + const catalog = 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)) { + if (!catalog.has(id)) { + catalog.set(id, ref); + } + } + } + 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..99feba8 --- /dev/null +++ b/src/library/libraryHtml.ts @@ -0,0 +1,445 @@ +/** + * 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; + 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[]; + 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; + /** `{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", +} 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, +}; + +/** + * What the document says to the host. + * + * There deliberately **no `ready` message**. The document is the data — every + * row, badge and count is already in it — so a "I have loaded" announcement can + * only invite the host to render the same thing again. It did exactly that, and + * because assigning `webview.html` reloads the document while every render mints + * a fresh CSP nonce, the page never matched the previous one: the view reloaded + * itself forever (flicker, unclickable rows, a pegged CPU). The host now also + * refuses to re-assign an identical document — both rules live in + * `src/webview/document.ts` (`WebviewDocument`) and `src/webview/render.ts` + * (`documentNeedsRender`) — so this cannot come back by accident. + */ +export type LibraryMessage = + | { type: "refresh" } + | { type: "search"; query: string } + | { type: "networkSearch"; enabled: boolean } + | { 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 = badgeLabels(row.badges, label); + 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 ? `${escapeHtml(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 [ + `
    `, + ` `, + ` `, + ` `, + `
    `, + ].join("\n"); +} + +function renderList(model: LibraryModel, label: UiLabel): string { + if (model.rows.length === 0) { + return `

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

    `; + } + return [ + `
      `, + model.rows.map((row) => renderRow(row, label)).join("\n"), + `
    `, + ``, + ].join("\n"); +} + +/** + * 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 searchInput = document.getElementById("library-search"); + var networkInput = document.getElementById("library-network"); + var list = document.getElementById("library-list"); + var empty = document.getElementById("library-empty"); + var countElement = document.getElementById("library-count"); + + function post(message) { + if (api) { api.postMessage(message); } + } + + // Every whitespace-separated word has to appear in the row's haystack, which + // the host builds from the id, the name, the description and the usage labels. + function rowMatches(row, query) { + if (query.length === 0) { return true; } + var haystack = row.getAttribute("data-haystack") || ""; + var parts = query.split(/\\s+/); + for (var index = 0; index < parts.length; index += 1) { + if (parts[index].length > 0 && haystack.indexOf(parts[index]) < 0) { return false; } + } + return true; + } + + function apply() { + var query = searchInput ? searchInput.value.trim().toLowerCase() : ""; + var any = false; + var visible = 0; + if (list) { + var rows = list.querySelectorAll("[data-row]"); + for (var index = 0; index < rows.length; index += 1) { + var shown = rowMatches(rows[index], query); + rows[index].parentNode.hidden = !shown; + if (shown) { any = true; visible += 1; } + } + list.hidden = !any; + } + if (empty) { empty.hidden = any || !list; } + if (countElement) { + var template = state && state.countTemplate ? state.countTemplate : ""; + var total = state && state.total ? state.total : 0; + countElement.textContent = template.split("{0}").join(String(visible)).split("{1}").join(String(total)); + } + } + + if (searchInput) { + searchInput.addEventListener("input", function () { + apply(); + post({ type: "search", query: searchInput.value }); + }); + } + if (networkInput) { + networkInput.addEventListener("change", function () { + post({ type: "networkSearch", enabled: networkInput.checked === true }); + }); + } + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var refresh = target.closest("[data-action]"); + if (refresh && refresh.getAttribute("data-action") === "refresh") { + post({ type: "refresh" }); + return; + } + var row = target.closest("[data-row]"); + if (row) { post({ type: "open", id: row.getAttribute("data-row") }); } + }); + apply(); +})();`; +} + +/** + * 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. + */ +function clientState(model: LibraryModel): Record { + return { + countTemplate: model.countTemplate, + total: model.total, + 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))}

    +

    ${escapeHtml(count)}

    +
    +${renderToolbar(model, label)} +
    +${renderList(model, label)} +
    +
    + ${escapeHtml(model.dataSource)} + +
    + + + +`; +} + +/** + * 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 "refresh": + return { type: "refresh" }; + 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 } : 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..b156fcb --- /dev/null +++ b/src/library/libraryView.ts @@ -0,0 +1,462 @@ +/** + * 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 { 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, type IndexRoot, type LibrarySnapshot } from "./indexLocator"; +import { + LIBRARY_UI, + decodeLibraryMessage, + renderLibraryHtml, + 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) => 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")) { + 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(), + 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; + dispose(): void; +} + +class LibraryViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + private view: vscode.WebviewView | undefined; + private snapshot: LibrarySnapshot | undefined; + private query = ""; + /** 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; + /** + * 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 dispose(): void { + 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(); + 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 { + if (this.rendering !== undefined) { + return this.rendering; + } + const projectRoot = this.deps.projectRoot(); + this.rendering = loadSnapshot(projectRoot === undefined ? {} : { projectRoot }) + .then((snapshot) => { + this.snapshot = snapshot; + }) + .catch((error: unknown) => { + this.deps.output?.appendLine(`mcpp library: ${error instanceof Error ? error.message : String(error)}`); + this.snapshot = { entries: [], roots: [], revision: "error" }; + }) + .then(() => { + this.rendering = undefined; + this.paint(); + }); + return this.rendering; + } + + private async handle(raw: unknown): Promise { + const message = decodeLibraryMessage(raw); + if (message === undefined) { + return; + } + switch (message.type) { + case "refresh": + await this.refresh(); + return; + case "search": + this.query = message.query; + this.scheduleSearch(); + return; + case "networkSearch": + await this.setNetworkSearch(message.enabled); + return; + case "open": + await this.deps.openDetail(message.id); + return; + default: + return; + } + } + + /** `mcpp.library.networkSearch`: persisted, then honoured on the next query. */ + private async setNetworkSearch(enabled: boolean): Promise { + this.extra = []; + this.searchNote = undefined; + try { + await write("mcpp.library.networkSearch", enabled, "user"); + } 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 (enabled) { + await this.runCrossRegistrySearch(); + } else { + this.paint(); + } + } + + private scheduleSearch(): void { + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + } + if (!this.networkSearch()) { + 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(); + if (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. + 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 (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, + 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("Library"), + [LIBRARY_UI.search]: t("Search packages"), + [LIBRARY_UI.networkSearch]: t("Search all 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("latest {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("Added"), + [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("Refresh"), + }; + } + + /** + * 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), + ); + this.webviewDocument.paint(view.webview, document); + } + + private model(): LibraryModel { + const snapshot = this.snapshot; + const ui = this.labels(); + if (snapshot === undefined) { + return this.emptyModel(ui, t("Reading the index…"), t("Reading the index…")); + } + const entries = snapshot.entries; + const rows = [...entries.map(rowOf), ...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, + query: this.query, + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource: this.dataSource(snapshot.roots, entries.length), + 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 { + const registries = roots.map((root) => root.registry).join(", "); + const base = + roots.length === 0 + ? t("No local index was read.") + : t( + "{0} descriptor(s) from {1} local index folder(s): {2}. Read offline.", + total, + roots.length, + registries, + ); + const sentence = `${t("Data source")}: ${base}`; + return this.searchNote === undefined ? sentence : `${sentence} ${this.searchNote}`; + } +} + +/** One entry as the row the document renders. */ +function rowOf(entry: LibraryEntry): LibraryRow { + return { + id: entry.id, + 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/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/mcppTomlCompletion.ts b/src/mcppTomlCompletion.ts deleted file mode 100644 index 016af8f..0000000 --- a/src/mcppTomlCompletion.ts +++ /dev/null @@ -1,223 +0,0 @@ -// mcpp.toml 的代码补全查询层(结构补全版)。 -// -// 范围:段头结构建议 + 开放词汇段的写法模板。每条建议携带显式替换范围。 -// 依赖包名/版本等动态数据补全与静态字段键/枚举补全均不在本版——前者等上游 -// 批量 catalog 接口,后者等版本化 manifest schema(见设计 issue #8 与 -// mcpp RFC #379)。 -// -// 本模块不依赖 vscode API;上下文来自 mcppTomlParser 的 contextAt(容错解析)。 - -import { - contextAt, - type ReplaceRange, - type SectionResolution, -} from "./mcppTomlParser"; - -export type McppTomlSuggestionKind = "section" | "template"; - -export interface McppTomlSuggestion { - label: string; - kind: McppTomlSuggestionKind; - detail: string; - documentation?: string; - /** 插入文本;含 $1 等 snippet 占位符。缺省时插入 label。 */ - insertSnippet?: string; - /** 替换范围(光标所在行的起止列)。 */ - range: ReplaceRange; -} - -export interface SectionHeaderSpec { - group: string; - label: string; - /** snippet 形式的段头(含 ${1:...} 占位)。 */ - header: string; - detail: string; -} - -// 段头结构清单:TOML 结构语法,非字段语义。出处:mcpp 文档 02/03/05/06 -// 与 src/manifest/toml.cppm 的段清单(契约测试用真实 mcpp 逐段验证)。 -export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ - { group: "package", label: "[package]", header: "[package]", detail: "包元数据" }, - { group: "lib", label: "[lib]", header: "[lib]", detail: "库根模块约定" }, - { group: "build", label: "[build]", header: "[build]", detail: "构建配置" }, - { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: "生成文件(路径 → 内容)" }, - { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: "运行时依赖" }, - { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: "开发/测试依赖" }, - { group: "build-dependencies", label: "[build-dependencies]", header: "[build-dependencies]", detail: "构建期依赖(仅构建期拉取,运行时不可见)" }, - { group: "workspace", label: "[workspace]", header: "[workspace]", detail: "工作空间成员声明" }, - { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: "集中声明依赖版本,成员用 workspace = true 继承" }, - { group: "features", label: "[features]", header: "[features]", detail: "feature 定义" }, - { group: "feature-deps", label: "[feature-deps.]", header: "[feature-deps.${1:name}]", detail: "由 feature 拉取的可选依赖" }, - { group: "capabilities", label: "[capabilities]", header: "[capabilities]", detail: "capability 绑定(provider 选择)" }, - { group: "targets", label: "[targets.]", header: "[targets.${1:name}]", detail: "构建目标" }, - { group: "profile", label: "[profile.]", header: "[profile.${1:name}]", detail: "构建档案" }, - { group: "runtime", label: "[runtime]", header: "[runtime]", detail: "主机运行时能力" }, - { group: "resources", label: "[resources]", header: "[resources]", detail: "编译进产物的元数据与资产(仅 PE 目标)" }, - { group: "toolchain", label: "[toolchain]", header: "[toolchain]", detail: "编译器工具链简写" }, - { group: "xlings", label: "[xlings]", header: "[xlings]", detail: "构建环境(xlings 供给)" }, - { group: "xlings.workspace", label: "[xlings.workspace]", header: "[xlings.workspace]", detail: "固定工具版本" }, - { group: "target", label: "[target.]", header: "[target.${1:x86_64-linux-gnu}]", detail: "按目标三元组的配置" }, - { group: "pack", label: "[pack]", header: "[pack]", detail: "mcpp pack 打包配置" }, - { group: "pack.bundle-project", label: "[pack.bundle-project]", header: "[pack.bundle-project]", detail: "vendored 过滤策略微调" }, - { group: "indices", label: "[indices]", header: "[indices]", detail: "项目级索引重定向" }, - { group: "tools.overrides", label: "[tools.overrides]", header: "[tools.overrides]", detail: "host 工具二进制覆盖" }, - { group: "language", label: "[language]", header: "[language]", detail: "旧版兼容字段;新项目请用 [package].standard" }, -]; - -/** 依赖类段(键位置给依赖写法模板)。 */ -const DEPENDENCY_GROUPS: ReadonlySet = new Set([ - "dependencies", - "dev-dependencies", - "build-dependencies", - "workspace.dependencies", - "feature-deps", -]); - -interface TemplateSpec { - label: string; - detail: string; - documentation?: string; - insertSnippet: string; -} - -const DEPENDENCY_TEMPLATES: readonly TemplateSpec[] = [ - { - label: 'name = "version"', - detail: "SemVer 版本依赖", - documentation: "默认 caret 约束(^);也支持 ~、= 与 \">=1.0, <2.0\" 范围组合。", - insertSnippet: '${1:name} = "${2:1.0.0}"', - }, - { - label: "name = { path = ... }", - detail: "路径依赖(本地开发)", - insertSnippet: '${1:name} = { path = "${2:../mylib}" }', - }, - { - label: "name = { git = ..., tag = ... }", - detail: "Git 依赖(tag / branch / rev 三选一)", - insertSnippet: '${1:name} = { git = "${2:https://github.com/user/repo.git}", tag = "${3:v1.0.0}" }', - }, - { - label: "name = { version = ..., features = [...] }", - detail: "长式 dep spec:请求该依赖的 feature", - insertSnippet: '${1:name} = { version = "${2:1.0}", features = ["${3:feature}"] }', - }, - { - label: "name = { version = ..., tools = [...] }", - detail: "依赖产出的 host 工具(须为该包的 bin target)", - insertSnippet: '${1:name} = { version = "${2:1.0}", tools = ["${3:protoc}"] }', - }, -]; - -const FEATURE_TEMPLATES: readonly TemplateSpec[] = [ - { label: "name = [...]", detail: "数组简写:仅隐含 feature", insertSnippet: "${1:name} = [${2}]" }, - { label: "name = { defines = [...] }", detail: "表形式:激活时贡献包自有宏", insertSnippet: '${1:name} = { defines = ["${2:MACRO}"] }' }, - { label: "name = { requires = [...] }", detail: "表形式:需要 capability", insertSnippet: '${1:name} = { requires = ["${2:blas}"] }' }, - { label: "name = { sources = [...] }", detail: "表形式:feature 门控的源 glob", insertSnippet: '${1:name} = { sources = ["${2:src/simd/**}"] }' }, -]; - -const GENERATED_FILE_TEMPLATES: readonly TemplateSpec[] = [ - { - label: '"path" = "content"', - detail: "生成文件(相对路径 → 内容,进指纹)", - insertSnippet: '"${1:src/gen/wrap.cppm}" = """\n${2:}\n"""', - }, -]; - -const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ - { - label: 'capability = "provider"', - detail: "capability 绑定(等价于 --cap)", - insertSnippet: '${1:blas} = "${2:compat.openblas}"', - }, -]; - -const XLINGS_WORKSPACE_TEMPLATES: readonly TemplateSpec[] = [ - { label: 'tool = "version"', detail: "固定 xlings 工具版本", insertSnippet: '${1:node} = "${2:24.19.0}"' }, -]; - -const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ - { - label: '"pkg:tool" = "path"', - detail: "用已有二进制覆盖 host 工具(跳过构建)", - insertSnippet: '"${1:compat.protobuf:protoc}" = "${2:/usr/bin/protoc}"', - }, -]; - -const TEMPLATES_BY_GROUP: Record = { - "features": FEATURE_TEMPLATES, - "generated_files": GENERATED_FILE_TEMPLATES, - "capabilities": CAPABILITY_TEMPLATES, - "xlings.workspace": XLINGS_WORKSPACE_TEMPLATES, - "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, -}; - -function sectionHeaderSuggestions(range: ReplaceRange): McppTomlSuggestion[] { - return SECTION_HEADERS.map((section) => ({ - label: section.label, - kind: "section", - detail: section.detail, - insertSnippet: section.header, - range, - })); -} - -function templateSuggestions(templates: readonly TemplateSpec[], range: ReplaceRange): McppTomlSuggestion[] { - return templates.map((template) => ({ - label: template.label, - kind: "template", - detail: template.detail, - documentation: template.documentation, - insertSnippet: template.insertSnippet, - range, - })); -} - -/** - * 计算 mcpp.toml 在指定位置的补全建议(结构补全:段头 + 写法模板)。 - */ -export function computeMcppTomlCompletions( - lines: readonly string[], - line: number, - character: number, -): McppTomlSuggestion[] { - const context = contextAt(lines, line, character); - - if (context.kind === "section-header") { - // mcpp manifest 不使用 TOML 数组表([[...]]);[[ 内不提供建议, - // 避免把用户意图的数组表悄悄替换成普通段 [x](未知段会被 mcpp 静默忽略)。 - if (context.isArray) { - return []; - } - // parser 的替换范围从段名 token 开始;段头建议插入的是完整 "[xxx]", - // 需要把范围扩展到本行的 "[",避免留下 "[["。仅当 "[" 是行内首个 - // 非空白字符时才扩展(section-header 上下文正常都满足,防御奇怪输入)。 - const lineText = (lines[line] ?? "").replace(/\r$/, ""); - const bracket = lineText.indexOf("["); - const firstNonWs = lineText.search(/\S/); - const range = bracket >= 0 && bracket === firstNonWs - ? { startCharacter: bracket, endCharacter: context.replaceRange.endCharacter } - : context.replaceRange; - return sectionHeaderSuggestions(range); - } - - if (context.kind === "key") { - const { section, containerPath, replaceRange } = context; - // 文档顶部(尚无段头):提示段头。未知段:不提供建议 - // (附录 A:不支持包自定义 toml 键)。 - if (section.kind === "top") { - return sectionHeaderSuggestions(replaceRange); - } - if (section.kind !== "known" || containerPath.length > 0) { - return []; - } - if (DEPENDENCY_GROUPS.has(section.group)) { - return templateSuggestions(DEPENDENCY_TEMPLATES, replaceRange); - } - const templates = TEMPLATES_BY_GROUP[section.group]; - return templates === undefined ? [] : templateSuggestions(templates, replaceRange); - } - - // 值位置:自由格式值不瞎猜(版本候选等动态数据层落地后再说)。 - return []; -} diff --git a/src/mcppls/bridge.ts b/src/mcppls/bridge.ts new file mode 100644 index 0000000..55a7738 --- /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("manageConflicts", restore), + 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..67bbffa --- /dev/null +++ b/src/mcppls/contract.ts @@ -0,0 +1,260 @@ +/** + * 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.", + }, + { + key: "manageConflicts", + kind: "forward", + title: "Manage other C++ language features", + commands: ["mcppls.turnOffOtherCppFeatures", "mcppls.restoreOtherCppFeatures"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer conflict handling.", + confirmHint: "This changes settings belonging to other C++ extensions, at their own keys. C++ Modules performs the change.", + }, + { + 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/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..a63e92d --- /dev/null +++ b/src/mcppls/stateSource.ts @@ -0,0 +1,71 @@ +/** + * 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 { 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; +} + +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, + }; + } +} 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/process.ts b/src/process.ts deleted file mode 100644 index e6740cf..0000000 --- a/src/process.ts +++ /dev/null @@ -1,53 +0,0 @@ -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; -} - -export type ProcessRunner = ( - executable: string, - args: string[], - cwd?: string, - options?: ProcessRunOptions, -) => Promise; - -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 : ""), - }; - } -} 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/discovery.ts b/src/projects/discovery.ts similarity index 53% rename from src/discovery.ts rename to src/projects/discovery.ts index c7a08ff..2f680f4 100644 --- a/src/discovery.ts +++ b/src/projects/discovery.ts @@ -6,6 +6,20 @@ export interface McppProjectDiscovery { 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 === "" || ( @@ -18,9 +32,13 @@ function isPathWithin(candidate: string, root: string): boolean { export function findNearestMcppProject( startPath: string, workspaceRoot?: string, + boundary: DiscoveryBoundary = "workspaceFolder", ): McppProjectDiscovery | undefined { let current = path.resolve(startPath); - const boundary = workspaceRoot === undefined ? undefined : path.resolve(workspaceRoot); + // `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()) { @@ -30,7 +48,7 @@ export function findNearestMcppProject( // A newly-created workspace path may not exist yet; treat it as a directory. } - if (boundary !== undefined && !isPathWithin(current, boundary)) { + if (stop !== undefined && !isPathWithin(current, stop)) { return undefined; } @@ -39,7 +57,7 @@ export function findNearestMcppProject( if (existsSync(manifestPath)) { return { root: current, manifestPath }; } - if (boundary !== undefined && current === boundary) { + if (stop !== undefined && current === stop) { return undefined; } const parent = path.dirname(current); 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/mcppTomlParser.ts b/src/toml/parser.ts similarity index 99% rename from src/mcppTomlParser.ts rename to src/toml/parser.ts index 1276eca..9587c0e 100644 --- a/src/mcppTomlParser.ts +++ b/src/toml/parser.ts @@ -624,7 +624,8 @@ class Scanner { // 光标后方同行还有值 token(光标恰在 token 首字符、或在 = 与 token // 之间的空白上)时,替换范围要覆盖整个 token,否则补全插入后原文残留。 if (!this.atEol() && this.peek() !== "#") { - const ahead = this.scanValue(valuePath); // 光标落在 token 内时由 scanValue 捕获 + // 光标落在 token 内时由 scanValue 捕获。 + const ahead = this.scanValue(valuePath); this.captureValue(valuePath, ahead.kind, { replaceRange: Scanner.singleLineReplaceRange(ahead.range) ?? this.emptyReplaceRange(), }); @@ -681,7 +682,8 @@ class Scanner { if (open) { while (!this.eof()) { if (!multiline && this.atEol()) { - break; // 单行串跨行 → 未闭合 + // 单行串跨行 → 未闭合。 + break; } const ch = this.peek(); if (ch === "\\" && quote === '"') { @@ -788,7 +790,8 @@ class Scanner { const elementStart = this.pos(); elements.push(this.scanValue(keyPath)); if (this.samePos(this.pos(), elementStart)) { - this.advance(); // 进度保护 + // 进度保护。 + this.advance(); } } return { kind: "array", range: this.rangeFrom(start), open, elements }; @@ -825,7 +828,8 @@ class Scanner { entries.push(entry); } if (this.samePos(this.pos(), entryStart)) { - this.advance(); // 进度保护 + // 进度保护。 + this.advance(); } } return { kind: "inlineTable", range: this.rangeFrom(start), open, entries }; 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..6baa138 --- /dev/null +++ b/src/views/languageServerView.ts @@ -0,0 +1,345 @@ +/** + * 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, +} from "../mcppls/stateSource"; +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(), + }; + }; + + // `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, "manageConflicts", () => [true]); + 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") + ) { + 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..95028d1 --- /dev/null +++ b/src/views/models.ts @@ -0,0 +1,878 @@ +/** + * 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 is two labelled sections: **Basics** (what this project is) + * 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 + * second 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 { parseMcppToml } from "../toml/parser"; + +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.*`), 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 { + /** `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 */ + +/** The two manifest groups the project view reports. A dep is either runtime or dev. */ +const DEPENDENCY_GROUPS: Readonly> = { + dependencies: false, + "dev-dependencies": true, +}; + +function dependencyField(entry: DependencyDeclaration, key: string, value: string | undefined): void { + if (value === undefined || value.length === 0) { + return; + } + if (key === "version") { + entry.version = value; + } else if (key === "path") { + entry.path = value; + } else if (key === "git") { + entry.git = value; + } +} + +/** + * The declared dependencies, read with the fault-tolerant TOML scanner the + * completion provider already uses. + * + * Three shapes are recognised, because all three are legal mcpp.toml: + * `name = "1.0"`, `name = { path = "…" }` (including multi-line inline tables) + * and `[dependencies.name]` with the fields on following lines. `workspace` and + * `features` are deliberately ignored: neither changes what the row must say. + */ +export function readDependencies(lines: readonly string[]): DependencyDeclaration[] { + const found: DependencyDeclaration[] = []; + const byName = new Map(); + const ensure = (name: string, dev: boolean): DependencyDeclaration => { + const existing = byName.get(name); + if (existing !== undefined) { + return existing; + } + const entry: DependencyDeclaration = dev ? { name, dev: true } : { name }; + byName.set(name, entry); + found.push(entry); + return entry; + }; + + try { + let group: string | undefined; + let named: DependencyDeclaration | undefined; + for (const node of parseMcppToml(lines).nodes) { + if (node.type === "section") { + const segments = node.segments.map((segment) => segment.name); + const head = segments[0]; + if (head === undefined || DEPENDENCY_GROUPS[head] === undefined) { + group = undefined; + named = undefined; + continue; + } + group = head; + // `[dependencies.name]` names the dependency; its fields follow. + named = segments.length >= 2 ? ensure(segments.slice(1).join("."), DEPENDENCY_GROUPS[head] === true) : undefined; + continue; + } + if (group === undefined) { + continue; + } + const dev = DEPENDENCY_GROUPS[group] === true; + const key = node.keyPath.map((segment) => segment.name).join("."); + const value = node.value; + if (value === undefined) { + continue; + } + if (named !== undefined) { + dependencyField(named, key, value.text); + continue; + } + if (key.length === 0) { + continue; + } + const entry = ensure(key, dev); + if (value.kind === "string") { + dependencyField(entry, "version", value.text); + } else if (value.kind === "inlineTable") { + for (const field of value.entries ?? []) { + dependencyField(entry, field.keyPath.map((segment) => segment.name).join("."), field.value?.text); + } + } + } + } catch { + // A manifest being edited is not an error here; whatever was read stands. + } + return found; +} + +/* ------------------------------------------------------------------ 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[]; + }; +} + +/** 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; +} + +/** The project view: two labelled sections — what this is, then what to do with it. */ +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", + }, + ...dependencyNodes(project, options), + ...languageServiceNodes(options.languageService), + ]; + + return [ + { id: "project.section.basic", label: plain("Basics"), icon: "info", children: basic }, + { 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", + 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: true, + children: declared.map((entry) => dependencyNode(entry, lock)), + }, + ]; +} + +/** + * One declared dependency. + * + * Level 1 is the declaration, with its markers; level 2 is the single version + * `mcpp.lock` resolved, or `Resolved —` when the lock has nothing to say. There + * is deliberately no third level and no connector glyph: `mcpp.lock` is a flat + * set with no parent/child edges, so a deeper tree would be invented. + */ +function dependencyNode(declaration: DependencyDeclaration, lock: readonly LockPackage[]): TreeNode { + const resolved = resolveLockedPackage(lock, declaration.name); + const resolvedLabel: Label = + resolved?.version === undefined ? plain("Resolved —") : { key: "Resolved {0}", args: [resolved.version] }; + const marker = dependencyMarker(declaration); + const node: TreeNode = { + id: `project.dependency.${declaration.name}`, + label: plain(declaration.name), + description: marker === undefined ? resolvedLabel : { key: "{0} · {1}", args: [marker, resolvedLabel] }, + icon: declaration.path === undefined ? "package" : "folder", + }; + return node; +} + +function dependencyMarker(declaration: DependencyDeclaration): Label | undefined { + if (declaration.path !== undefined) { + return { key: "path · {0}", args: [declaration.path] }; + } + if (declaration.git !== undefined) { + return { key: "git · {0}", args: [declaration.git] }; + } + return declaration.dev === true ? plain("dev") : undefined; +} + +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", + }, + ]; + + (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; +} + +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..27e5239 --- /dev/null +++ b/src/views/projectView.ts @@ -0,0 +1,148 @@ +/** + * The project view: two labelled sections — 「基本信息」 and 「常用命令」. + * + * The first section is what the project is (identity, target, toolchain, the + * declared dependencies and the folded C++ Modules block); the second is what can + * be done to it. 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"; + +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); + 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); + + 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 => { + current = snapshot(deps.currentProject(), 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(() => reload(true)), + 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..67d5ce0 --- /dev/null +++ b/src/views/treeProvider.ts @@ -0,0 +1,111 @@ +/** + * 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); + 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/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/moduleSetup.ts b/src/workflows/moduleSetup.ts similarity index 88% rename from src/moduleSetup.ts rename to src/workflows/moduleSetup.ts index cc0557f..6810dde 100644 --- a/src/moduleSetup.ts +++ b/src/workflows/moduleSetup.ts @@ -1,3 +1,5 @@ +import { t } from "../i18n/t"; + export type ModuleSetupStage = "build" | "language-server"; export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled" | "not-started"; export type ModuleSetupBlockedReason = "untrusted" | "busy"; @@ -26,8 +28,8 @@ export interface ModuleSetupConfirmation { export function moduleSetupConfirmation(): ModuleSetupConfirmation { return { - message: "是否构建当前 mcpp 工程并刷新 C++ 模块语言服务?", - detail: "将执行 mcpp build,并由 C++ Modules 扩展重新读取构建描述;不会修改工具链、mcpp.toml 或任何语言服务设置。", + 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."), }; } diff --git a/test/architecture.test.ts b/test/architecture.test.ts new file mode 100644 index 0000000..4b6c377 --- /dev/null +++ b/test/architecture.test.ts @@ -0,0 +1,141 @@ +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/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 c3ca208..d710dbd 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { 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; @@ -15,13 +18,29 @@ interface PackageManifest { 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 }>; }; } @@ -30,43 +49,61 @@ const root = path.resolve(process.cwd()); 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.4.0"); - assert.equal(manifest.description, "mcpp 工程构建、工具链与 C++ Modules 语言服务集成"); + // 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")); - assert.ok(manifest.activationEvents?.includes("workspaceContains:mcpp.toml")); - assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.run")); - assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureLanguageServer")); - assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureClangd")); // deprecated alias + // 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", + "onLanguage:mcpp-build", + ]); assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); - assert.equal( - manifest.capabilities?.untrustedWorkspaces?.description, - "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。", + 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?.commands?.map((command) => command.command), - [ - "mcpp.showMenu", - "mcpp.newProject", - "mcpp.build", - "mcpp.run", - "mcpp.test", - "mcpp.clean", - "mcpp.showToolchains", - "mcpp.installToolchain", - "mcpp.selectDefaultToolchain", - "mcpp.configureLanguageServer", - "mcpp.configureClangd", - "mcpp.refreshCompilationDatabase", - "mcpp.checkModuleSupport", - "mcpp.autoConfigureModules", - "mcpp.showModuleGraph", - "mcpp.showLanguageServerLogs", - ], + manifest.contributes?.views?.mcpp?.map((view) => view.id), + ["mcpp.project", "mcpp.library", "mcpp.cache"], + ); + assert.deepEqual( + 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.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"], { @@ -78,10 +115,12 @@ test("declares mcpp-language-server as the C++ modules language service", () => }); test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服务", () => { - const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + 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("const mcppTomlCompletionKinds", start); + const end = source.indexOf("export async function activate(", start); assert.notEqual(start, -1); assert.notEqual(end, -1); const wizard = source.slice(start, end); @@ -100,11 +139,207 @@ test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服 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, /postResult\(session, \{ state: "ok", message: t\("Opened \{0\} in your browser\."/); + assert.match(panel, /postResult\(session, \{\s*state: "error",/); + assert.match(panel, /vscode\.env\.clipboard\.writeText\(url\)/); + // The client's own line, so the click is visible before the host answers. + const html = readFileSync(path.join(root, "src", "library", "detailHtml.ts"), "utf8"); + assert.match(html, /state: "pending"/); +}); + +/** `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 ?? []; @@ -220,7 +455,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); @@ -233,7 +468,7 @@ test("设置全局默认后先释放工具链锁再提供立即构建", () => { }); test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", () => { - 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 runProjectTask"); const end = source.indexOf("public async showToolchains", start); assert.notEqual(start, -1); @@ -246,7 +481,7 @@ test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", }); 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 +496,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 +527,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,7 +535,7 @@ 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.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"); @@ -309,24 +544,62 @@ test("声明 GitHub 仓库和扩展图标", () => { assert.deepEqual([...icon.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); }); -test("README 说明 mcpp 与 mcppls 的职责边界和升级限制", () => { +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, /sunrisepeak\.mcpp-language-server/); - assert.match(readme, /不启动第二个\s+LSP 客户端/); - assert.match(readme, /不表示本扩展读取用户的 `clangd\.\*` 设置/); - assert.match(readme, /mcpp\.path.*只控制/s); - assert.match(readme, /darwin-x64/); - assert.match(readme, /不再读取或写入它们/); - assert.doesNotMatch(readme, /mcpp\.clangd\.path.*匹配 LLVM/); - assert.doesNotMatch(readme, /当前完整的模块语义能力只支持 LLVM/); + 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\*"/); @@ -340,18 +613,68 @@ 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 体积与内容/); + assert.match(workflow, /forbidden in \.dev-profile\/ \.agents\/ test\/ tools\/ src\/ node_modules\//); + 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/); + assert.match(workflow, /forbidden in \.dev-profile\/ \.agents\/ test\/ tools\/ src\/ node_modules\//); + 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..ea3cb4b --- /dev/null +++ b/test/buildscript/analysis.test.ts @@ -0,0 +1,199 @@ +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", + ]); +}); 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.match(none, /This descriptor declares no dependencies\./); +}); + +test("the add button carries the exact command, and is disabled when there is no version", () => { + const html = renderDetailHtml(model(), ASSETS); + // One place chooses the version (the matrix) and one place shows what the + // choice produces; there is no second, redundant `Add to mcpp\.toml<\/button>/); + assert.match(html, //); + assert.match(none, /This index publishes no version for this platform\./); +}); + +test("a package the project already has says so, and the button offers the switch", () => { + // Same version as the manifest: nothing to do, and the button says so — with + // `data-installed`, which paints it the thinned green of a done thing. + const same = renderDetailHtml(model({ installed: { version: "3.2", dev: false } }), ASSETS); + assert.match( + same, + /