From 529c4b9815e5ff4ff59afda88f543d7eaa9c0228 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:22:59 +0800 Subject: [PATCH 01/56] =?UTF-8?q?refactor:=20=E7=9B=AE=E5=BD=95=E6=8C=89?= =?UTF-8?q?=E7=94=A8=E9=80=94=E5=88=86=E7=BB=84=EF=BC=8C=E5=88=A0=E9=99=A4?= =?UTF-8?q?=20clangd=20=E6=97=B6=E4=BB=A3=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/ 从 15 个平铺文件改为 cli/ projects/ toml/ mcppls/ commands/ workflows/ 分组 - test/ 与 src/ 镜像;e2e 套件改名 extension.e2e.ts,避免被 node --test 采集 - 删除 src/configureOnly.ts、src/ideWorkflow.ts(0.4.0 迁移后无生产调用者, 且与"不再解析 compile_commands.json"的职责边界冲突) - commands.ts 拆为 commands/ids.ts(命令 ID)与 commands/menu.ts(快捷菜单) - package.json test 脚本改用递归 glob dist/test/**/*.test.js - 纳入 .agents/docs 设计文档;.agents/reviews 保持 gitignore 行为零变化:179 个单测通过(原 188 个,减少的 9 个来自删除的死代码测试)。 --- .../2026-10-02-plugin-optimisation-plan.md | 1115 +++++++++++++++++ .agents/docs/README.md | 20 + .agents/docs/architecture.md | 77 ++ .agents/docs/mcpp-integration.md | 80 ++ .agents/docs/mcppls-integration.md | 179 +++ .gitignore | 3 + package.json | 2 +- src/{cliController.ts => cli/controller.ts} | 7 +- src/{ => cli}/newProject.ts | 0 src/{ => cli}/process.ts | 0 src/{ => cli}/tasks.ts | 0 src/{cli.ts => cli/toolchain.ts} | 0 src/commands/ids.ts | 23 + src/{commands.ts => commands/menu.ts} | 22 +- src/configureOnly.ts | 18 - src/extension.ts | 16 +- src/ideWorkflow.ts | 45 - src/{languageServer.ts => mcppls/bridge.ts} | 0 src/{inProject.ts => projects/context.ts} | 0 src/{ => projects}/discovery.ts | 0 .../completion.ts} | 2 +- src/{mcppTomlParser.ts => toml/parser.ts} | 0 src/{ => workflows}/moduleSetup.ts | 0 test/artifacts.test.ts | 16 +- test/{ => cli}/newProject.test.ts | 4 +- test/{ => cli}/process.test.ts | 2 +- test/{ => cli}/tasks.test.ts | 2 +- test/{cli.test.ts => cli/toolchain.test.ts} | 2 +- .../ids.test.ts} | 3 +- test/configureOnly.test.ts | 58 - .../{extension.test.ts => extension.e2e.ts} | 0 test/e2e/suite/index.ts | 2 +- test/ideWorkflow.test.ts | 118 -- .../bridge.test.ts} | 2 +- .../context.test.ts} | 2 +- test/{ => projects}/discovery.test.ts | 2 +- .../completion.test.ts} | 2 +- .../contract.test.ts} | 4 +- .../parser.test.ts} | 2 +- test/{ => workflows}/moduleSetup.test.ts | 2 +- 40 files changed, 1536 insertions(+), 296 deletions(-) create mode 100644 .agents/docs/2026-10-02-plugin-optimisation-plan.md create mode 100644 .agents/docs/README.md create mode 100644 .agents/docs/architecture.md create mode 100644 .agents/docs/mcpp-integration.md create mode 100644 .agents/docs/mcppls-integration.md rename src/{cliController.ts => cli/controller.ts} (99%) rename src/{ => cli}/newProject.ts (100%) rename src/{ => cli}/process.ts (100%) rename src/{ => cli}/tasks.ts (100%) rename src/{cli.ts => cli/toolchain.ts} (100%) create mode 100644 src/commands/ids.ts rename src/{commands.ts => commands/menu.ts} (68%) delete mode 100644 src/configureOnly.ts delete mode 100644 src/ideWorkflow.ts rename src/{languageServer.ts => mcppls/bridge.ts} (100%) rename src/{inProject.ts => projects/context.ts} (100%) rename src/{ => projects}/discovery.ts (100%) rename src/{mcppTomlCompletion.ts => toml/completion.ts} (99%) rename src/{mcppTomlParser.ts => toml/parser.ts} (100%) rename src/{ => workflows}/moduleSetup.ts (100%) rename test/{ => cli}/newProject.test.ts (97%) rename test/{ => cli}/process.test.ts (88%) rename test/{ => cli}/tasks.test.ts (99%) rename test/{cli.test.ts => cli/toolchain.test.ts} (99%) rename test/{commands.test.ts => commands/ids.test.ts} (92%) delete mode 100644 test/configureOnly.test.ts rename test/e2e/suite/{extension.test.ts => extension.e2e.ts} (100%) delete mode 100644 test/ideWorkflow.test.ts rename test/{languageServer.test.ts => mcppls/bridge.test.ts} (99%) rename test/{inProject.test.ts => projects/context.test.ts} (98%) rename test/{ => projects}/discovery.test.ts (97%) rename test/{mcppTomlCompletion.test.ts => toml/completion.test.ts} (99%) rename test/{mcppTomlContract.test.ts => toml/contract.test.ts} (98%) rename test/{mcppTomlParser.test.ts => toml/parser.test.ts} (99%) rename test/{ => workflows}/moduleSetup.test.ts (99%) diff --git a/.agents/docs/2026-10-02-plugin-optimisation-plan.md b/.agents/docs/2026-10-02-plugin-optimisation-plan.md new file mode 100644 index 0000000..74898ca --- /dev/null +++ b/.agents/docs/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_level`、`[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.22 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.22 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.22 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/README.md b/.agents/docs/README.md new file mode 100644 index 0000000..10bafe5 --- /dev/null +++ b/.agents/docs/README.md @@ -0,0 +1,20 @@ +# .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)的依赖与命令桥接契约 | 现状 | +| [`2026-10-02-plugin-optimisation-plan.md`](2026-10-02-plugin-optimisation-plan.md) | 插件优化方案 **v4**:目录树与 README、mcppls 依赖韧性、稳定基座、`mcpp.toml` 与 `build.mcpp` 编辑体验、缓存统计与两级清理、配色与可视化、i18n、统一配置模块与配置面板、**mcppls 状态与管理(§3.9)** | **待评审** | + +边界: + +- `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`。 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/mcpp-integration.md b/.agents/docs/mcpp-integration.md new file mode 100644 index 0000000..0aa53bb --- /dev/null +++ b/.agents/docs/mcpp-integration.md @@ -0,0 +1,80 @@ +# 与 mcpp CLI 的集成契约 + +> 对象:`mcpp-community/mcpp`(C++23 模块优先构建工具,下称 mcpp)。本文记录本扩展**实际 +> 调用**的 mcpp 接口,以及 mcpp 已经提供、本扩展尚未使用的稳定接口。 + +## 1. 本扩展实际调用的命令 + +| 调用点 | 命令 | 依赖的输出 | +| --- | --- | --- | +| `cliController.newProject` | `mcpp new ` | 退出码 | +| `tasks.projectTaskPlan` | `mcpp build` / `run` / `test` / `clean` | 退出码(VS Code Task) | +| `cliController.readToolchainInventory` | `mcpp toolchain list` | **人类文本**(正则解析) | +| `cliController.selectDefaultToolchainFromInventory` | `mcpp toolchain default ` | 退出码 | +| `cliController.pickInstallSpec` → `installToolchain` | `mcpp toolchain install ` | 退出码 | + +`mcpp.path` 为空时用 `"mcpp"`,由 VS Code 进程的 `PATH` 解析。 + +## 2. mcpp 已提供的稳定机读接口(本扩展未使用) + +mcpp 有正式的机读输出协议(`docs/50-machine-output.md`)。**检测规则**:解析 stdout, +要求 `schemaVersion` 与 `kind` 存在;**不要**用退出码或"命令没报错"来判断支持与否。 + +| 命令 | `kind` | 用途 | +| --- | --- | --- | +| `mcpp --protocol-version` | `mcpp.protocol` | 静态探测:信封版本、各 kind 版本、每个命令的 `effects`;无副作用 | +| `mcpp self env --format json` | `mcpp.env` | `mcppHome` / `registry` / `xlingsBinary` / `config` / `buildCache` / `mcppVersion` / `defaultToolchain`;**只读**,不会创建 `$MCPP_HOME` | +| `mcpp toolchain list --format json` | `mcpp.toolchain.list` | `{host, toolchains[], targets[]}`,字段 `family`/`version`/`default`/`source`,target 行含 `target`/`note`/`toolchain`/`pin`/`status`/`default` | +| `mcpp emit build-database [--spec s1\|compile-commands] --format json` | `mcpp.build-database` | 构建计划;**不写工程目录**(`--configure-only` 会写 `compile_commands.json`) | +| `mcpp why toolchain --format json` | `mcpp.why.toolchain` | 某个 (target, toolchain) 的解析依据,含 `reason` token | + +信封形态: + +```jsonc +{ "schemaVersion": 1, "kind": "...", "kindVersion": 1, + "effects": [], "mcpp": { "version": "2026.9.30.2", + "protocol": { "min": 1, "max": 1 } }, + "data": {}, "diagnostics": [] } +``` + +`kind` 内字段只增不删、含义不变;破坏性变更提升 `kindVersion`。 + +## 3. 流与退出码 + +- **stdout = 结果,stderr = 叙述**(2026.9.30.2 起;此前叙述在 stdout)。`mcpp toolchain list` + 这样的"列表"命令,列表本身在 stdout。 +- `--format json`(带信封)与 `--json`(裸文档,如 `mcpp cache list --json`)是两种**永久并存** + 的输出;`ndjson` 不被 `--format` 接受。 +- `mcpp build --configure-only` 在规划失败(无 `mcpp.toml`、工具链/依赖解析失败)时返回 **2**, + 而不是 1;`mcpp emit build-database` 对同类失败返回 **1** 并给出信封与 `diagnostics`。 +- 完整退出码契约(SPEC-003):`0` 成功、`1` 运行期失败、`2` 用法错误、**`4` 环境未就绪**、 + `70` 内部错误、`101` **仅 `mcpp run`** 的构建失败、`127` 未知命令。 +- **退出码不得用于协议识别**:判断某功能是否支持,唯一跨版本成立的判据是解析 stdout。 +- `1` 可以与 stdout 上的信封同时出现,不要因为非零退出就丢弃已解析到的文档。 +- `mcpp run` 透传被运行程序的退出码(`0–124`);`125/126/127` 是 spawn 被拒。 + +## 4. 明确不是接口的东西 + +`docs/51-supported-versions.md` 的表面稳定性表把 `mcpp.toml` 键、CLI 命令与标志、机读输出、 +`build.mcpp` 指令协议、`mcpp.lock`、target 行列为 additive;而 **构建指纹、缓存布局与 +`target/` 下的内容不是接口**,无通知即变。判断"要不要重新规划"请用 +`mcpp emit build-database` 的 `watch` 与 `inputs-fingerprint`。 + +## 5. mcpp 版本 + +形如 `2026.10.1.3`(`年.月.日.序号`),数字**不携带**兼容性承诺。`mcpp --version` 输出 +`mcpp `(**无** `v` 前缀);`mcpp --help` 的横幅反而带 `v`,不要从那里抓版本。 +机读版本号在 `mcpp self env --format json` 的 `data.mcppVersion`,以及任何信封的 +`mcpp.version`。mcpp 自身仍标注为早期项目,接口与行为可能变化;`docs/50` 与 `docs/specs/` +是兼容性承诺的来源。 + +## 6. 本扩展对 mcpp 的已知假设(需要随 mcpp 版本复核) + +1. `mcpp toolchain list` 的人类输出包含 `Toolchains:` / `Targets:` / `Available toolchains...:` 段, + 用 `*` 标记有效项,并用 `global default is ''` 报告全局默认。 + —— 在 mcpp 2026.9.30.2 上已观察不到该 `global default` 行,解析器会退化为用"有效项"充当 + 全局默认。 +2. `mcpp toolchain list` 的每一行可用 `family version [/ version...]` 或 `family@version` 形式解析。 +3. `mcpp new ` 的模板不做 TOML/C++ 转义,且名字包含 `PROJECT` 时模板替换不终止 + (mcpp#380);`newProject.ts` 因此做名称白名单校验。 +4. `--configure-only` 会写 `compile_commands.json`,成功条件是退出码 0 且该文件可解析。 diff --git a/.agents/docs/mcppls-integration.md b/.agents/docs/mcppls-integration.md new file mode 100644 index 0000000..3e85850 --- /dev/null +++ b/.agents/docs/mcppls-integration.md @@ -0,0 +1,179 @@ +# 与 mcppls 的集成契约 + +> 对象:`sunrisepeak.mcpp-language-server`(显示名 **C++ Modules Language Server**,仓库 +> `Sunrisepeak/mcpp-language-server`,下称 mcppls)。本文记录本扩展**依赖的**契约与 +> **不确定的**部分。撰写时对照 mcppls `0.0.9`。 + +## 1. 依赖声明 + +```json +"extensionDependencies": ["sunrisepeak.mcpp-language-server"] +``` + +VS Code 的 `extensionDependencies` **只接受扩展 ID,没有版本范围语法**。因此: + +- 安装期:VS Code 自动安装当前平台可用的 mcppls(平台由 mcppls 的 + `vsce package --target ` 决定),无需用户操作; +- 升级:用户升级 mcppls 后本扩展立即使用新版本,无需重装; +- **没有版本下限、没有版本上限、没有安装期兼容性校验**。 + +mcppls 侧**没有** `extensionDependencies`:依赖是单向的。 + +## 2. 本扩展调用的命令(全部耦合面) + +### 2.1 第一批(v1 已有) + +| 命令 ID | 本扩展中的用途 | +| --- | --- | +| `mcppls.selectContext` | `mcpp.configureLanguageServer`(+ 弃用别名 `mcpp.configureClangd`) | +| `mcppls.restartServer` | `mcpp.checkModuleSupport`;build 之后 | +| `mcppls.showModuleGraph` | `mcpp.showModuleGraph` | +| `mcppls.showLogs` | `mcpp.showLanguageServerLogs` | + +### 2.2 第二批(§3.9「C++ Modules」视图) + +| 命令 ID | 用途 | 危险级 | +| --- | --- | --- | +| `mcppls.restartServer` | 重启语言服务 | none | +| `mcppls.restartClangd` | 重启核心引擎(服务器命令 `mcppls.restartEngine`) | confirm | +| `mcppls.resetWorkspaceCache` | **重置本工作区的模型缓存**(服务器命令 `mcppls.resetCache`) | **destructive** | +| `mcppls.selectContext` | 选择分析上下文 | none | +| `mcppls.showModuleGraph` | 模块图 | none | +| `mcppls.showLogs` | 打开日志频道 | none | +| `mcppls.collectReport` | 收集诊断报告 | none | +| `mcppls.exportDiagnosticBundle` | 导出诊断包(服务器命令 `mcppls.exportBundle`) | none | +| `mcppls.runBuildToolInTerminal` | 在集成终端运行构建工具 | confirm | +| `mcppls.turnOffOtherCppFeatures` / `mcppls.restoreOtherCppFeatures` | 关闭/恢复其它 C++ 扩展的语言特性(**会写别的扩展自己的设置**,由 mcppls 执行) | confirm | +| `mcppls.turnOnInWorkspace` / `mcppls.turnOffInWorkspace` | 在本工作区启用/停用 C++ Modules(**会写 `mcppls.enable`**,由 mcppls 执行) | confirm | +| `mcppls.installCommandLineTools` | macOS 命令行工具 | confirm | +| `mcppls.review.run` / `mcppls.review.clear` | Review Changes(**服务器广告**的命令,服务器运行后才存在;受 `mcppls.ai.enabled` 约束) | none | + +调用前只检查 `vscode.extensions.getExtension(id) !== undefined`,成功与否由 +`vscode.commands.executeCommand` 是否抛出决定。**任何 `mcppls.*` 设置都由 mcppls 自己写**, +本扩展只在用户显式点击时转发它自己的命令。 + +**这些命令 ID 不是 mcppls 文档化的跨扩展 API。** mcppls 的 `docs/` 里从未出现这些 ID 的 +字面量,`package.json` 也没有 `exports`/public API 章节。它们是 mcppls 自己的 UI 命令 +(`editors/vscode/src/commands.ts:516-530` 的 `registerCommands`),随 mcppls 版本自由变动。 +当前唯一的护栏是**本仓库的测试**。 + +## 3. 不可违反的规则:不要注册 `mcppls.*` 命令 + +mcppls 的 S3 规范第 **S3-5.6-3** 条(`docs/specs/s3-lsp-extensions.md:267`): + +> A client **MUST NOT** register a command of its own under an id the server advertises. + +因为 `vscode-languageclient` 会为服务器 `executeCommandProvider` 广告的每个命令注册一个 +VS Code 命令,重名会让 mcppls 的语言客户端启动失败。mcppls 因此成对存在: + +| 扩展自有命令 | 服务器命令 | +| --- | --- | +| `mcppls.resetWorkspaceCache` | `mcppls.resetCache` | +| `mcppls.exportDiagnosticBundle` | `mcppls.exportBundle` | +| `mcppls.restartClangd` | `mcppls.restartEngine` | + +**本扩展的任何新命令都必须用 `mcpp.` 前缀。** 也不要为了"补上"缺失的刷新命令而自己注册 +`mcppls.reloadBuildDescription` —— 它已经在服务器的命令列表里。 + +服务器广告的完整命令列表(`src/orchestrator/routing.cpp:158`): +`mcppls.review.run`、`mcppls.review.clear`、`mcppls.reloadBuildDescription`、 +`mcppls.describeOnline`、`mcppls.restartEngine`、`mcppls.exportBundle`、`mcppls.resetCache`。 + +## 4. mcppls 侧的相关事实 + +- **扩展版本与产品版本一致**(三段 `MAJOR.MINOR.PATCH`),由 `mcppls-devtools version --check` + 强制与四端插件同步;服务器版本另写在 payload 的 `payload.json` 里。 +- **`extensionKind: ["workspace"]`**;`capabilities.untrustedWorkspaces.supported = "limited"` + 且 `restrictedConfigurations: ["mcppls.compiler"]`;**`capabilities.virtualWorkspaces: false`**。 +- mcppls 的 `activate()` 返回的是**测试 API**(`TestApi`)—— 见 §4.1,本扩展**尽力而为**地 + 用它读取状态,且这是它唯一的非命令行通道。 +- **`mcppls.mcpp`**(mcpp 可执行文件路径)在服务器注册表里存在 + (`src/config/settings.cpp:287`),但 `Surface::server` + `clientConfigurable = false`, + 所以 VS Code 设置里看不到它,本扩展也无法传递 `mcpp.path`。 + 为空时 mcppls 按 **`PATH` → `$HOME/.mcpp/bin/mcpp` → `$HOME/.xlings/subos/current/bin/mcpp`** + 查找(`src/project/provider.cpp:12-20`、`src/project/mcpp.cpp:341`),**不使用 `mcpp self env`**。 +- mcppls 用 `mcpp --protocol-version` 协商,只有声明了 `mcpp.build-database` 才运行 + `mcpp emit build-database --format json`;最后兜底才是 `mcpp build --configure-only` + (`src/project/mcpp.cpp:226,249-258,277,373-395`)。 +- mcppls 内部通过 LSP `workspace/executeCommand` 暴露 `mcppls.reloadBuildDescription`, + 并在窗口重新获得焦点时自行调用(`editors/vscode/src/extension.ts:647`);但它 + **没有注册为 VS Code 命令**,所以本扩展无法调用(上游诉求 U.2)。 +- payload 有强校验:`payload-version ∈ {1,2,3}`、`manifest.platform` 必须等于 + `${process.platform}-${process.arch}`(`editors/vscode/src/payload.ts:25-27,124-129`)。 +- mcppls 只读 **`mcppls.*`** 设置,从不读 `mcpp.*`。 +- mcppls 的诊断报告会读取 `mcpp-community.mcpp-vscode` 的 `packageJSON.version` + 作为环境信息(`editors/vscode/src/commands.ts:269`),并把 mcpp-vscode 明确排除在 + C++ 语言服务冲突候选之外(`editors/vscode/src/conflictCandidates.ts:23`)—— + 这是 mcppls 对 mcpp-vscode 的单向了解,不构成兼容性判断。 + +### 4.1 状态读取:`extension.exports`(⚠ 非契约) + +`activate()` 返回的对象在 mcppls 内部被命名为 **TestApi**,不是公开 API。但其中若干字段 +**在非测试模式下也有效**,是本扩展获得 LSP 状态的唯一通道(我们没有 LSP 客户端, +也禁止再建一个): + +| 字段 | 非测试模式下是否有效 | 用途 | +| --- | --- | --- | +| `lastStatus(): CxxModulesStatus \| undefined` | ✅(`status.lastStatus()` 读的是控制器当前状态) | 视图的全部状态内容 | +| `statusBarText(): string` | ✅ | 与 mcppls 自己的状态栏文案一致 | +| `serverRunning()` / `serverEnabled()` | ✅ | 显示"运行中/已停用" | +| `serverCommands(): string[]` | ✅(服务器未运行时为空数组) | 判断服务器是否广告了某命令 | +| `environment()` | ✅ | version / vscode / appName / appHost / uiKind / platform / remote | +| `waitForState()` | ✅ | 需要等待某个状态时使用 | +| 计数器类(`notificationCount` 等) | ❌ 非测试模式下返回 `-1`/`0` | **不使用** | + +**使用纪律**(写进 `mcpp-vscode/src/mcppls/state.ts`,并有单测与 e2e 覆盖): + +1. 形状探测:`exports` 是对象且 `typeof exports.lastStatus === "function"`,否则 `available: false`; +2. `try/catch` 包住调用; +3. 校验返回值的 `state` 属于 S3 的六个取值之一,否则视为**未知形状**; +4. `issues[].code` / `engines[].state` 用白名单渲染,未知值只显示文本、不解释; +5. 由 `mcpp.languageService.readState`(默认 true)可整体关闭; +6. **不轮询**:默认只在 `extensions.onDidChange`、命令完成回调、用户手动刷新时读。 + +`CxxModulesStatus` 的**内容**是 S3 规范级契约(`docs/specs/s3-lsp-extensions.md` §4): +`state` / `project{root,source,level,tier}` / `profile` / `engine` / `engines[]` / `progress` / +`issues[]`(含**自带的修复 `command`**)/ `notices[]` / `onlineRun`。 +不稳定的只是"能不能拿到这个对象",所以降级路径必须存在。 + +**拿不到的**:mcppls 自己的缓存目录与体量。`environment()` 不含缓存路径,磁盘布局是内部实现, +本扩展**不读、不解析、不删除**;"清理"只通过 mcppls 自己的 +`mcppls.resetWorkspaceCache`(调用前必须 modal 确认)。上游诉求见 U.5。 + +## 5. 平台矩阵 + +| 平台 | mcppls VSIX | +| --- | --- | +| linux-x64 | ✅ | +| linux-arm64 | ✅ | +| darwin-arm64 | ✅ | +| win32-x64 | ✅ | +| darwin-x64 | ❌ 无包 | +| win32-arm64 | ❌ 无包 | + +来源:`packaging/release.manifest.json` 的 `platforms`,并由 +`mcppls-devtools check platforms` 与 `packaging/payload.lock.json`、 +`editors/vscode/src/payload.ts` 的 `SUPPORTED_PLATFORMS`、CI 矩阵四处比对。 +在不支持的平台上,`extensionDependencies` 解析不到可安装的 mcppls,**本扩展不会被激活** +(硬依赖在安装/激活阶段解析),而不是激活后返回 `unavailable`。这条行为应在上线前用干净 +extensions 目录实测确认(含离线安装场景),并记录在此。 + +## 6. 版本探测的可行手段(当前未使用) + +```ts +const dependency = vscode.extensions.getExtension("sunrisepeak.mcpp-language-server"); +const version = dependency?.packageJSON?.version; // 标准且受支持 +const commands = await vscode.commands.getCommands(true); // 可做命令存在性探测 +``` + +引入任何版本门禁或能力降级前,请先补齐这两项,并把结论写入本文件。 + +## 7. 变更流程 + +mcppls 侧发生以下任一变化时,本文件与 `src/languageServer.ts` 必须同步: + +- 四个命令 ID 的增删改名; +- 命令语义变化(例如 `selectContext` 不再弹 QuickPick); +- 平台矩阵变化; +- 出现新的公开刷新/状态命令; +- 服务器 `executeCommandProvider` 列表变化(影响 §3 的命名约束)。 diff --git a/.gitignore b/.gitignore index 46f3a90..124d17c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ *.vsix .DS_Store .vscode-test/ + +# 分析报告与评审记录:本地产物,只保留 .agents/docs +.agents/reviews/ diff --git a/package.json b/package.json index 5b43e50..01ca05c 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "compile": "tsc -p tsconfig.json", - "test": "npm run clean && npm run compile && node --test dist/test/*.test.js", + "test": "npm run clean && npm run compile && node --test \"dist/test/**/*.test.js\"", "package": "npm run clean && npm run compile && vsce package", "test:e2e": "npm run compile && node dist/test/e2e/runTest.js", "test:all": "npm test && npm run test:e2e" diff --git a/src/cliController.ts b/src/cli/controller.ts similarity index 99% rename from src/cliController.ts rename to src/cli/controller.ts index 79e9fc9..29a97d5 100644 --- a/src/cliController.ts +++ b/src/cli/controller.ts @@ -13,8 +13,8 @@ import { toolchainSpecTargetHint, type ToolchainInventory, type ToolchainItem, -} from "./cli"; -import type { McppProjectDiscovery } from "./discovery"; +} from "./toolchain"; +import type { McppProjectDiscovery } from "../projects/discovery"; import { runProcess } from "./process"; import { McppOperationRegistry, @@ -24,7 +24,8 @@ import { type ProjectTaskKind, type TaskCompletion, } from "./tasks"; -import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; +import { CLI_COMMANDS } from "../commands/ids"; +import { quickMenuItems, quickMenuStatusText } from "../commands/menu"; import { runNewProjectFlow, validateNewProjectName } from "./newProject"; export interface McppCliControllerOptions { diff --git a/src/newProject.ts b/src/cli/newProject.ts similarity index 100% rename from src/newProject.ts rename to src/cli/newProject.ts diff --git a/src/process.ts b/src/cli/process.ts similarity index 100% rename from src/process.ts rename to src/cli/process.ts diff --git a/src/tasks.ts b/src/cli/tasks.ts similarity index 100% rename from src/tasks.ts rename to src/cli/tasks.ts 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/commands/ids.ts b/src/commands/ids.ts new file mode 100644 index 0000000..134c8ae --- /dev/null +++ b/src/commands/ids.ts @@ -0,0 +1,23 @@ +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 type CliCommandId = (typeof CLI_COMMANDS)[keyof typeof CLI_COMMANDS]; diff --git a/src/commands.ts b/src/commands/menu.ts similarity index 68% rename from src/commands.ts rename to src/commands/menu.ts index eb60afe..1b47cc1 100644 --- a/src/commands.ts +++ b/src/commands/menu.ts @@ -1,24 +1,4 @@ -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; +import { CLI_COMMANDS } from "./ids"; export const quickMenuStatusText = "$(tools) mcpp: 快捷菜单"; 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..969d96b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,23 +1,23 @@ 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 } from "./cli/controller"; +import { CLI_COMMANDS, DEPRECATED_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 { computeMcppTomlCompletions } from "./toml/completion"; import { buildModuleSetupPlan, executeModuleSetup, moduleSetupConfirmation, type ModuleSetupDecision, type ModuleSetupStepResult, -} from "./moduleSetup"; -import type { TaskCompletion } from "./tasks"; +} from "./workflows/moduleSetup"; +import type { TaskCompletion } from "./cli/tasks"; function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; 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/mcppls/bridge.ts similarity index 100% rename from src/languageServer.ts rename to src/mcppls/bridge.ts diff --git a/src/inProject.ts b/src/projects/context.ts similarity index 100% rename from src/inProject.ts rename to src/projects/context.ts diff --git a/src/discovery.ts b/src/projects/discovery.ts similarity index 100% rename from src/discovery.ts rename to src/projects/discovery.ts diff --git a/src/mcppTomlCompletion.ts b/src/toml/completion.ts similarity index 99% rename from src/mcppTomlCompletion.ts rename to src/toml/completion.ts index 016af8f..11ce41d 100644 --- a/src/mcppTomlCompletion.ts +++ b/src/toml/completion.ts @@ -11,7 +11,7 @@ import { contextAt, type ReplaceRange, type SectionResolution, -} from "./mcppTomlParser"; +} from "./parser"; export type McppTomlSuggestionKind = "section" | "template"; diff --git a/src/mcppTomlParser.ts b/src/toml/parser.ts similarity index 100% rename from src/mcppTomlParser.ts rename to src/toml/parser.ts diff --git a/src/moduleSetup.ts b/src/workflows/moduleSetup.ts similarity index 100% rename from src/moduleSetup.ts rename to src/workflows/moduleSetup.ts diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index c3ca208..b55e642 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -78,7 +78,7 @@ 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"); const start = source.indexOf("async function autoConfigureModulesWizard"); const end = source.indexOf("const mcppTomlCompletionKinds", start); @@ -220,7 +220,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 +233,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 +246,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,7 +261,7 @@ 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); @@ -270,7 +270,7 @@ test("泛化 triple 工具链由 mcpp 最终校验", () => { }); 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 +292,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/); @@ -322,7 +322,7 @@ test("README 说明 mcpp 与 mcppls 的职责边界和升级限制", () => { }); 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, /当前是工作区成员/); }); diff --git a/test/newProject.test.ts b/test/cli/newProject.test.ts similarity index 97% rename from test/newProject.test.ts rename to test/cli/newProject.test.ts index 420c642..d82d7f0 100644 --- a/test/newProject.test.ts +++ b/test/cli/newProject.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { validateNewProjectName } from "../src/newProject"; +import { validateNewProjectName } from "../../src/cli/newProject"; test("拒绝空值和纯空白项目名", () => { for (const name of ["", " "]) { @@ -64,7 +64,7 @@ test("接受常规项目名,前后空白忽略", () => { assert.equal(validateNewProjectName(name), undefined, `should accept: ${name}`); } }); -import { runNewProjectFlow, type NewProjectActions } from "../src/newProject"; +import { runNewProjectFlow, type NewProjectActions } from "../../src/cli/newProject"; function recordingActions(overrides: Partial, calls: string[]): NewProjectActions { return { diff --git a/test/process.test.ts b/test/cli/process.test.ts similarity index 88% rename from test/process.test.ts rename to test/cli/process.test.ts index 363d0d0..cb0574b 100644 --- a/test/process.test.ts +++ b/test/cli/process.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import process from "node:process"; import test from "node:test"; -import { runProcess } from "../src/process"; +import { runProcess } from "../../src/cli/process"; test("captures output and exit status from a real child process", async () => { const result = await runProcess(process.execPath, ["-e", "process.stdout.write('ok')"]); diff --git a/test/tasks.test.ts b/test/cli/tasks.test.ts similarity index 99% rename from test/tasks.test.ts rename to test/cli/tasks.test.ts index 86ca012..bded3bb 100644 --- a/test/tasks.test.ts +++ b/test/cli/tasks.test.ts @@ -6,7 +6,7 @@ import { classifyTaskExit, projectTaskPlan, shouldRefreshLanguageServerAfterTask, -} from "../src/tasks"; +} from "../../src/cli/tasks"; test("基础项目任务使用固定的 mcpp 参数数组", () => { assert.deepEqual(projectTaskPlan("build"), { diff --git a/test/cli.test.ts b/test/cli/toolchain.test.ts similarity index 99% rename from test/cli.test.ts rename to test/cli/toolchain.test.ts index aaa95ad..f717c39 100644 --- a/test/cli.test.ts +++ b/test/cli/toolchain.test.ts @@ -9,7 +9,7 @@ import { parseToolchainList, toolchainInstallKind, toolchainSpecTargetHint, -} from "../src/cli"; +} from "../../src/cli/toolchain"; const plainOutput = [ "Toolchains:", diff --git a/test/commands.test.ts b/test/commands/ids.test.ts similarity index 92% rename from test/commands.test.ts rename to test/commands/ids.test.ts index f9025b6..c8c7092 100644 --- a/test/commands.test.ts +++ b/test/commands/ids.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { CLI_COMMANDS, DEPRECATED_COMMANDS, quickMenuItems, quickMenuStatusText } from "../src/commands"; +import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "../../src/commands/ids"; +import { quickMenuItems, quickMenuStatusText } from "../../src/commands/menu"; test("状态栏快捷菜单名称与 mcpp 项目状态易于区分", () => { assert.equal(quickMenuStatusText, "$(tools) mcpp: 快捷菜单"); diff --git a/test/configureOnly.test.ts b/test/configureOnly.test.ts deleted file mode 100644 index 890abb9..0000000 --- a/test/configureOnly.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { configureOnlyArguments, runConfigureOnly } from "../src/configureOnly"; - -test("runs mcpp build --configure-only with the configured executable and cwd", async () => { - const calls: Array<{ - executable: string; - args: string[]; - cwd?: string; - timeoutMs?: number; - }> = []; - const result = await runConfigureOnly( - "/work/app", - "/tools/mcpp", - async (executable, args, cwd, options) => { - calls.push({ executable, args, cwd, timeoutMs: options?.timeoutMs }); - return { exitCode: 0, stdout: "Configured 2 compile commands", stderr: "" }; - }, - ); - - assert.deepEqual(calls, [{ - executable: "/tools/mcpp", - args: [...configureOnlyArguments], - cwd: "/work/app", - timeoutMs: 5 * 60_000, - }]); - assert.equal(result.exitCode, 0); -}); - -test("does not interpret human-readable configure output", async () => { - const result = await runConfigureOnly( - "/work/app", - "/tools/mcpp", - async () => ({ - exitCode: 0, - stdout: "Configured 2 compile commands\nFinished dev in 0.02s", - stderr: "", - }), - ); - - assert.equal(result.stdout, "Configured 2 compile commands\nFinished dev in 0.02s"); -}); - -test("returns a non-zero configure-only exit code without parsing stdout", async () => { - const result = await runConfigureOnly( - "/work/app", - "/tools/mcpp", - async () => ({ - exitCode: 2, - stdout: "error: unknown option '--configure-only'", - stderr: "", - }), - ); - - assert.equal(result.exitCode, 2); - assert.equal(result.stdout, "error: unknown option '--configure-only'"); -}); diff --git a/test/e2e/suite/extension.test.ts b/test/e2e/suite/extension.e2e.ts similarity index 100% rename from test/e2e/suite/extension.test.ts rename to test/e2e/suite/extension.e2e.ts diff --git a/test/e2e/suite/index.ts b/test/e2e/suite/index.ts index 7181a3d..392d38b 100644 --- a/test/e2e/suite/index.ts +++ b/test/e2e/suite/index.ts @@ -3,7 +3,7 @@ import path from "node:path"; export function run(): Promise { const mocha = new Mocha({ ui: "tdd", color: true, timeout: 30_000 }); - mocha.addFile(path.resolve(__dirname, "extension.test.js")); + mocha.addFile(path.resolve(__dirname, "extension.e2e.js")); return new Promise((resolvePromise, reject) => { mocha.run((failures) => { if (failures === 0) { diff --git a/test/ideWorkflow.test.ts b/test/ideWorkflow.test.ts deleted file mode 100644 index 0f1f415..0000000 --- a/test/ideWorkflow.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { ensureIdeConfigured } from "../src/ideWorkflow"; - -const success = { exitCode: 0, stdout: "Configured 1 compile command", stderr: "" }; - -test("configures a trusted project before clangd when the CDB is missing", async () => { - let calls = 0; - const outcome = await ensureIdeConfigured({ - projectRoot: "/work/app", - compilationDatabasePath: "/work/app/compile_commands.json", - trusted: true, - databaseValid: () => calls > 0, - configure: async () => { - calls += 1; - return success; - }, - }); - - assert.equal(calls, 1); - assert.deepEqual(outcome, { - state: "configured", - compileCommands: "/work/app/compile_commands.json", - }); -}); - -test("keeps a valid existing CDB without invoking mcpp", async () => { - let called = false; - const outcome = await ensureIdeConfigured({ - projectRoot: "/work/app", - compilationDatabasePath: "/work/app/compile_commands.json", - trusted: true, - databaseValid: () => true, - configure: async () => { - called = true; - throw new Error("must not run"); - }, - }); - - assert.equal(called, false); - assert.deepEqual(outcome, { - state: "existing", - compileCommands: "/work/app/compile_commands.json", - }); -}); - -test("does not execute mcpp in an untrusted workspace", async () => { - let called = false; - const outcome = await ensureIdeConfigured({ - projectRoot: "/work/app", - compilationDatabasePath: "/work/app/compile_commands.json", - trusted: false, - databaseValid: () => false, - configure: async () => { - called = true; - throw new Error("must not run"); - }, - }); - - assert.equal(called, false); - assert.deepEqual(outcome, { state: "untrusted" }); -}); - -test("retains an existing CDB when forced configure-only fails", async () => { - let called = false; - const outcome = await ensureIdeConfigured({ - projectRoot: "/work/app", - compilationDatabasePath: "/work/app/compile_commands.json", - trusted: true, - force: true, - databaseValid: () => true, - configure: async () => { - called = true; - return { exitCode: 1, stdout: "compile failed", stderr: "" }; - }, - }); - - assert.equal(called, true); - assert.deepEqual(outcome, { - state: "failed", - compileCommands: "/work/app/compile_commands.json", - exitCode: 1, - }); -}); - -test("requires a valid CDB after configure-only succeeds", async () => { - const outcome = await ensureIdeConfigured({ - projectRoot: "/work/app", - compilationDatabasePath: "/work/app/compile_commands.json", - trusted: true, - databaseValid: () => false, - configure: async () => success, - }); - - assert.deepEqual(outcome, { state: "failed", exitCode: 0 }); -}); - -test("force refresh invokes configure-only when a CDB already exists", async () => { - let calls = 0; - const outcome = await ensureIdeConfigured({ - projectRoot: "/work/app", - compilationDatabasePath: "/work/app/compile_commands.json", - trusted: true, - force: true, - databaseValid: () => true, - configure: async () => { - calls += 1; - return success; - }, - }); - - assert.equal(calls, 1); - assert.deepEqual(outcome, { - state: "configured", - compileCommands: "/work/app/compile_commands.json", - }); -}); diff --git a/test/languageServer.test.ts b/test/mcppls/bridge.test.ts similarity index 99% rename from test/languageServer.test.ts rename to test/mcppls/bridge.test.ts index 373e5f7..dd398d2 100644 --- a/test/languageServer.test.ts +++ b/test/mcppls/bridge.test.ts @@ -5,7 +5,7 @@ import { createLanguageServerBridge, MCPPLS_COMMANDS, MCPPLS_EXTENSION_ID, -} from "../src/languageServer"; +} from "../../src/mcppls/bridge"; function harness(installed = true) { const calls: Array<{ command: string; args: unknown[] }> = []; diff --git a/test/inProject.test.ts b/test/projects/context.test.ts similarity index 98% rename from test/inProject.test.ts rename to test/projects/context.test.ts index ba4e942..333df7b 100644 --- a/test/inProject.test.ts +++ b/test/projects/context.test.ts @@ -7,7 +7,7 @@ import { registerInProjectContext, updateInProjectContext, type InProjectEnvironment, -} from "../src/inProject"; +} from "../../src/projects/context"; interface FakeState { project: unknown | undefined; diff --git a/test/discovery.test.ts b/test/projects/discovery.test.ts similarity index 97% rename from test/discovery.test.ts rename to test/projects/discovery.test.ts index 534c834..cdc6347 100644 --- a/test/discovery.test.ts +++ b/test/projects/discovery.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { findNearestMcppProject } from "../src/discovery"; +import { findNearestMcppProject } from "../../src/projects/discovery"; test("finds the nearest mcpp manifest and project root", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-discovery-")); diff --git a/test/mcppTomlCompletion.test.ts b/test/toml/completion.test.ts similarity index 99% rename from test/mcppTomlCompletion.test.ts rename to test/toml/completion.test.ts index 2d2e132..e7b75fe 100644 --- a/test/mcppTomlCompletion.test.ts +++ b/test/toml/completion.test.ts @@ -4,7 +4,7 @@ import test from "node:test"; import { computeMcppTomlCompletions, type McppTomlSuggestion, -} from "../src/mcppTomlCompletion"; +} from "../../src/toml/completion"; function labels(suggestions: McppTomlSuggestion[]): string[] { return suggestions.map((suggestion) => suggestion.label); diff --git a/test/mcppTomlContract.test.ts b/test/toml/contract.test.ts similarity index 98% rename from test/mcppTomlContract.test.ts rename to test/toml/contract.test.ts index cba1865..e6c3281 100644 --- a/test/mcppTomlContract.test.ts +++ b/test/toml/contract.test.ts @@ -10,7 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; import test from "node:test"; -import { SECTION_HEADERS, type SectionHeaderSpec } from "../src/mcppTomlCompletion"; +import { SECTION_HEADERS, type SectionHeaderSpec } from "../../src/toml/completion"; /** 探测 mcpp 是否可用;不可用则全部跳过。 */ function detectMcpp(): string | undefined { @@ -143,7 +143,7 @@ test("[indices] 带 path 条目被接受(项目级索引重定向)", { skip: assertClean(run, "[indices] 索引重定向"); }); -// 依赖 spec 的 12 个键:与 src/mcppTomlCompletion.ts 的 DEPENDENCY_TEMPLATES +// 依赖 spec 的 12 个键:与 src/toml/completion.ts 的 DEPENDENCY_TEMPLATES // 保持同步(模板未逐一列出键名,此处按 mcpp manifest schema 硬编码)。 // 注意:features/backend/tools/host-module/reexport 不是「锚定键」——单独出现 // 时 mcpp 会把内联表当成嵌套依赖表报错,必须搭配 version/path/git/workspace diff --git a/test/mcppTomlParser.test.ts b/test/toml/parser.test.ts similarity index 99% rename from test/mcppTomlParser.test.ts rename to test/toml/parser.test.ts index fb29820..ddb5e79 100644 --- a/test/mcppTomlParser.test.ts +++ b/test/toml/parser.test.ts @@ -7,7 +7,7 @@ import { resolveSection, type TomlKeyValueNode, type TomlSectionNode, -} from "../src/mcppTomlParser"; +} from "../../src/toml/parser"; function sectionAt(lines: string[], index: number): TomlSectionNode { const node = parseMcppToml(lines).nodes[index]; diff --git a/test/moduleSetup.test.ts b/test/workflows/moduleSetup.test.ts similarity index 99% rename from test/moduleSetup.test.ts rename to test/workflows/moduleSetup.test.ts index 489f260..336ac6d 100644 --- a/test/moduleSetup.test.ts +++ b/test/workflows/moduleSetup.test.ts @@ -7,7 +7,7 @@ import { moduleSetupConfirmation, type ModuleSetupOperations, type ModuleSetupStepResult, -} from "../src/moduleSetup"; +} from "../../src/workflows/moduleSetup"; test("只信任且不忙时可开始一键配置", () => { assert.deepEqual(buildModuleSetupPlan(true, false), { kind: "ready" }); From 5a439a4050fc795d39db56bf258c263e659634f5 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:32:24 +0800 Subject: [PATCH 02/56] =?UTF-8?q?feat(i18n,config):=20=E4=B8=AD=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E6=A1=88=E8=B7=9F=E9=9A=8F=20VS=20Code=EF=BC=8C?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=B3=A8=E5=86=8C=E8=A1=A8=E6=88=90=E4=B8=BA?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E4=BA=8B=E5=AE=9E=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n - 英文原文即 key,data/i18n/*.json 是运行时文案的单一来源 - tools/generate-l10n.mjs 生成 l10n/bundle.l10n*.json;tools/l10n-check.mjs 阻止新增硬编码:任一 t("…") 缺翻译即 CI 失败,且校验 package.nls 双语 key 集合一致 - package.json 的用户可见字符串(displayName/description/16 个命令标题/category/ untrustedWorkspaces.description)改为 %key%,文案落在 package.nls.json 与 package.nls.zh-cn.json - mcpp.ui.language 支持 auto/en/zh-cn 手动覆盖(逃生门;命令面板与原生设置页恒随 VS Code) config - data/config-registry.json 是全部设置的唯一事实源:64 项、10 组、29 项公开 - src/config/{registry,validate,access,migrate,presets}.ts:类型化读写、枚举/范围校验、 生效值来源(default/user/workspace/folder)、旧键别名与一次性迁移、四套预设 - tools/check-config.mjs 把 registry 与 package.json 做语义一致性门禁; tools/generate-settings-docs.mjs 生成 docs/settings.md - 激活时按设置同步语言偏好,并在设置变更时刷新 其他 - tsconfig 打开 resolveJsonModule;package.json test 脚本先跑两个一致性门禁 - .vscodeignore 排除 tools/ 与根 data/(JSON 已编译进 dist/data/) 验证:npm test 229 通过(含 check-config 与 l10n-check 门禁) --- .../docs/2026-10-02-implementation-plan.md | 193 ++++ .../2026-10-02-plugin-optimisation-plan.md | 6 +- .vscodeignore | 3 + data/config-registry.json | 1002 +++++++++++++++++ data/i18n/zh-cn.json | 1 + docs/settings.md | 825 ++++++++++++++ l10n/bundle.l10n.json | 1 + l10n/bundle.l10n.zh-cn.json | 1 + package.json | 682 ++++++++++- package.nls.json | 155 +++ package.nls.zh-cn.json | 155 +++ src/config/access.ts | 133 +++ src/config/migrate.ts | 66 ++ src/config/presets.ts | 110 ++ src/config/registry.ts | 184 +++ src/config/validate.ts | 93 ++ src/extension.ts | 13 + src/i18n/t.ts | 62 + src/i18n/translate.ts | 55 + src/util/format.ts | 153 +++ src/util/text.ts | 79 ++ test/artifacts.test.ts | 19 +- test/config/presets.test.ts | 51 + test/config/registry.test.ts | 85 ++ test/config/validate.test.ts | 82 ++ test/i18n/translate.test.ts | 40 + test/util/format.test.ts | 109 ++ test/util/text.test.ts | 56 + tools/check-config.mjs | 162 +++ tools/generate-l10n.mjs | 59 + tools/generate-settings-docs.mjs | 92 ++ tools/l10n-check.mjs | 102 ++ tsconfig.json | 1 + 33 files changed, 4787 insertions(+), 43 deletions(-) create mode 100644 .agents/docs/2026-10-02-implementation-plan.md create mode 100644 data/config-registry.json create mode 100644 data/i18n/zh-cn.json create mode 100644 docs/settings.md create mode 100644 l10n/bundle.l10n.json create mode 100644 l10n/bundle.l10n.zh-cn.json create mode 100644 package.nls.json create mode 100644 package.nls.zh-cn.json create mode 100644 src/config/access.ts create mode 100644 src/config/migrate.ts create mode 100644 src/config/presets.ts create mode 100644 src/config/registry.ts create mode 100644 src/config/validate.ts create mode 100644 src/i18n/t.ts create mode 100644 src/i18n/translate.ts create mode 100644 src/util/format.ts create mode 100644 src/util/text.ts create mode 100644 test/config/presets.test.ts create mode 100644 test/config/registry.test.ts create mode 100644 test/config/validate.test.ts create mode 100644 test/i18n/translate.test.ts create mode 100644 test/util/format.test.ts create mode 100644 test/util/text.test.ts create mode 100644 tools/check-config.mjs create mode 100644 tools/generate-l10n.mjs create mode 100644 tools/generate-settings-docs.mjs create mode 100644 tools/l10n-check.mjs diff --git a/.agents/docs/2026-10-02-implementation-plan.md b/.agents/docs/2026-10-02-implementation-plan.md new file mode 100644 index 0000000..30a71d6 --- /dev/null +++ b/.agents/docs/2026-10-02-implementation-plan.md @@ -0,0 +1,193 @@ +# 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 写清"什么变了 / 什么没变 / 需要手动做什么" diff --git a/.agents/docs/2026-10-02-plugin-optimisation-plan.md b/.agents/docs/2026-10-02-plugin-optimisation-plan.md index 74898ca..db53720 100644 --- a/.agents/docs/2026-10-02-plugin-optimisation-plan.md +++ b/.agents/docs/2026-10-02-plugin-optimisation-plan.md @@ -359,7 +359,7 @@ CI 漂移 job:用 `mcpp@main` 重新生成 + `git diff --exit-code`。 | 命令 | 数据 | 机读 | |---|---|---| -| `mcpp cache list --format json` | kind `mcpp.cache`:`data.root` + `data.entries[]`,每条 `{accessed(Unix 秒), bytes, complete, dir, files, key, kind, label}`。实测 **657 条 / 7.22 GiB / pkg 576 · std 81 / 2 条 incomplete / 83 个 label / 最旧 2026-09-23** | ✅ 信封 | +| `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` | 校验条目清单与磁盘 | ❌(看退出码与文本) | @@ -481,7 +481,7 @@ mcpp ← Activity Bar 容器 $(tools) │ ├── 项目产物 … 1.4 GiB(估算)· 3 个 fingerprint │ │ ├── 过期产物 12 项 · 820 MiB [$(trash) 清理过期产物…] │ │ └── [$(trash) 清理项目产物…] -│ ├── 全局构建缓存 … 7.22 GiB · 657 条目 +│ ├── 全局构建缓存 … 7.20 GiB · 657 条目 │ │ └── [$(refresh) 刷新] [$(graph) 统计面板] [$(history) 收敛到预算…] [$(check) 校验] │ └── pre-v1 遗留缓存 … 167.5 MiB [$(trash) 清理遗留缓存] └── C++ Modules (mcpp.languageServer) ← TreeView,由 mcppls 提供内容(§3.9) @@ -1084,7 +1084,7 @@ mcppls 降级能力与状态视图、i18n 机制本身。 | 交给 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.22 GiB / pkg 576 · std 81 / 2 incomplete | +| 缓存数据可机读 | `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` | diff --git a/.vscodeignore b/.vscodeignore index 19d5237..4a24466 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,9 +1,12 @@ .vscode/** .github/** .gitignore +.agents/** docs/** src/** test/** +tools/** +data/** tsconfig.json package-lock.json *.tsbuildinfo diff --git a/data/config-registry.json b/data/config-registry.json new file mode 100644 index 0000000..9ee0e9d --- /dev/null +++ b/data/config-registry.json @@ -0,0 +1,1002 @@ +{ + "version": 1, + "groups": [ + { + "id": "project", + "order": 10, + "title": "Project" + }, + { + "id": "tasks", + "order": 20, + "title": "Tasks" + }, + { + "id": "languageService", + "order": 30, + "title": "C++ Modules language service" + }, + { + "id": "toml", + "order": 40, + "title": "mcpp.toml editing" + }, + { + "id": "buildScript", + "order": 50, + "title": "build.mcpp editing" + }, + { + "id": "cache", + "order": 60, + "title": "Cache" + }, + { + "id": "views", + "order": 70, + "title": "Views" + }, + { + "id": "ui", + "order": 80, + "title": "Interface" + }, + { + "id": "diagnostics", + "order": 90, + "title": "Diagnostics and logging" + }, + { + "id": "advanced", + "order": 100, + "title": "Advanced" + } + ], + "settings": [ + { + "key": "mcpp.path", + "type": "string", + "default": "", + "scope": "resource", + "group": "project", + "order": 1, + "tier": "public", + "applies": "immediate", + "since": "0.1.0", + "title": "Set the mcpp executable path", + "description": "Path to the mcpp CLI used for every mcpp command. Leave it empty to look up mcpp on the PATH of the VS Code process." + }, + { + "key": "mcpp.project.discoveryBoundary", + "type": "string", + "default": "workspaceFolder", + "enum": [ + "workspaceFolder", + "filesystem" + ], + "scope": "resource", + "group": "project", + "order": 2, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Limit project discovery scope", + "description": "How far the extension walks to find mcpp.toml. \"filesystem\" also searches outside the workspace folder; it costs more I/O and can reach unrelated trees." + }, + { + "key": "mcpp.task.clearTerminal", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Clear the task terminal", + "description": "Clear the terminal before an mcpp task starts, so the last build's output is not mistaken for this one's.", + "order": 1 + }, + { + "key": "mcpp.task.buildArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 2, + "tier": "public", + "applies": "next-build", + "since": "0.5.0", + "title": "Add arguments to mcpp build", + "description": "Extra arguments appended to the mcpp build task. Applied on the next build." + }, + { + "key": "mcpp.task.runArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 3, + "tier": "public", + "applies": "next-build", + "since": "0.5.0", + "title": "Add arguments to mcpp run", + "description": "Extra arguments appended to the mcpp run task. Applied on the next run." + }, + { + "key": "mcpp.task.testArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 4, + "tier": "public", + "applies": "next-build", + "since": "0.5.0", + "title": "Add arguments to mcpp test", + "description": "Extra arguments appended to the mcpp test task. Applied on the next test run." + }, + { + "key": "mcpp.task.cleanArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 5, + "tier": "public", + "applies": "next-clean", + "since": "0.5.0", + "title": "Add arguments to mcpp clean", + "description": "Extra arguments appended to the mcpp clean task. Applied on the next clean." + }, + { + "key": "mcpp.task.confirmClean", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 6, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm before cleaning", + "description": "Ask for confirmation before a clean task deletes the target directory of the project." + }, + { + "key": "mcpp.task.revealTerminal", + "type": "string", + "default": "always", + "enum": [ + "always", + "onFailure", + "never" + ], + "scope": "resource", + "group": "tasks", + "order": 7, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Reveal the task terminal", + "description": "When an mcpp task brings the terminal panel to the front: always, only when the task fails, or never." + }, + { + "key": "mcpp.task.focusTerminal", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "tasks", + "order": 8, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Focus the task terminal", + "description": "Move keyboard focus to the terminal while an mcpp task runs. Off by default so editor focus is not taken away." + }, + { + "key": "mcpp.task.problemMatcher", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 9, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Parse task problems", + "description": "Parse compiler output of the mcpp build and test tasks into the Problems panel." + }, + { + "key": "mcpp.task.editorTitleButtons", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Show editor title buttons", + "description": "Show the Run and Test buttons in the editor title bar while the file belongs to an mcpp project." + }, + { + "key": "mcpp.languageService.menuItems", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show language-service entries in the menu", + "description": "Keep the C++ Modules actions in the mcpp quick menu. Turn off to leave the menu to mcpp's own commands.", + "order": 1 + }, + { + "key": "mcpp.languageService.confirmResetCache", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm resetting the workspace cache", + "description": "Ask before running the C++ Modules reset, which discards this workspace's model cache and prepares it again. Turning this off removes the only warning before that happens.", + "order": 2 + }, + { + "key": "mcpp.languageService.refreshAfterBuild", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "reload", + "restart", + "off" + ], + "scope": "resource", + "group": "languageService", + "order": 3, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Refresh language service after build", + "description": "How mcpp-vscode tells the C++ Modules extension about a finished build: auto, a lightweight reload, a full restart, or nothing." + }, + { + "key": "mcpp.languageService.notifyOnDegraded", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "order": 4, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Notify when the language service degrades", + "description": "Show a notification when the C++ Modules language service reports a degraded state." + }, + { + "key": "mcpp.languageService.readState", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "order": 5, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Read language service state", + "description": "Read status from the C++ Modules extension through the API it exposes for tests. Turning this off only hides status; it never changes mcppls itself." + }, + { + "key": "mcpp.languageService.stateRefreshSeconds", + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "group": "languageService", + "order": 6, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the state refresh interval", + "description": "Re-read the language service state every this many seconds. 0 disables the timer." + }, + { + "key": "mcpp.toml.completion", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 1, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "aliases": [ + "mcpp.tomlCompletion" + ], + "title": "Complete mcpp.toml keys", + "description": "Offer section, key and value completion while editing mcpp.toml. The old mcpp.tomlCompletion key still works as an alias." + }, + { + "key": "mcpp.toml.hover", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 2, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Show mcpp.toml hovers", + "description": "Show documentation for sections and keys when hovering in mcpp.toml." + }, + { + "key": "mcpp.toml.navigation", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 3, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Navigate inside mcpp.toml", + "description": "Enable go-to-definition for workspace, path and feature references in mcpp.toml." + }, + { + "key": "mcpp.toml.diagnostics.enabled", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 4, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Enable mcpp.toml diagnostics", + "description": "Turn all mcpp.toml validation on or off. Every rule keeps its own severity setting." + }, + { + "key": "mcpp.toml.diagnostics.syntax", + "type": "string", + "default": "error", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 5, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set syntax diagnostic severity", + "description": "Severity used for mcpp.toml syntax errors." + }, + { + "key": "mcpp.toml.diagnostics.unknownSection", + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 6, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set unknown section severity", + "description": "Severity used for sections that mcpp does not recognise." + }, + { + "key": "mcpp.toml.diagnostics.unknownKey", + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 7, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set unknown key severity", + "description": "Severity used for keys that mcpp does not recognise." + }, + { + "key": "mcpp.toml.diagnostics.planeSeparation", + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 8, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set plane separation severity", + "description": "Severity used when a key is written in the wrong mcpp.toml plane, such as a build key in the library plane." + }, + { + "key": "mcpp.toml.diagnostics.legacyKeys", + "type": "string", + "default": "info", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 9, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set legacy key severity", + "description": "Severity used for pre-0.5 mcpp.toml keys that still work but should be migrated." + }, + { + "key": "mcpp.toml.indexCompletion", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "toml", + "order": 10, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Complete dependency versions", + "description": "Query the package index to complete dependency versions. Off by default because it needs network access." + }, + { + "key": "mcpp.toml.indexCompletionTimeoutSeconds", + "type": "number", + "default": 20, + "minimum": 1, + "maximum": 600, + "scope": "resource", + "group": "toml", + "order": 11, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the index query timeout", + "description": "How long one package index query may run before it is abandoned and completion falls back to local data, in seconds." + }, + { + "key": "mcpp.buildScript.intelligence", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 1, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Enable build.mcpp intelligence", + "description": "Turn completion and hover support for build.mcpp on or off." + }, + { + "key": "mcpp.buildScript.diagnostics", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 2, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Enable build.mcpp diagnostics", + "description": "Validate build.mcpp against the build API mcpp actually ships." + }, + { + "key": "mcpp.buildScript.diagnostics.severity", + "type": "string", + "default": "warning", + "enum": [ + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "buildScript", + "order": 3, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set build.mcpp diagnostic severity", + "description": "Severity used for build.mcpp diagnostics." + }, + { + "key": "mcpp.buildScript.imports.knownModules", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 4, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Check imported modules", + "description": "Flag imports in build.mcpp that are neither standard modules nor known mcpp modules." + }, + { + "key": "mcpp.buildScript.snippets", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 5, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Offer build.mcpp snippets", + "description": "Offer snippet completions for the mcpp build script API." + }, + { + "key": "mcpp.cache.statusBar", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "cache", + "order": 1, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Show cache size in status bar", + "description": "Show the size of the mcpp global cache in the status bar. Off by default to keep the status bar quiet." + }, + { + "key": "mcpp.cache.warnAboveGiB", + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "group": "cache", + "order": 2, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Warn above a cache size", + "description": "Warn when the mcpp global cache grows past this many GiB. 0 disables the warning." + }, + { + "key": "mcpp.cache.staleDays", + "type": "number", + "default": 3, + "minimum": 0, + "maximum": 365, + "scope": "resource", + "group": "cache", + "order": 3, + "tier": "public", + "applies": "next-clean", + "since": "0.5.0", + "title": "Mark stale cache entries", + "description": "Treat cache entries that were not accessed for this many days as stale. 0 disables the age rule." + }, + { + "key": "mcpp.cache.autoRefreshSeconds", + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "group": "cache", + "order": 4, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Refresh the cache view automatically", + "description": "Re-read the cache statistics every this many seconds. 0 disables automatic refreshing." + }, + { + "key": "mcpp.cache.estimateProjectBytes", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "cache", + "order": 5, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Estimate project artifact size", + "description": "Measure the target directory of the current project so the cache view can show its size." + }, + { + "key": "mcpp.cache.showLegacy", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "cache", + "order": 6, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show legacy cache entries", + "description": "Include cache entries written by older mcpp versions in the cache view and in cleaning." + }, + { + "key": "mcpp.cache.gc.defaultBudgetGiB", + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "group": "cache", + "order": 7, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the default cleanup budget", + "description": "Default size budget for cache cleanup, in GiB. 0 asks for a budget on every run." + }, + { + "key": "mcpp.cache.gc.confirmAboveGiB", + "type": "number", + "default": 1, + "minimum": 0, + "scope": "resource", + "group": "cache", + "order": 8, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm large cache cleanup", + "description": "Require an extra confirmation when a cleanup would free more than this many GiB. 0 confirms every cleanup." + }, + { + "key": "mcpp.cache.pruneAgeDays", + "type": "number", + "default": 30, + "minimum": 1, + "scope": "resource", + "group": "cache", + "order": 9, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the pruning age", + "description": "Default age in days used when pruning cache entries older than a threshold." + }, + { + "key": "mcpp.views.project.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "views", + "order": 1, + "tier": "public", + "applies": "view-reload", + "since": "0.5.0", + "title": "Show the project view", + "description": "Show the mcpp project view in the activity bar. Takes effect after the view container reloads." + }, + { + "key": "mcpp.views.cache.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "views", + "order": 2, + "tier": "public", + "applies": "view-reload", + "since": "0.5.0", + "title": "Show the cache view", + "description": "Show the mcpp cache view in the activity bar. Takes effect after the view container reloads." + }, + { + "key": "mcpp.views.languageServer.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "views", + "order": 3, + "tier": "public", + "applies": "view-reload", + "since": "0.5.0", + "title": "Show the language service view", + "description": "Show the C++ Modules status view that mcpp-vscode fills in. Takes effect after the view container reloads." + }, + { + "key": "mcpp.views.cache.topN", + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 50, + "scope": "window", + "group": "views", + "order": 4, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set cache list length", + "description": "How many entries the cache view lists per group." + }, + { + "key": "mcpp.views.cache.ageBuckets", + "type": "array", + "default": [ + "1d", + "7d", + "30d" + ], + "scope": "window", + "group": "views", + "order": 5, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set cache age buckets", + "description": "Age buckets used to group cache entries in the cache view, written as \"1d\", \"7d\" and so on." + }, + { + "key": "mcpp.ui.confirmDestructiveOnly", + "type": "boolean", + "default": true, + "scope": "window", + "group": "ui", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm only destructive actions", + "description": "Use a modal dialogue only for actions that cannot be undone. Turning this off asks for confirmation more often, never less.", + "order": 1 + }, + { + "key": "mcpp.ui.language", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "en", + "zh-cn" + ], + "scope": "window", + "group": "ui", + "order": 2, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the extension language", + "description": "Language for extension messages and panels. \"auto\" follows VS Code; command titles and settings labels always follow the VS Code display language." + }, + { + "key": "mcpp.ui.statusBar.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "ui", + "order": 3, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show the mcpp status bar item", + "description": "Show the main mcpp status bar item." + }, + { + "key": "mcpp.ui.statusBar.showLanguageServer", + "type": "boolean", + "default": false, + "scope": "window", + "group": "ui", + "order": 4, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show language service status", + "description": "Show the C++ Modules language service state in the mcpp status bar item. Off by default because mcppls already has its own status item." + }, + { + "key": "mcpp.ui.notifications.success", + "type": "string", + "default": "statusBar", + "enum": [ + "silent", + "statusBar", + "toast" + ], + "scope": "window", + "group": "ui", + "order": 5, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Report successful operations", + "description": "How successful mcpp operations are reported: in the status bar only, as a toast, or not at all." + }, + { + "key": "mcpp.ui.notifications.dedupeMinutes", + "type": "number", + "default": 5, + "minimum": 0, + "maximum": 1440, + "scope": "window", + "group": "ui", + "order": 6, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Deduplicate notifications", + "description": "Suppress a repeated notification for this many minutes. 0 shows every notification." + }, + { + "key": "mcpp.ui.numberFormat", + "type": "string", + "default": "binary", + "enum": [ + "binary", + "decimal" + ], + "scope": "window", + "group": "ui", + "order": 7, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Choose number formatting", + "description": "Format byte sizes with binary units (KiB, MiB) or decimal units (KB, MB)." + }, + { + "key": "mcpp.log.level", + "type": "string", + "default": "info", + "enum": [ + "error", + "warn", + "info", + "debug" + ], + "scope": "window", + "group": "diagnostics", + "order": 1, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the log level", + "description": "Amount of detail written to the mcpp output channel. Debug output is verbose." + }, + { + "key": "mcpp.diagnostics.selfCheckOnStartup", + "type": "boolean", + "default": false, + "scope": "window", + "group": "diagnostics", + "order": 2, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Run a self check at startup", + "description": "Run the environment self check on activation and write problems to the output channel. Off by default because it starts mcpp." + }, + { + "key": "mcpp.runtime.timeoutSeconds", + "type": "number", + "default": 30, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "group": "advanced", + "order": 1, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the command timeout", + "description": "How long one mcpp CLI command may run before it is terminated, in seconds. 0 disables the timeout." + }, + { + "key": "mcpp.runtime.maxOutputMiB", + "type": "number", + "default": 16, + "minimum": 1, + "maximum": 1024, + "scope": "resource", + "group": "advanced", + "order": 2, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Cap captured command output", + "description": "Maximum output captured for one mcpp command, in MiB. When output is truncated the tail is kept." + }, + { + "key": "mcpp.runtime.concurrency", + "type": "string", + "default": "perProject", + "enum": [ + "perProject", + "global" + ], + "scope": "resource", + "group": "advanced", + "order": 3, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Limit concurrent mcpp commands", + "description": "Whether the one-command-at-a-time rule applies to each project separately or to the whole window." + }, + { + "key": "mcpp.clangd.path", + "type": "string", + "default": "", + "scope": "resource", + "group": "advanced", + "order": 4, + "tier": "advanced", + "applies": "immediate", + "since": "0.4.0", + "deprecated": true, + "deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "title": "Deprecated clangd path", + "description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting." + }, + { + "key": "mcpp.modulesSupport", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "on", + "off" + ], + "scope": "resource", + "group": "advanced", + "order": 5, + "tier": "advanced", + "applies": "immediate", + "since": "0.4.0", + "deprecated": true, + "deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "title": "Deprecated module support mode", + "description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting." + }, + { + "key": "mcpp.configureCppTools", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "advanced", + "order": 6, + "tier": "advanced", + "applies": "immediate", + "since": "0.4.0", + "deprecated": true, + "deprecationMessage": "Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "title": "Deprecated C++ tool configuration", + "description": "Deprecated: Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting." + }, + { + "key": "mcpp.tomlCompletion", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "advanced", + "order": 7, + "tier": "advanced", + "applies": "immediate", + "since": "0.2.6", + "deprecated": true, + "deprecationMessage": "mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead.", + "title": "Deprecated mcpp.toml completion", + "description": "Deprecated: mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead." + } + ] +} diff --git a/data/i18n/zh-cn.json b/data/i18n/zh-cn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/data/i18n/zh-cn.json @@ -0,0 +1 @@ +{} diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..7e1f396 --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,825 @@ +# Settings + + + +64 settings, in 10 groups. All of them are read by the +**mcpp** extension only. `mcppls.*` belongs to the C++ Modules extension and is never +written by this one; the configuration panel says so on every screen. + +| | | +|---|---| +| Panel | **mcpp: Open Settings Panel** (`mcpp.openSettings`) | +| Native UI | Settings → Extensions → mcpp, or `@ext:mcpp-community.mcpp-vscode` | +| Defaults | Every default is deliberately quiet: nothing here changes behaviour until you ask | + +## Project + +### `mcpp.path` + +Path to the mcpp CLI used for every mcpp command. Leave it empty to look up mcpp on the PATH of the VS Code process. + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.1.0 | + +### `mcpp.project.discoveryBoundary` — advanced + +How far the extension walks to find mcpp.toml. "filesystem" also searches outside the workspace folder; it costs more I/O and can reach unrelated trees. + +| | | +|---|---| +| Type | `string`: `workspaceFolder` \| `filesystem` | +| Default | `"workspaceFolder"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Tasks + +### `mcpp.task.clearTerminal` — advanced + +Clear the terminal before an mcpp task starts, so the last build's output is not mistaken for this one's. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.buildArgs` + +Extra arguments appended to the mcpp build task. Applied on the next build. + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next build/run/test | +| Since | 0.5.0 | + +### `mcpp.task.runArgs` + +Extra arguments appended to the mcpp run task. Applied on the next run. + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next build/run/test | +| Since | 0.5.0 | + +### `mcpp.task.testArgs` + +Extra arguments appended to the mcpp test task. Applied on the next test run. + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next build/run/test | +| Since | 0.5.0 | + +### `mcpp.task.cleanArgs` + +Extra arguments appended to the mcpp clean task. Applied on the next clean. + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next clean | +| Since | 0.5.0 | + +### `mcpp.task.confirmClean` + +Ask for confirmation before a clean task deletes the target directory of the project. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.revealTerminal` — advanced + +When an mcpp task brings the terminal panel to the front: always, only when the task fails, or never. + +| | | +|---|---| +| Type | `string`: `always` \| `onFailure` \| `never` | +| Default | `"always"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.focusTerminal` + +Move keyboard focus to the terminal while an mcpp task runs. Off by default so editor focus is not taken away. + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.problemMatcher` + +Parse compiler output of the mcpp build and test tasks into the Problems panel. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.editorTitleButtons` + +Show the Run and Test buttons in the editor title bar while the file belongs to an mcpp project. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## C++ Modules language service + +### `mcpp.languageService.menuItems` — advanced + +Keep the C++ Modules actions in the mcpp quick menu. Turn off to leave the menu to mcpp's own commands. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.confirmResetCache` — advanced + +Ask before running the C++ Modules reset, which discards this workspace's model cache and prepares it again. Turning this off removes the only warning before that happens. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.refreshAfterBuild` + +How mcpp-vscode tells the C++ Modules extension about a finished build: auto, a lightweight reload, a full restart, or nothing. + +| | | +|---|---| +| Type | `string`: `auto` \| `reload` \| `restart` \| `off` | +| Default | `"auto"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.notifyOnDegraded` — advanced + +Show a notification when the C++ Modules language service reports a degraded state. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.readState` — advanced + +Read status from the C++ Modules extension through the API it exposes for tests. Turning this off only hides status; it never changes mcppls itself. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.stateRefreshSeconds` — advanced + +Re-read the language service state every this many seconds. 0 disables the timer. + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## mcpp.toml editing + +### `mcpp.toml.completion` + +Offer section, key and value completion while editing mcpp.toml. The old mcpp.tomlCompletion key still works as an alias. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Old name | `mcpp.tomlCompletion` (still read) | + +### `mcpp.toml.hover` + +Show documentation for sections and keys when hovering in mcpp.toml. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.navigation` + +Enable go-to-definition for workspace, path and feature references in mcpp.toml. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.enabled` + +Turn all mcpp.toml validation on or off. Every rule keeps its own severity setting. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.syntax` — advanced + +Severity used for mcpp.toml syntax errors. + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"error"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.unknownSection` + +Severity used for sections that mcpp does not recognise. + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.unknownKey` + +Severity used for keys that mcpp does not recognise. + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.planeSeparation` — advanced + +Severity used when a key is written in the wrong mcpp.toml plane, such as a build key in the library plane. + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.legacyKeys` — advanced + +Severity used for pre-0.5 mcpp.toml keys that still work but should be migrated. + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"info"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.indexCompletion` — advanced + +Query the package index to complete dependency versions. Off by default because it needs network access. + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.indexCompletionTimeoutSeconds` — advanced + +How long one package index query may run before it is abandoned and completion falls back to local data, in seconds. + +| | | +|---|---| +| Type | `number` | +| Default | `20` | +| Range | 1 … 600 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## build.mcpp editing + +### `mcpp.buildScript.intelligence` + +Turn completion and hover support for build.mcpp on or off. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.diagnostics` + +Validate build.mcpp against the build API mcpp actually ships. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.diagnostics.severity` — advanced + +Severity used for build.mcpp diagnostics. + +| | | +|---|---| +| Type | `string`: `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.imports.knownModules` — advanced + +Flag imports in build.mcpp that are neither standard modules nor known mcpp modules. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.snippets` — advanced + +Offer snippet completions for the mcpp build script API. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Cache + +### `mcpp.cache.statusBar` + +Show the size of the mcpp global cache in the status bar. Off by default to keep the status bar quiet. + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.warnAboveGiB` + +Warn when the mcpp global cache grows past this many GiB. 0 disables the warning. + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.staleDays` + +Treat cache entries that were not accessed for this many days as stale. 0 disables the age rule. + +| | | +|---|---| +| Type | `number` | +| Default | `3` | +| Range | 0 … 365 | +| Scope | per folder | +| Applies | Takes effect on the next clean | +| Since | 0.5.0 | + +### `mcpp.cache.autoRefreshSeconds` — advanced + +Re-read the cache statistics every this many seconds. 0 disables automatic refreshing. + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … 3600 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.estimateProjectBytes` — advanced + +Measure the target directory of the current project so the cache view can show its size. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.showLegacy` — advanced + +Include cache entries written by older mcpp versions in the cache view and in cleaning. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.gc.defaultBudgetGiB` + +Default size budget for cache cleanup, in GiB. 0 asks for a budget on every run. + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.gc.confirmAboveGiB` — advanced + +Require an extra confirmation when a cleanup would free more than this many GiB. 0 confirms every cleanup. + +| | | +|---|---| +| Type | `number` | +| Default | `1` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.pruneAgeDays` — advanced + +Default age in days used when pruning cache entries older than a threshold. + +| | | +|---|---| +| Type | `number` | +| Default | `30` | +| Range | 1 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Views + +### `mcpp.views.project.show` + +Show the mcpp project view in the activity bar. Takes effect after the view container reloads. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.5.0 | + +### `mcpp.views.cache.show` + +Show the mcpp cache view in the activity bar. Takes effect after the view container reloads. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.5.0 | + +### `mcpp.views.languageServer.show` + +Show the C++ Modules status view that mcpp-vscode fills in. Takes effect after the view container reloads. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.5.0 | + +### `mcpp.views.cache.topN` — advanced + +How many entries the cache view lists per group. + +| | | +|---|---| +| Type | `number` | +| Default | `5` | +| Range | 1 … 50 | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.views.cache.ageBuckets` — advanced + +Age buckets used to group cache entries in the cache view, written as "1d", "7d" and so on. + +| | | +|---|---| +| Type | `array` | +| Default | `["1d","7d","30d"]` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Interface + +### `mcpp.ui.confirmDestructiveOnly` — advanced + +Use a modal dialogue only for actions that cannot be undone. Turning this off asks for confirmation more often, never less. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.language` + +Language for extension messages and panels. "auto" follows VS Code; command titles and settings labels always follow the VS Code display language. + +| | | +|---|---| +| Type | `string`: `auto` \| `en` \| `zh-cn` | +| Default | `"auto"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.statusBar.show` — advanced + +Show the main mcpp status bar item. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.statusBar.showLanguageServer` — advanced + +Show the C++ Modules language service state in the mcpp status bar item. Off by default because mcppls already has its own status item. + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.notifications.success` + +How successful mcpp operations are reported: in the status bar only, as a toast, or not at all. + +| | | +|---|---| +| Type | `string`: `silent` \| `statusBar` \| `toast` | +| Default | `"statusBar"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.notifications.dedupeMinutes` — advanced + +Suppress a repeated notification for this many minutes. 0 shows every notification. + +| | | +|---|---| +| Type | `number` | +| Default | `5` | +| Range | 0 … 1440 | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.numberFormat` + +Format byte sizes with binary units (KiB, MiB) or decimal units (KB, MB). + +| | | +|---|---| +| Type | `string`: `binary` \| `decimal` | +| Default | `"binary"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Diagnostics and logging + +### `mcpp.log.level` + +Amount of detail written to the mcpp output channel. Debug output is verbose. + +| | | +|---|---| +| Type | `string`: `error` \| `warn` \| `info` \| `debug` | +| Default | `"info"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.diagnostics.selfCheckOnStartup` — advanced + +Run the environment self check on activation and write problems to the output channel. Off by default because it starts mcpp. + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Advanced + +### `mcpp.runtime.timeoutSeconds` — advanced + +How long one mcpp CLI command may run before it is terminated, in seconds. 0 disables the timeout. + +| | | +|---|---| +| Type | `number` | +| Default | `30` | +| Range | 0 … 3600 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.runtime.maxOutputMiB` — advanced + +Maximum output captured for one mcpp command, in MiB. When output is truncated the tail is kept. + +| | | +|---|---| +| Type | `number` | +| Default | `16` | +| Range | 1 … 1024 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.runtime.concurrency` — advanced + +Whether the one-command-at-a-time rule applies to each project separately or to the whole window. + +| | | +|---|---| +| Type | `string`: `perProject` \| `global` | +| Default | `"perProject"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.clangd.path` — **deprecated**, advanced + +Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.4.0 | +| Deprecated | The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. | + +### `mcpp.modulesSupport` — **deprecated**, advanced + +Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. + +| | | +|---|---| +| Type | `string`: `auto` \| `on` \| `off` | +| Default | `"auto"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.4.0 | +| Deprecated | The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. | + +### `mcpp.configureCppTools` — **deprecated**, advanced + +Deprecated: Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.4.0 | +| Deprecated | Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. | + +### `mcpp.tomlCompletion` — **deprecated**, advanced + +Deprecated: mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.2.6 | +| Deprecated | mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead. | + +## Language + +`mcpp.ui.language` overrides the language of **runtime messages and this extension's +panels**. The command palette and the Settings UI always follow the editor's own +language: VS Code resolves `package.nls.*` once at startup, so a manual override +cannot reach them. `auto` (the default) follows the editor everywhere. diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/l10n/bundle.l10n.json @@ -0,0 +1 @@ +{} diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/l10n/bundle.l10n.zh-cn.json @@ -0,0 +1 @@ +{} diff --git a/package.json b/package.json index 01ca05c..a5b1b8c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mcpp-vscode", - "displayName": "mcpp", - "description": "mcpp 工程构建、工具链与 C++ Modules 语言服务集成", + "displayName": "%displayName%", + "description": "%description%", "version": "0.4.0", "publisher": "mcpp-community", "license": "Apache-2.0", @@ -49,7 +49,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。" + "description": "%untrustedWorkspaces.description%" } }, "main": "./dist/src/extension.js", @@ -71,111 +71,722 @@ "commands": [ { "command": "mcpp.showMenu", - "title": "mcpp: 打开快捷菜单" + "title": "%command.mcpp.showMenu.title%", + "category": "%category%" }, { "command": "mcpp.newProject", - "title": "mcpp: 新建工程" + "title": "%command.mcpp.newProject.title%", + "category": "%category%" }, { "command": "mcpp.build", - "title": "mcpp: 构建" + "title": "%command.mcpp.build.title%", + "category": "%category%" }, { "command": "mcpp.run", - "title": "mcpp: 运行", - "icon": "$(play)" + "title": "%command.mcpp.run.title%", + "icon": "$(play)", + "category": "%category%" }, { "command": "mcpp.test", - "title": "mcpp: 测试", - "icon": "$(beaker)" + "title": "%command.mcpp.test.title%", + "icon": "$(beaker)", + "category": "%category%" }, { "command": "mcpp.clean", - "title": "mcpp: 清理 target" + "title": "%command.mcpp.clean.title%", + "category": "%category%" }, { "command": "mcpp.showToolchains", - "title": "mcpp: 查看工具链" + "title": "%command.mcpp.showToolchains.title%", + "category": "%category%" }, { "command": "mcpp.installToolchain", - "title": "mcpp: 安装工具链" + "title": "%command.mcpp.installToolchain.title%", + "category": "%category%" }, { "command": "mcpp.selectDefaultToolchain", - "title": "mcpp: 选择全局默认工具链" + "title": "%command.mcpp.selectDefaultToolchain.title%", + "category": "%category%" }, { "command": "mcpp.configureLanguageServer", - "title": "mcpp: 选择 C++ 模块分析上下文" + "title": "%command.mcpp.configureLanguageServer.title%", + "category": "%category%" }, { "command": "mcpp.configureClangd", - "title": "mcpp: [已弃用] 配置 C++ 模块语言服务" + "title": "%command.mcpp.configureClangd.title%", + "category": "%category%" }, { "command": "mcpp.refreshCompilationDatabase", - "title": "mcpp: 刷新模块构建描述" + "title": "%command.mcpp.refreshCompilationDatabase.title%", + "category": "%category%" }, { "command": "mcpp.checkModuleSupport", - "title": "mcpp: 重启 C++ Modules 语言服务" + "title": "%command.mcpp.checkModuleSupport.title%", + "category": "%category%" }, { "command": "mcpp.autoConfigureModules", - "title": "mcpp: 一键构建并刷新模块语言服务" + "title": "%command.mcpp.autoConfigureModules.title%", + "category": "%category%" }, { "command": "mcpp.showModuleGraph", - "title": "mcpp: 查看模块图" + "title": "%command.mcpp.showModuleGraph.title%", + "category": "%category%" }, { "command": "mcpp.showLanguageServerLogs", - "title": "mcpp: 打开 C++ Modules 日志" + "title": "%command.mcpp.showLanguageServerLogs.title%", + "category": "%category%" } ], "configuration": { - "title": "mcpp 设置", + "title": "%mcpp.configuration.title%", "properties": { "mcpp.path": { "type": "string", "default": "", "scope": "resource", - "description": "供扩展执行全部 mcpp CLI 命令使用;留空时从 VS Code 进程的 PATH 查找 mcpp。" + "order": 10010, + "description": "%mcpp.path.title%", + "markdownDescription": "%mcpp.path.description%" + }, + "mcpp.project.discoveryBoundary": { + "type": "string", + "default": "workspaceFolder", + "enum": [ + "workspaceFolder", + "filesystem" + ], + "scope": "resource", + "order": 10020, + "description": "%mcpp.project.discoveryBoundary.title%", + "markdownDescription": "%mcpp.project.discoveryBoundary.description%" + }, + "mcpp.task.clearTerminal": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20001, + "description": "%mcpp.task.clearTerminal.title%", + "markdownDescription": "%mcpp.task.clearTerminal.description%" + }, + "mcpp.task.buildArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20010, + "description": "%mcpp.task.buildArgs.title%", + "markdownDescription": "%mcpp.task.buildArgs.description%" + }, + "mcpp.task.runArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20020, + "description": "%mcpp.task.runArgs.title%", + "markdownDescription": "%mcpp.task.runArgs.description%" + }, + "mcpp.task.testArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20030, + "description": "%mcpp.task.testArgs.title%", + "markdownDescription": "%mcpp.task.testArgs.description%" + }, + "mcpp.task.cleanArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20040, + "description": "%mcpp.task.cleanArgs.title%", + "markdownDescription": "%mcpp.task.cleanArgs.description%" + }, + "mcpp.task.confirmClean": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20050, + "description": "%mcpp.task.confirmClean.title%", + "markdownDescription": "%mcpp.task.confirmClean.description%" + }, + "mcpp.task.revealTerminal": { + "type": "string", + "default": "always", + "enum": [ + "always", + "onFailure", + "never" + ], + "scope": "resource", + "order": 20060, + "description": "%mcpp.task.revealTerminal.title%", + "markdownDescription": "%mcpp.task.revealTerminal.description%" + }, + "mcpp.task.focusTerminal": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 20070, + "description": "%mcpp.task.focusTerminal.title%", + "markdownDescription": "%mcpp.task.focusTerminal.description%" + }, + "mcpp.task.problemMatcher": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20080, + "description": "%mcpp.task.problemMatcher.title%", + "markdownDescription": "%mcpp.task.problemMatcher.description%" + }, + "mcpp.task.editorTitleButtons": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20090, + "description": "%mcpp.task.editorTitleButtons.title%", + "markdownDescription": "%mcpp.task.editorTitleButtons.description%" + }, + "mcpp.languageService.menuItems": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30001, + "description": "%mcpp.languageService.menuItems.title%", + "markdownDescription": "%mcpp.languageService.menuItems.description%" + }, + "mcpp.languageService.confirmResetCache": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30002, + "description": "%mcpp.languageService.confirmResetCache.title%", + "markdownDescription": "%mcpp.languageService.confirmResetCache.description%" + }, + "mcpp.languageService.refreshAfterBuild": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "reload", + "restart", + "off" + ], + "scope": "resource", + "order": 30010, + "description": "%mcpp.languageService.refreshAfterBuild.title%", + "markdownDescription": "%mcpp.languageService.refreshAfterBuild.description%" + }, + "mcpp.languageService.notifyOnDegraded": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30020, + "description": "%mcpp.languageService.notifyOnDegraded.title%", + "markdownDescription": "%mcpp.languageService.notifyOnDegraded.description%" + }, + "mcpp.languageService.readState": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30030, + "description": "%mcpp.languageService.readState.title%", + "markdownDescription": "%mcpp.languageService.readState.description%" + }, + "mcpp.languageService.stateRefreshSeconds": { + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "order": 30040, + "description": "%mcpp.languageService.stateRefreshSeconds.title%", + "markdownDescription": "%mcpp.languageService.stateRefreshSeconds.description%" + }, + "mcpp.toml.completion": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40010, + "description": "%mcpp.toml.completion.title%", + "markdownDescription": "%mcpp.toml.completion.description%" + }, + "mcpp.toml.hover": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40020, + "description": "%mcpp.toml.hover.title%", + "markdownDescription": "%mcpp.toml.hover.description%" + }, + "mcpp.toml.navigation": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40030, + "description": "%mcpp.toml.navigation.title%", + "markdownDescription": "%mcpp.toml.navigation.description%" + }, + "mcpp.toml.diagnostics.enabled": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40040, + "description": "%mcpp.toml.diagnostics.enabled.title%", + "markdownDescription": "%mcpp.toml.diagnostics.enabled.description%" + }, + "mcpp.toml.diagnostics.syntax": { + "type": "string", + "default": "error", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40050, + "description": "%mcpp.toml.diagnostics.syntax.title%", + "markdownDescription": "%mcpp.toml.diagnostics.syntax.description%" + }, + "mcpp.toml.diagnostics.unknownSection": { + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40060, + "description": "%mcpp.toml.diagnostics.unknownSection.title%", + "markdownDescription": "%mcpp.toml.diagnostics.unknownSection.description%" + }, + "mcpp.toml.diagnostics.unknownKey": { + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40070, + "description": "%mcpp.toml.diagnostics.unknownKey.title%", + "markdownDescription": "%mcpp.toml.diagnostics.unknownKey.description%" + }, + "mcpp.toml.diagnostics.planeSeparation": { + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40080, + "description": "%mcpp.toml.diagnostics.planeSeparation.title%", + "markdownDescription": "%mcpp.toml.diagnostics.planeSeparation.description%" + }, + "mcpp.toml.diagnostics.legacyKeys": { + "type": "string", + "default": "info", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40090, + "description": "%mcpp.toml.diagnostics.legacyKeys.title%", + "markdownDescription": "%mcpp.toml.diagnostics.legacyKeys.description%" + }, + "mcpp.toml.indexCompletion": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 40100, + "description": "%mcpp.toml.indexCompletion.title%", + "markdownDescription": "%mcpp.toml.indexCompletion.description%" + }, + "mcpp.toml.indexCompletionTimeoutSeconds": { + "type": "number", + "default": 20, + "minimum": 1, + "maximum": 600, + "scope": "resource", + "order": 40110, + "description": "%mcpp.toml.indexCompletionTimeoutSeconds.title%", + "markdownDescription": "%mcpp.toml.indexCompletionTimeoutSeconds.description%" + }, + "mcpp.buildScript.intelligence": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50010, + "description": "%mcpp.buildScript.intelligence.title%", + "markdownDescription": "%mcpp.buildScript.intelligence.description%" + }, + "mcpp.buildScript.diagnostics": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50020, + "description": "%mcpp.buildScript.diagnostics.title%", + "markdownDescription": "%mcpp.buildScript.diagnostics.description%" + }, + "mcpp.buildScript.diagnostics.severity": { + "type": "string", + "default": "warning", + "enum": [ + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 50030, + "description": "%mcpp.buildScript.diagnostics.severity.title%", + "markdownDescription": "%mcpp.buildScript.diagnostics.severity.description%" + }, + "mcpp.buildScript.imports.knownModules": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50040, + "description": "%mcpp.buildScript.imports.knownModules.title%", + "markdownDescription": "%mcpp.buildScript.imports.knownModules.description%" + }, + "mcpp.buildScript.snippets": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50050, + "description": "%mcpp.buildScript.snippets.title%", + "markdownDescription": "%mcpp.buildScript.snippets.description%" + }, + "mcpp.cache.statusBar": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 60010, + "description": "%mcpp.cache.statusBar.title%", + "markdownDescription": "%mcpp.cache.statusBar.description%" + }, + "mcpp.cache.warnAboveGiB": { + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "order": 60020, + "description": "%mcpp.cache.warnAboveGiB.title%", + "markdownDescription": "%mcpp.cache.warnAboveGiB.description%" + }, + "mcpp.cache.staleDays": { + "type": "number", + "default": 3, + "minimum": 0, + "maximum": 365, + "scope": "resource", + "order": 60030, + "description": "%mcpp.cache.staleDays.title%", + "markdownDescription": "%mcpp.cache.staleDays.description%" + }, + "mcpp.cache.autoRefreshSeconds": { + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "order": 60040, + "description": "%mcpp.cache.autoRefreshSeconds.title%", + "markdownDescription": "%mcpp.cache.autoRefreshSeconds.description%" + }, + "mcpp.cache.estimateProjectBytes": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 60050, + "description": "%mcpp.cache.estimateProjectBytes.title%", + "markdownDescription": "%mcpp.cache.estimateProjectBytes.description%" + }, + "mcpp.cache.showLegacy": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 60060, + "description": "%mcpp.cache.showLegacy.title%", + "markdownDescription": "%mcpp.cache.showLegacy.description%" + }, + "mcpp.cache.gc.defaultBudgetGiB": { + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "order": 60070, + "description": "%mcpp.cache.gc.defaultBudgetGiB.title%", + "markdownDescription": "%mcpp.cache.gc.defaultBudgetGiB.description%" + }, + "mcpp.cache.gc.confirmAboveGiB": { + "type": "number", + "default": 1, + "minimum": 0, + "scope": "resource", + "order": 60080, + "description": "%mcpp.cache.gc.confirmAboveGiB.title%", + "markdownDescription": "%mcpp.cache.gc.confirmAboveGiB.description%" + }, + "mcpp.cache.pruneAgeDays": { + "type": "number", + "default": 30, + "minimum": 1, + "scope": "resource", + "order": 60090, + "description": "%mcpp.cache.pruneAgeDays.title%", + "markdownDescription": "%mcpp.cache.pruneAgeDays.description%" + }, + "mcpp.views.project.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70010, + "description": "%mcpp.views.project.show.title%", + "markdownDescription": "%mcpp.views.project.show.description%" + }, + "mcpp.views.cache.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70020, + "description": "%mcpp.views.cache.show.title%", + "markdownDescription": "%mcpp.views.cache.show.description%" + }, + "mcpp.views.languageServer.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70030, + "description": "%mcpp.views.languageServer.show.title%", + "markdownDescription": "%mcpp.views.languageServer.show.description%" + }, + "mcpp.views.cache.topN": { + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 50, + "scope": "window", + "order": 70040, + "description": "%mcpp.views.cache.topN.title%", + "markdownDescription": "%mcpp.views.cache.topN.description%" + }, + "mcpp.views.cache.ageBuckets": { + "type": "array", + "default": [ + "1d", + "7d", + "30d" + ], + "items": { + "type": "string" + }, + "scope": "window", + "order": 70050, + "description": "%mcpp.views.cache.ageBuckets.title%", + "markdownDescription": "%mcpp.views.cache.ageBuckets.description%" + }, + "mcpp.ui.confirmDestructiveOnly": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 80001, + "description": "%mcpp.ui.confirmDestructiveOnly.title%", + "markdownDescription": "%mcpp.ui.confirmDestructiveOnly.description%" + }, + "mcpp.ui.language": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "en", + "zh-cn" + ], + "scope": "window", + "order": 80010, + "description": "%mcpp.ui.language.title%", + "markdownDescription": "%mcpp.ui.language.description%" + }, + "mcpp.ui.statusBar.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 80020, + "description": "%mcpp.ui.statusBar.show.title%", + "markdownDescription": "%mcpp.ui.statusBar.show.description%" + }, + "mcpp.ui.statusBar.showLanguageServer": { + "type": "boolean", + "default": false, + "scope": "window", + "order": 80030, + "description": "%mcpp.ui.statusBar.showLanguageServer.title%", + "markdownDescription": "%mcpp.ui.statusBar.showLanguageServer.description%" + }, + "mcpp.ui.notifications.success": { + "type": "string", + "default": "statusBar", + "enum": [ + "silent", + "statusBar", + "toast" + ], + "scope": "window", + "order": 80040, + "description": "%mcpp.ui.notifications.success.title%", + "markdownDescription": "%mcpp.ui.notifications.success.description%" + }, + "mcpp.ui.notifications.dedupeMinutes": { + "type": "number", + "default": 5, + "minimum": 0, + "maximum": 1440, + "scope": "window", + "order": 80050, + "description": "%mcpp.ui.notifications.dedupeMinutes.title%", + "markdownDescription": "%mcpp.ui.notifications.dedupeMinutes.description%" + }, + "mcpp.ui.numberFormat": { + "type": "string", + "default": "binary", + "enum": [ + "binary", + "decimal" + ], + "scope": "window", + "order": 80060, + "description": "%mcpp.ui.numberFormat.title%", + "markdownDescription": "%mcpp.ui.numberFormat.description%" + }, + "mcpp.log.level": { + "type": "string", + "default": "info", + "enum": [ + "error", + "warn", + "info", + "debug" + ], + "scope": "window", + "order": 90010, + "description": "%mcpp.log.level.title%", + "markdownDescription": "%mcpp.log.level.description%" + }, + "mcpp.diagnostics.selfCheckOnStartup": { + "type": "boolean", + "default": false, + "scope": "window", + "order": 90020, + "description": "%mcpp.diagnostics.selfCheckOnStartup.title%", + "markdownDescription": "%mcpp.diagnostics.selfCheckOnStartup.description%" + }, + "mcpp.runtime.timeoutSeconds": { + "type": "number", + "default": 30, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "order": 100010, + "description": "%mcpp.runtime.timeoutSeconds.title%", + "markdownDescription": "%mcpp.runtime.timeoutSeconds.description%" + }, + "mcpp.runtime.maxOutputMiB": { + "type": "number", + "default": 16, + "minimum": 1, + "maximum": 1024, + "scope": "resource", + "order": 100020, + "description": "%mcpp.runtime.maxOutputMiB.title%", + "markdownDescription": "%mcpp.runtime.maxOutputMiB.description%" + }, + "mcpp.runtime.concurrency": { + "type": "string", + "default": "perProject", + "enum": [ + "perProject", + "global" + ], + "scope": "resource", + "order": 100030, + "description": "%mcpp.runtime.concurrency.title%", + "markdownDescription": "%mcpp.runtime.concurrency.description%" }, "mcpp.clangd.path": { "type": "string", "default": "", "scope": "resource", - "description": "已弃用:C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server,此设置不再生效。", - "deprecationMessage": "C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server;此设置不再被 mcpp-vscode 使用。" + "order": 100040, + "description": "%mcpp.clangd.path.title%", + "markdownDescription": "%mcpp.clangd.path.description%", + "deprecationMessage": "%mcpp.clangd.path.deprecationMessage%" }, "mcpp.modulesSupport": { "type": "string", + "default": "auto", "enum": [ "auto", "on", "off" ], - "default": "auto", "scope": "resource", - "description": "已弃用:C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server,此设置不再生效。", - "deprecationMessage": "C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server;此设置不再被 mcpp-vscode 使用。" + "order": 100050, + "description": "%mcpp.modulesSupport.title%", + "markdownDescription": "%mcpp.modulesSupport.description%", + "deprecationMessage": "%mcpp.modulesSupport.deprecationMessage%" }, "mcpp.configureCppTools": { "type": "boolean", "default": true, "scope": "resource", - "description": "已弃用:语言服务冲突现由 sunrisepeak.mcpp-language-server 管理,此设置不再生效。", - "deprecationMessage": "C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server;此设置不再被 mcpp-vscode 使用。" + "order": 100060, + "description": "%mcpp.configureCppTools.title%", + "markdownDescription": "%mcpp.configureCppTools.description%", + "deprecationMessage": "%mcpp.configureCppTools.deprecationMessage%" }, "mcpp.tomlCompletion": { "type": "boolean", "default": true, "scope": "resource", - "description": "为 mcpp.toml 提供结构补全:段头与写法模板(snippet)。所有建议带显式替换范围,并经真实 mcpp 契约测试验证。" + "order": 100070, + "description": "%mcpp.tomlCompletion.title%", + "markdownDescription": "%mcpp.tomlCompletion.description%", + "deprecationMessage": "%mcpp.tomlCompletion.deprecationMessage%" } } }, @@ -234,10 +845,15 @@ "scripts": { "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "compile": "tsc -p tsconfig.json", - "test": "npm run clean && npm run compile && node --test \"dist/test/**/*.test.js\"", + "test": "npm run check && npm run clean && npm run compile && node --test \"dist/test/**/*.test.js\"", "package": "npm run clean && npm run compile && vsce package", "test:e2e": "npm run compile && node dist/test/e2e/runTest.js", - "test:all": "npm test && npm run test:e2e" + "test:all": "npm test && npm run test:e2e", + "gen:l10n": "node tools/generate-l10n.mjs", + "gen:docs": "node tools/generate-settings-docs.mjs", + "check:config": "node tools/check-config.mjs", + "check:l10n": "node tools/l10n-check.mjs", + "check": "npm run check:config && npm run check:l10n" }, "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.nls.json b/package.nls.json new file mode 100644 index 0000000..fca0805 --- /dev/null +++ b/package.nls.json @@ -0,0 +1,155 @@ +{ + "category": "mcpp", + "command.mcpp.autoConfigureModules.title": "一键构建并刷新模块语言服务", + "command.mcpp.build.title": "构建", + "command.mcpp.checkModuleSupport.title": "重启 C++ Modules 语言服务", + "command.mcpp.clean.title": "清理 target", + "command.mcpp.configureClangd.title": "[已弃用] 配置 C++ 模块语言服务", + "command.mcpp.configureLanguageServer.title": "选择 C++ 模块分析上下文", + "command.mcpp.installToolchain.title": "安装工具链", + "command.mcpp.newProject.title": "新建工程", + "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", + "command.mcpp.run.title": "运行", + "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", + "command.mcpp.showLanguageServerLogs.title": "打开 C++ Modules 日志", + "command.mcpp.showMenu.title": "打开快捷菜单", + "command.mcpp.showModuleGraph.title": "查看模块图", + "command.mcpp.showToolchains.title": "查看工具链", + "command.mcpp.test.title": "测试", + "description": "Build, run, test and manage mcpp C++23 module projects, with mcpp.toml editing and build-script support.", + "displayName": "mcpp", + "mcpp.buildScript.diagnostics.description": "Validate build.mcpp against the build API mcpp actually ships.", + "mcpp.buildScript.diagnostics.severity.description": "Severity used for build.mcpp diagnostics.", + "mcpp.buildScript.diagnostics.severity.title": "Set build.mcpp diagnostic severity", + "mcpp.buildScript.diagnostics.title": "Enable build.mcpp diagnostics", + "mcpp.buildScript.imports.knownModules.description": "Flag imports in build.mcpp that are neither standard modules nor known mcpp modules.", + "mcpp.buildScript.imports.knownModules.title": "Check imported modules", + "mcpp.buildScript.intelligence.description": "Turn completion and hover support for build.mcpp on or off.", + "mcpp.buildScript.intelligence.title": "Enable build.mcpp intelligence", + "mcpp.buildScript.snippets.description": "Offer snippet completions for the mcpp build script API.", + "mcpp.buildScript.snippets.title": "Offer build.mcpp snippets", + "mcpp.cache.autoRefreshSeconds.description": "Re-read the cache statistics every this many seconds. 0 disables automatic refreshing.", + "mcpp.cache.autoRefreshSeconds.title": "Refresh the cache view automatically", + "mcpp.cache.estimateProjectBytes.description": "Measure the target directory of the current project so the cache view can show its size.", + "mcpp.cache.estimateProjectBytes.title": "Estimate project artifact size", + "mcpp.cache.gc.confirmAboveGiB.description": "Require an extra confirmation when a cleanup would free more than this many GiB. 0 confirms every cleanup.", + "mcpp.cache.gc.confirmAboveGiB.title": "Confirm large cache cleanup", + "mcpp.cache.gc.defaultBudgetGiB.description": "Default size budget for cache cleanup, in GiB. 0 asks for a budget on every run.", + "mcpp.cache.gc.defaultBudgetGiB.title": "Set the default cleanup budget", + "mcpp.cache.pruneAgeDays.description": "Default age in days used when pruning cache entries older than a threshold.", + "mcpp.cache.pruneAgeDays.title": "Set the pruning age", + "mcpp.cache.showLegacy.description": "Include cache entries written by older mcpp versions in the cache view and in cleaning.", + "mcpp.cache.showLegacy.title": "Show legacy cache entries", + "mcpp.cache.staleDays.description": "Treat cache entries that were not accessed for this many days as stale. 0 disables the age rule.", + "mcpp.cache.staleDays.title": "Mark stale cache entries", + "mcpp.cache.statusBar.description": "Show the size of the mcpp global cache in the status bar. Off by default to keep the status bar quiet.", + "mcpp.cache.statusBar.title": "Show cache size in status bar", + "mcpp.cache.warnAboveGiB.description": "Warn when the mcpp global cache grows past this many GiB. 0 disables the warning.", + "mcpp.cache.warnAboveGiB.title": "Warn above a cache size", + "mcpp.clangd.path.deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.clangd.path.description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.clangd.path.title": "Deprecated clangd path", + "mcpp.configuration.title": "mcpp settings", + "mcpp.configureCppTools.deprecationMessage": "Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.configureCppTools.description": "Deprecated: Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.configureCppTools.title": "Deprecated C++ tool configuration", + "mcpp.diagnostics.selfCheckOnStartup.description": "Run the environment self check on activation and write problems to the output channel. Off by default because it starts mcpp.", + "mcpp.diagnostics.selfCheckOnStartup.title": "Run a self check at startup", + "mcpp.languageService.confirmResetCache.description": "Ask before running the C++ Modules reset, which discards this workspace's model cache and prepares it again. Turning this off removes the only warning before that happens.", + "mcpp.languageService.confirmResetCache.title": "Confirm resetting the workspace cache", + "mcpp.languageService.menuItems.description": "Keep the C++ Modules actions in the mcpp quick menu. Turn off to leave the menu to mcpp's own commands.", + "mcpp.languageService.menuItems.title": "Show language-service entries in the menu", + "mcpp.languageService.notifyOnDegraded.description": "Show a notification when the C++ Modules language service reports a degraded state.", + "mcpp.languageService.notifyOnDegraded.title": "Notify when the language service degrades", + "mcpp.languageService.readState.description": "Read status from the C++ Modules extension through the API it exposes for tests. Turning this off only hides status; it never changes mcppls itself.", + "mcpp.languageService.readState.title": "Read language service state", + "mcpp.languageService.refreshAfterBuild.description": "How mcpp-vscode tells the C++ Modules extension about a finished build: auto, a lightweight reload, a full restart, or nothing.", + "mcpp.languageService.refreshAfterBuild.title": "Refresh language service after build", + "mcpp.languageService.stateRefreshSeconds.description": "Re-read the language service state every this many seconds. 0 disables the timer.", + "mcpp.languageService.stateRefreshSeconds.title": "Set the state refresh interval", + "mcpp.log.level.description": "Amount of detail written to the mcpp output channel. Debug output is verbose.", + "mcpp.log.level.title": "Set the log level", + "mcpp.modulesSupport.deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.modulesSupport.description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.modulesSupport.title": "Deprecated module support mode", + "mcpp.path.description": "Path to the mcpp CLI used for every mcpp command. Leave it empty to look up mcpp on the PATH of the VS Code process.", + "mcpp.path.title": "Set the mcpp executable path", + "mcpp.project.discoveryBoundary.description": "How far the extension walks to find mcpp.toml. \"filesystem\" also searches outside the workspace folder; it costs more I/O and can reach unrelated trees.", + "mcpp.project.discoveryBoundary.title": "Limit project discovery scope", + "mcpp.runtime.concurrency.description": "Whether the one-command-at-a-time rule applies to each project separately or to the whole window.", + "mcpp.runtime.concurrency.title": "Limit concurrent mcpp commands", + "mcpp.runtime.maxOutputMiB.description": "Maximum output captured for one mcpp command, in MiB. When output is truncated the tail is kept.", + "mcpp.runtime.maxOutputMiB.title": "Cap captured command output", + "mcpp.runtime.timeoutSeconds.description": "How long one mcpp CLI command may run before it is terminated, in seconds. 0 disables the timeout.", + "mcpp.runtime.timeoutSeconds.title": "Set the command timeout", + "mcpp.task.buildArgs.description": "Extra arguments appended to the mcpp build task. Applied on the next build.", + "mcpp.task.buildArgs.title": "Add arguments to mcpp build", + "mcpp.task.cleanArgs.description": "Extra arguments appended to the mcpp clean task. Applied on the next clean.", + "mcpp.task.cleanArgs.title": "Add arguments to mcpp clean", + "mcpp.task.clearTerminal.description": "Clear the terminal before an mcpp task starts, so the last build's output is not mistaken for this one's.", + "mcpp.task.clearTerminal.title": "Clear the task terminal", + "mcpp.task.confirmClean.description": "Ask for confirmation before a clean task deletes the target directory of the project.", + "mcpp.task.confirmClean.title": "Confirm before cleaning", + "mcpp.task.editorTitleButtons.description": "Show the Run and Test buttons in the editor title bar while the file belongs to an mcpp project.", + "mcpp.task.editorTitleButtons.title": "Show editor title buttons", + "mcpp.task.focusTerminal.description": "Move keyboard focus to the terminal while an mcpp task runs. Off by default so editor focus is not taken away.", + "mcpp.task.focusTerminal.title": "Focus the task terminal", + "mcpp.task.problemMatcher.description": "Parse compiler output of the mcpp build and test tasks into the Problems panel.", + "mcpp.task.problemMatcher.title": "Parse task problems", + "mcpp.task.revealTerminal.description": "When an mcpp task brings the terminal panel to the front: always, only when the task fails, or never.", + "mcpp.task.revealTerminal.title": "Reveal the task terminal", + "mcpp.task.runArgs.description": "Extra arguments appended to the mcpp run task. Applied on the next run.", + "mcpp.task.runArgs.title": "Add arguments to mcpp run", + "mcpp.task.testArgs.description": "Extra arguments appended to the mcpp test task. Applied on the next test run.", + "mcpp.task.testArgs.title": "Add arguments to mcpp test", + "mcpp.toml.completion.description": "Offer section, key and value completion while editing mcpp.toml. The old mcpp.tomlCompletion key still works as an alias.", + "mcpp.toml.completion.title": "Complete mcpp.toml keys", + "mcpp.toml.diagnostics.enabled.description": "Turn all mcpp.toml validation on or off. Every rule keeps its own severity setting.", + "mcpp.toml.diagnostics.enabled.title": "Enable mcpp.toml diagnostics", + "mcpp.toml.diagnostics.legacyKeys.description": "Severity used for pre-0.5 mcpp.toml keys that still work but should be migrated.", + "mcpp.toml.diagnostics.legacyKeys.title": "Set legacy key severity", + "mcpp.toml.diagnostics.planeSeparation.description": "Severity used when a key is written in the wrong mcpp.toml plane, such as a build key in the library plane.", + "mcpp.toml.diagnostics.planeSeparation.title": "Set plane separation severity", + "mcpp.toml.diagnostics.syntax.description": "Severity used for mcpp.toml syntax errors.", + "mcpp.toml.diagnostics.syntax.title": "Set syntax diagnostic severity", + "mcpp.toml.diagnostics.unknownKey.description": "Severity used for keys that mcpp does not recognise.", + "mcpp.toml.diagnostics.unknownKey.title": "Set unknown key severity", + "mcpp.toml.diagnostics.unknownSection.description": "Severity used for sections that mcpp does not recognise.", + "mcpp.toml.diagnostics.unknownSection.title": "Set unknown section severity", + "mcpp.toml.hover.description": "Show documentation for sections and keys when hovering in mcpp.toml.", + "mcpp.toml.hover.title": "Show mcpp.toml hovers", + "mcpp.toml.indexCompletion.description": "Query the package index to complete dependency versions. Off by default because it needs network access.", + "mcpp.toml.indexCompletion.title": "Complete dependency versions", + "mcpp.toml.indexCompletionTimeoutSeconds.description": "How long one package index query may run before it is abandoned and completion falls back to local data, in seconds.", + "mcpp.toml.indexCompletionTimeoutSeconds.title": "Set the index query timeout", + "mcpp.toml.navigation.description": "Enable go-to-definition for workspace, path and feature references in mcpp.toml.", + "mcpp.toml.navigation.title": "Navigate inside mcpp.toml", + "mcpp.tomlCompletion.deprecationMessage": "mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead.", + "mcpp.tomlCompletion.description": "Deprecated: mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead.", + "mcpp.tomlCompletion.title": "Deprecated mcpp.toml completion", + "mcpp.ui.confirmDestructiveOnly.description": "Use a modal dialogue only for actions that cannot be undone. Turning this off asks for confirmation more often, never less.", + "mcpp.ui.confirmDestructiveOnly.title": "Confirm only destructive actions", + "mcpp.ui.language.description": "Language for extension messages and panels. \"auto\" follows VS Code; command titles and settings labels always follow the VS Code display language.", + "mcpp.ui.language.title": "Set the extension language", + "mcpp.ui.notifications.dedupeMinutes.description": "Suppress a repeated notification for this many minutes. 0 shows every notification.", + "mcpp.ui.notifications.dedupeMinutes.title": "Deduplicate notifications", + "mcpp.ui.notifications.success.description": "How successful mcpp operations are reported: in the status bar only, as a toast, or not at all.", + "mcpp.ui.notifications.success.title": "Report successful operations", + "mcpp.ui.numberFormat.description": "Format byte sizes with binary units (KiB, MiB) or decimal units (KB, MB).", + "mcpp.ui.numberFormat.title": "Choose number formatting", + "mcpp.ui.statusBar.show.description": "Show the main mcpp status bar item.", + "mcpp.ui.statusBar.show.title": "Show the mcpp status bar item", + "mcpp.ui.statusBar.showLanguageServer.description": "Show the C++ Modules language service state in the mcpp status bar item. Off by default because mcppls already has its own status item.", + "mcpp.ui.statusBar.showLanguageServer.title": "Show language service status", + "mcpp.views.cache.ageBuckets.description": "Age buckets used to group cache entries in the cache view, written as \"1d\", \"7d\" and so on.", + "mcpp.views.cache.ageBuckets.title": "Set cache age buckets", + "mcpp.views.cache.show.description": "Show the mcpp cache view in the activity bar. Takes effect after the view container reloads.", + "mcpp.views.cache.show.title": "Show the cache view", + "mcpp.views.cache.topN.description": "How many entries the cache view lists per group.", + "mcpp.views.cache.topN.title": "Set cache list length", + "mcpp.views.languageServer.show.description": "Show the C++ Modules status view that mcpp-vscode fills in. Takes effect after the view container reloads.", + "mcpp.views.languageServer.show.title": "Show the language service view", + "mcpp.views.project.show.description": "Show the mcpp project view in the activity bar. Takes effect after the view container reloads.", + "mcpp.views.project.show.title": "Show the project view", + "untrustedWorkspaces.description": "In an untrusted workspace this extension only provides module syntax highlighting and text-only mcpp.toml completion; it runs no mcpp command and changes no language-service configuration." +} diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json new file mode 100644 index 0000000..d568e5c --- /dev/null +++ b/package.nls.zh-cn.json @@ -0,0 +1,155 @@ +{ + "category": "mcpp", + "command.mcpp.autoConfigureModules.title": "一键构建并刷新模块语言服务", + "command.mcpp.build.title": "构建", + "command.mcpp.checkModuleSupport.title": "重启 C++ Modules 语言服务", + "command.mcpp.clean.title": "清理 target", + "command.mcpp.configureClangd.title": "[已弃用] 配置 C++ 模块语言服务", + "command.mcpp.configureLanguageServer.title": "选择 C++ 模块分析上下文", + "command.mcpp.installToolchain.title": "安装工具链", + "command.mcpp.newProject.title": "新建工程", + "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", + "command.mcpp.run.title": "运行", + "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", + "command.mcpp.showLanguageServerLogs.title": "打开 C++ Modules 日志", + "command.mcpp.showMenu.title": "打开快捷菜单", + "command.mcpp.showModuleGraph.title": "查看模块图", + "command.mcpp.showToolchains.title": "查看工具链", + "command.mcpp.test.title": "测试", + "description": "构建、运行、测试并管理 mcpp C++23 模块工程,提供 mcpp.toml 编辑与构建脚本支持。", + "displayName": "mcpp", + "mcpp.buildScript.diagnostics.description": "按 mcpp 实际提供的构建 API 校验 build.mcpp。", + "mcpp.buildScript.diagnostics.severity.description": "build.mcpp 诊断使用的严重度。", + "mcpp.buildScript.diagnostics.severity.title": "设置 build.mcpp 诊断严重度", + "mcpp.buildScript.diagnostics.title": "启用 build.mcpp 诊断", + "mcpp.buildScript.imports.knownModules.description": "标记 build.mcpp 中既不是标准模块、也不是已知 mcpp 模块的导入。", + "mcpp.buildScript.imports.knownModules.title": "检查导入的模块", + "mcpp.buildScript.intelligence.description": "统一开关 build.mcpp 的补全与悬停支持。", + "mcpp.buildScript.intelligence.title": "启用 build.mcpp 智能", + "mcpp.buildScript.snippets.description": "为 mcpp 构建脚本 API 提供片段补全。", + "mcpp.buildScript.snippets.title": "提供 build.mcpp 片段", + "mcpp.cache.autoRefreshSeconds.description": "每隔多少秒重新读取一次缓存统计。0 表示关闭自动刷新。", + "mcpp.cache.autoRefreshSeconds.title": "自动刷新缓存视图", + "mcpp.cache.estimateProjectBytes.description": "统计当前工程 target 目录的体积,供缓存视图显示。", + "mcpp.cache.estimateProjectBytes.title": "估算工程产物体积", + "mcpp.cache.gc.confirmAboveGiB.description": "单次清理预计释放超过该 GiB 数时追加一次确认。0 表示每次清理都确认。", + "mcpp.cache.gc.confirmAboveGiB.title": "大额缓存清理前确认", + "mcpp.cache.gc.defaultBudgetGiB.description": "缓存清理的默认体积预算(GiB)。0 表示每次清理都询问预算。", + "mcpp.cache.gc.defaultBudgetGiB.title": "设置默认清理预算", + "mcpp.cache.pruneAgeDays.description": "清理缓存条目时默认使用的天数阈值。", + "mcpp.cache.pruneAgeDays.title": "设置清理保留天数", + "mcpp.cache.showLegacy.description": "在缓存视图与清理中包含旧版 mcpp 写入的缓存条目。", + "mcpp.cache.showLegacy.title": "显示旧版缓存条目", + "mcpp.cache.staleDays.description": "超过该天数未被访问的缓存条目视为过期。0 表示关闭该时间规则。", + "mcpp.cache.staleDays.title": "标记过期缓存条目", + "mcpp.cache.statusBar.description": "在状态栏显示 mcpp 全局缓存的大小。默认关闭,以保持状态栏清爽。", + "mcpp.cache.statusBar.title": "在状态栏显示缓存大小", + "mcpp.cache.warnAboveGiB.description": "mcpp 全局缓存超过该 GiB 数时发出警告。0 表示关闭警告。", + "mcpp.cache.warnAboveGiB.title": "缓存超过阈值时警告", + "mcpp.clangd.path.deprecationMessage": "mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.clangd.path.description": "已弃用:mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.clangd.path.title": "已弃用的 clangd 路径", + "mcpp.configuration.title": "mcpp 设置", + "mcpp.configureCppTools.deprecationMessage": "mcpp-vscode 不再读取此设置;语言服务冲突现由 sunrisepeak.mcpp-language-server 管理。", + "mcpp.configureCppTools.description": "已弃用:mcpp-vscode 不再读取此设置;语言服务冲突现由 sunrisepeak.mcpp-language-server 管理。", + "mcpp.configureCppTools.title": "已弃用的 C++ 工具配置", + "mcpp.diagnostics.selfCheckOnStartup.description": "激活时运行环境自检,并把发现的问题写入输出频道。默认关闭,因为会启动 mcpp。", + "mcpp.diagnostics.selfCheckOnStartup.title": "启动时运行环境自检", + "mcpp.languageService.confirmResetCache.description": "在执行 C++ Modules 的重置前询问——该操作会丢弃本工作区的模型缓存并重新准备。关闭后这一步不再有提示。", + "mcpp.languageService.confirmResetCache.title": "重置工作区缓存前确认", + "mcpp.languageService.menuItems.description": "在 mcpp 快捷菜单中保留 C++ Modules 操作。关闭后菜单只留 mcpp 自己的命令。", + "mcpp.languageService.menuItems.title": "在快捷菜单中显示语言服务项", + "mcpp.languageService.notifyOnDegraded.description": "C++ Modules 语言服务报告降级状态时显示通知。", + "mcpp.languageService.notifyOnDegraded.title": "语言服务降级时提示", + "mcpp.languageService.readState.description": "通过 C++ Modules 扩展对外暴露的测试 API 读取其状态。关闭只会隐藏状态,不会改动 mcppls 本身。", + "mcpp.languageService.readState.title": "读取语言服务状态", + "mcpp.languageService.refreshAfterBuild.description": "mcpp-vscode 以何种方式把构建结果告知 C++ Modules 扩展:auto、轻量重载、完整重启,或不做处理。", + "mcpp.languageService.refreshAfterBuild.title": "构建后刷新语言服务", + "mcpp.languageService.stateRefreshSeconds.description": "每隔多少秒重新读取一次语言服务状态。0 表示关闭定时刷新。", + "mcpp.languageService.stateRefreshSeconds.title": "设置状态刷新间隔", + "mcpp.log.level.description": "写入 mcpp 输出频道的详细程度。debug 输出较为冗长。", + "mcpp.log.level.title": "设置日志级别", + "mcpp.modulesSupport.deprecationMessage": "mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.modulesSupport.description": "已弃用:mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.modulesSupport.title": "已弃用的模块支持模式", + "mcpp.path.description": "供扩展执行全部 mcpp CLI 命令使用的 mcpp 路径。留空时从 VS Code 进程的 PATH 中查找 mcpp。", + "mcpp.path.title": "设置 mcpp 可执行文件路径", + "mcpp.project.discoveryBoundary.description": "扩展为查找 mcpp.toml 而向上搜索的范围。设为 “filesystem” 时还会搜索工作区文件夹之外,磁盘开销更大,且可能触及无关目录树。", + "mcpp.project.discoveryBoundary.title": "限制工程发现范围", + "mcpp.runtime.concurrency.description": "「同一时刻只运行一条命令」的限制作用于单个工程,还是整个窗口。", + "mcpp.runtime.concurrency.title": "限制并发 mcpp 命令", + "mcpp.runtime.maxOutputMiB.description": "单条 mcpp 命令最多捕获的输出(MiB)。输出被截断时保留尾部。", + "mcpp.runtime.maxOutputMiB.title": "限制命令输出大小", + "mcpp.runtime.timeoutSeconds.description": "单条 mcpp CLI 命令的最长运行秒数,超时后终止。0 表示不限时。", + "mcpp.runtime.timeoutSeconds.title": "设置命令超时", + "mcpp.task.buildArgs.description": "追加到 mcpp build 任务的额外参数。下次构建时生效。", + "mcpp.task.buildArgs.title": "为 mcpp build 追加参数", + "mcpp.task.cleanArgs.description": "追加到 mcpp clean 任务的额外参数。下次清理时生效。", + "mcpp.task.cleanArgs.title": "为 mcpp clean 追加参数", + "mcpp.task.clearTerminal.description": "在 mcpp 任务开始前清空终端,避免把上一次构建的输出误当成这一次的。", + "mcpp.task.clearTerminal.title": "清空任务终端", + "mcpp.task.confirmClean.description": "清理任务删除工程 target 目录前先请求确认。", + "mcpp.task.confirmClean.title": "清理前确认", + "mcpp.task.editorTitleButtons.description": "当文件属于 mcpp 工程时,在编辑器标题栏显示运行与测试按钮。", + "mcpp.task.editorTitleButtons.title": "显示编辑器标题按钮", + "mcpp.task.focusTerminal.description": "任务运行时把键盘焦点移到终端。默认关闭,以免抢走编辑器焦点。", + "mcpp.task.focusTerminal.title": "聚焦任务终端", + "mcpp.task.problemMatcher.description": "把 mcpp build 与 test 任务的编译器输出解析到「问题」面板。", + "mcpp.task.problemMatcher.title": "解析任务问题", + "mcpp.task.revealTerminal.description": "mcpp 任务何时把终端面板带到前台:始终显示、仅在失败时显示,或从不显示。", + "mcpp.task.revealTerminal.title": "显示任务终端", + "mcpp.task.runArgs.description": "追加到 mcpp run 任务的额外参数。下次运行时生效。", + "mcpp.task.runArgs.title": "为 mcpp run 追加参数", + "mcpp.task.testArgs.description": "追加到 mcpp test 任务的额外参数。下次测试时生效。", + "mcpp.task.testArgs.title": "为 mcpp test 追加参数", + "mcpp.toml.completion.description": "编辑 mcpp.toml 时提供段、键与值补全。旧键 mcpp.tomlCompletion 仍作为别名生效。", + "mcpp.toml.completion.title": "补全 mcpp.toml 键", + "mcpp.toml.diagnostics.enabled.description": "统一开关 mcpp.toml 的校验。各条规则仍保留各自的严重度设置。", + "mcpp.toml.diagnostics.enabled.title": "启用 mcpp.toml 诊断", + "mcpp.toml.diagnostics.legacyKeys.description": "0.5 之前仍可用、但建议迁移的 mcpp.toml 旧键的严重度。", + "mcpp.toml.diagnostics.legacyKeys.title": "设置旧键严重度", + "mcpp.toml.diagnostics.planeSeparation.description": "在 mcpp.toml 中把键写错层面(例如把构建层面的键写进库层面)时的严重度。", + "mcpp.toml.diagnostics.planeSeparation.title": "设置层面混用严重度", + "mcpp.toml.diagnostics.syntax.description": "mcpp.toml 语法错误的严重度。", + "mcpp.toml.diagnostics.syntax.title": "设置语法诊断严重度", + "mcpp.toml.diagnostics.unknownKey.description": "mcpp 无法识别的键的严重度。", + "mcpp.toml.diagnostics.unknownKey.title": "设置未知键严重度", + "mcpp.toml.diagnostics.unknownSection.description": "mcpp 无法识别的段的严重度。", + "mcpp.toml.diagnostics.unknownSection.title": "设置未知段严重度", + "mcpp.toml.hover.description": "在 mcpp.toml 中悬停时显示段与键的说明。", + "mcpp.toml.hover.title": "显示 mcpp.toml 悬停", + "mcpp.toml.indexCompletion.description": "查询包索引以补全依赖版本。默认关闭,因为需要网络访问。", + "mcpp.toml.indexCompletion.title": "补全依赖版本", + "mcpp.toml.indexCompletionTimeoutSeconds.description": "单次包索引查询的最长等待秒数;超时后回退到本地数据补全。", + "mcpp.toml.indexCompletionTimeoutSeconds.title": "设置索引查询超时", + "mcpp.toml.navigation.description": "为 mcpp.toml 中的 workspace、path 与 features 引用启用跳转定义。", + "mcpp.toml.navigation.title": "在 mcpp.toml 内跳转", + "mcpp.tomlCompletion.deprecationMessage": "mcpp-vscode 不再读取此设置;请改用 mcpp.toml.completion。", + "mcpp.tomlCompletion.description": "已弃用:mcpp-vscode 不再读取此设置;请改用 mcpp.toml.completion。", + "mcpp.tomlCompletion.title": "已弃用的 mcpp.toml 补全", + "mcpp.ui.confirmDestructiveOnly.description": "只在无法撤销的操作上使用模态对话框。关闭后只会问得更多,不会更少。", + "mcpp.ui.confirmDestructiveOnly.title": "仅对不可逆操作确认", + "mcpp.ui.language.description": "扩展提示与面板使用的语言。“auto” 跟随 VS Code;命令面板标题与设置项名称始终跟随 VS Code 显示语言。", + "mcpp.ui.language.title": "设置扩展语言", + "mcpp.ui.notifications.dedupeMinutes.description": "同一条通知在该分钟数内只显示一次。0 表示每次都显示。", + "mcpp.ui.notifications.dedupeMinutes.title": "通知去重", + "mcpp.ui.notifications.success.description": "mcpp 操作成功时的提示方式:只在状态栏显示、弹出 toast,或完全静默。", + "mcpp.ui.notifications.success.title": "报告成功操作", + "mcpp.ui.numberFormat.description": "字节大小使用二进制单位(KiB、MiB)还是十进制单位(KB、MB)。", + "mcpp.ui.numberFormat.title": "选择数字格式", + "mcpp.ui.statusBar.show.description": "显示 mcpp 的主状态栏项。", + "mcpp.ui.statusBar.show.title": "显示 mcpp 状态栏项", + "mcpp.ui.statusBar.showLanguageServer.description": "在 mcpp 状态栏项中显示 C++ Modules 语言服务状态。默认关闭,因为 mcppls 已有自己的状态项。", + "mcpp.ui.statusBar.showLanguageServer.title": "显示语言服务状态", + "mcpp.views.cache.ageBuckets.description": "缓存视图中用于分组缓存条目的时间桶,写作 “1d”、“7d” 等。", + "mcpp.views.cache.ageBuckets.title": "设置缓存时间分桶", + "mcpp.views.cache.show.description": "在活动栏显示 mcpp 缓存视图。视图容器重载后生效。", + "mcpp.views.cache.show.title": "显示缓存视图", + "mcpp.views.cache.topN.description": "缓存视图每组最多列出多少条。", + "mcpp.views.cache.topN.title": "设置缓存列表条数", + "mcpp.views.languageServer.show.description": "显示由 mcpp-vscode 填充内容的 C++ Modules 状态视图。视图容器重载后生效。", + "mcpp.views.languageServer.show.title": "显示语言服务视图", + "mcpp.views.project.show.description": "在活动栏显示 mcpp 工程视图。视图容器重载后生效。", + "mcpp.views.project.show.title": "显示工程视图", + "untrustedWorkspaces.description": "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。" +} 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/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/extension.ts b/src/extension.ts index 969d96b..4afcefe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,14 @@ import { type ModuleSetupStepResult, } from "./workflows/moduleSetup"; import type { TaskCompletion } from "./cli/tasks"; +import { onDidChange as onConfigurationChanged, read } from "./config/access"; +import { setLanguagePreference, type LanguagePreference } from "./i18n/t"; + +/** `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; @@ -191,6 +199,11 @@ const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { 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, activateExtension: async (id) => { diff --git a/src/i18n/t.ts b/src/i18n/t.ts new file mode 100644 index 0000000..9d8c7fe --- /dev/null +++ b/src/i18n/t.ts @@ -0,0 +1,62 @@ +/** + * 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 * 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; + +/** + * 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") { + return vscode.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/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/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/test/artifacts.test.ts b/test/artifacts.test.ts index b55e642..81c4b64 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -5,6 +5,7 @@ import test from "node:test"; interface PackageManifest { version?: string; + displayName?: string; description?: string; icon?: string; dependencies?: Record; @@ -17,9 +18,9 @@ interface PackageManifest { activationEvents?: string[]; capabilities?: { untrustedWorkspaces?: { supported?: string; description?: 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 }>; grammars?: Array<{ language?: string; scopeName: string; injectTo?: string[]; path: string }>; @@ -31,7 +32,9 @@ 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 语言服务集成"); + // 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")); @@ -40,10 +43,7 @@ test("declares mcpp-language-server as the C++ modules language service", () => assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureLanguageServer")); assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureClangd")); // deprecated alias 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%"); assert.deepEqual( manifest.contributes?.commands?.map((command) => command.command), [ @@ -67,6 +67,11 @@ test("declares mcpp-language-server as the C++ modules language service", () => ); 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"], { diff --git a/test/config/presets.test.ts b/test/config/presets.test.ts new file mode 100644 index 0000000..045f42e --- /dev/null +++ b/test/config/presets.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { PRESETS, preset, presetProblems, presetValues } from "../../src/config/presets"; +import { setting } from "../../src/config/registry"; + +test("every preset names only settings that exist", () => { + assert.deepEqual(presetProblems(), []); +}); + +test("preset ids are unique and carry English copy", () => { + const ids = PRESETS.map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length); + for (const entry of PRESETS) { + assert.ok(entry.title.length > 0, entry.id); + assert.ok(entry.description.length > 0, entry.id); + } +}); + +test("the defaults preset writes nothing", () => { + assert.deepEqual(presetValues("defaults"), []); + assert.deepEqual(preset("defaults")?.values, {}); +}); + +test("a preset value is valid for its setting", () => { + for (const entry of PRESETS) { + for (const [key, value] of Object.entries(entry.values)) { + const declared = setting(key); + assert.ok(declared, key); + if (declared.enum !== undefined) { + assert.ok(declared.enum.includes(String(value)), `${key}=${String(value)} is not in its enum`); + } + if (declared.type === "number" && typeof value === "number") { + if (declared.minimum !== undefined) assert.ok(value >= declared.minimum, `${key} below minimum`); + if (declared.maximum !== undefined) assert.ok(value <= declared.maximum, `${key} above maximum`); + } + } + } +}); + +test("an unknown preset is empty rather than an error", () => { + assert.equal(preset("nope"), undefined); + assert.deepEqual(presetValues("nope"), []); +}); + +test("the quiet preset turns the noisy things off", () => { + const values = new Map(presetValues("quiet").map((entry) => [entry.key, entry.value])); + assert.equal(values.get("mcpp.cache.statusBar"), false); + assert.equal(values.get("mcpp.cache.autoRefreshSeconds"), 0); + assert.equal(values.get("mcpp.ui.notifications.success"), "silent"); +}); diff --git a/test/config/registry.test.ts b/test/config/registry.test.ts new file mode 100644 index 0000000..d221e2e --- /dev/null +++ b/test/config/registry.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + GROUPS, + SETTINGS, + aliasedKey, + groupOf, + groupedKeys, + manifestKeys, + registryShapeProblems, + renamedKeys, + setting, + settingsByTier, + settingsInGroup, + subKey, +} from "../../src/config/registry"; + +test("the registry declares 10 groups and 64 settings", () => { + assert.equal(GROUPS.length, 10); + assert.equal(SETTINGS.length, 64); +}); + +test("the registry's own shape rules hold", () => { + assert.deepEqual(registryShapeProblems(), []); +}); + +test("29 settings are public and the rest are advanced", () => { + assert.equal(settingsByTier("public").length, 29); + assert.equal(settingsByTier("advanced").length, 35); + // The four deprecated keys live in `advanced` so the Settings UI keeps them out of the way. + assert.equal(settingsByTier("advanced").filter((entry) => entry.deprecated === true).length, 4); +}); + +test("every key resolves, and subKey strips the section", () => { + for (const key of manifestKeys()) { + assert.ok(setting(key), `missing ${key}`); + assert.ok(groupOf(key), `no group for ${key}`); + } + assert.equal(setting("mcpp.nope"), undefined); + assert.equal(subKey("mcpp.cache.staleDays"), "cache.staleDays"); + assert.equal(subKey("cache.staleDays"), "cache.staleDays"); +}); + +test("settingsInGroup returns the declared order", () => { + const cache = settingsInGroup("cache"); + assert.ok(cache.length > 0); + const orders = cache.map((entry) => entry.order); + assert.deepEqual(orders, [...orders].sort((a, b) => a - b)); + assert.ok(cache.every((entry) => entry.group === "cache")); +}); + +test("the plan's defaults survive in the registry", () => { + assert.equal(setting("mcpp.cache.staleDays")?.default, 3); + assert.equal(setting("mcpp.toml.diagnostics.unknownSection")?.default, "warning"); + assert.equal(setting("mcpp.toml.diagnostics.unknownKey")?.default, "warning"); + assert.equal(setting("mcpp.toml.indexCompletion")?.default, false); + assert.equal(setting("mcpp.languageService.readState")?.default, true); + assert.equal(setting("mcpp.views.languageServer.show")?.default, true); + assert.equal(setting("mcpp.ui.statusBar.showLanguageServer")?.default, false); + assert.equal(setting("mcpp.runtime.timeoutSeconds")?.default, 30); + assert.deepEqual(setting("mcpp.views.cache.ageBuckets")?.default, ["1d", "7d", "30d"]); +}); + +test("the four deprecated settings are declared and not public", () => { + for (const key of ["mcpp.clangd.path", "mcpp.modulesSupport", "mcpp.configureCppTools", "mcpp.tomlCompletion"]) { + const current = setting(key); + assert.ok(current, `missing deprecated ${key}`); + assert.equal(current.deprecated, true); + assert.equal(current.tier, "advanced"); + assert.equal(typeof current.deprecationMessage, "string"); + } +}); + +test("renamed settings point forward, and the alias resolves", () => { + assert.deepEqual(renamedKeys(), [{ from: "mcpp.tomlCompletion", to: "mcpp.toml.completion" }]); + assert.equal(aliasedKey("mcpp.tomlCompletion"), "mcpp.toml.completion"); + assert.equal(aliasedKey("mcpp.path"), undefined); +}); + +test("groupedKeys covers every setting exactly once", () => { + const grouped = groupedKeys(); + assert.equal(grouped.length, SETTINGS.length); + assert.equal(new Set(grouped).size, SETTINGS.length); +}); diff --git a/test/config/validate.test.ts b/test/config/validate.test.ts new file mode 100644 index 0000000..4875a85 --- /dev/null +++ b/test/config/validate.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { defaultValue, expectation, validateValue } from "../../src/config/validate"; +import type { SettingEntry } from "../../src/config/registry"; + +function entry(overrides: Partial): SettingEntry { + return { + key: "mcpp.test.value", + type: "boolean", + default: true, + scope: "resource", + group: "advanced", + order: 1, + tier: "advanced", + applies: "immediate", + since: "0.5.0", + title: "Test", + description: "Test setting.", + ...overrides, + } as SettingEntry; +} + +test("boolean accepts only booleans", () => { + const current = entry({ type: "boolean", default: true }); + assert.deepEqual(validateValue(current, true), { ok: true, value: true }); + assert.deepEqual(validateValue(current, "true"), { ok: false, reason: "type" }); + assert.deepEqual(validateValue(current, undefined), { ok: false, reason: "type" }); + assert.deepEqual(validateValue(current, null), { ok: false, reason: "type" }); +}); + +test("number enforces the declared bounds", () => { + const current = entry({ type: "number", default: 3, minimum: 0, maximum: 365 }); + assert.deepEqual(validateValue(current, 3), { ok: true, value: 3 }); + assert.deepEqual(validateValue(current, 0), { ok: true, value: 0 }); + assert.deepEqual(validateValue(current, -1), { ok: false, reason: "minimum" }); + assert.deepEqual(validateValue(current, 366), { ok: false, reason: "maximum" }); + assert.deepEqual(validateValue(current, Number.NaN), { ok: false, reason: "type" }); + assert.deepEqual(validateValue(current, "3"), { ok: false, reason: "type" }); +}); + +test("string enforces the enum when one is declared", () => { + const current = entry({ type: "string", default: "auto", enum: ["auto", "en", "zh-cn"] }); + assert.deepEqual(validateValue(current, "en"), { ok: true, value: "en" }); + assert.deepEqual(validateValue(current, "fr"), { ok: false, reason: "enum" }); +}); + +test("a free-form string accepts anything textual", () => { + const current = entry({ type: "string", default: "" }); + assert.deepEqual(validateValue(current, "/usr/bin/mcpp"), { ok: true, value: "/usr/bin/mcpp" }); + assert.deepEqual(validateValue(current, 7), { ok: false, reason: "type" }); +}); + +test("array accepts only arrays of strings", () => { + const current = entry({ type: "array", default: ["-j", "4"] }); + assert.deepEqual(validateValue(current, ["-q"]), { ok: true, value: ["-q"] }); + assert.deepEqual(validateValue(current, []), { ok: true, value: [] }); + assert.deepEqual(validateValue(current, ["ok", 1]), { ok: false, reason: "items" }); + assert.deepEqual(validateValue(current, "-q"), { ok: false, reason: "type" }); +}); + +test("validateValue copies arrays so callers cannot mutate the registry", () => { + const current = entry({ type: "array", default: ["a"] }); + const result = validateValue(current, ["b"]); + assert.equal(result.ok, true); + (result.value as string[]).push("c"); + assert.deepEqual(current.default, ["a"]); +}); + +test("defaultValue also hands out a fresh array", () => { + const current = entry({ type: "array", default: ["a"] }); + const first = defaultValue(current); + first.push("b"); + assert.deepEqual(defaultValue(current), ["a"]); +}); + +test("expectation describes what a value should look like", () => { + assert.equal(expectation(entry({ type: "string", default: "a", enum: ["a", "b"] })), "a | b"); + assert.equal(expectation(entry({ type: "number", default: 1, minimum: 0, maximum: 10 })), "0 … 10"); + assert.equal(expectation(entry({ type: "boolean", default: true })), "boolean"); + assert.equal(expectation(entry({ type: "array", default: [] })), "array of strings"); +}); diff --git a/test/i18n/translate.test.ts b/test/i18n/translate.test.ts new file mode 100644 index 0000000..c7b0d6f --- /dev/null +++ b/test/i18n/translate.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { format, localeFromEditorLanguage, missingTranslations, translate } from "../../src/i18n/translate"; + +const BUNDLE = { "Build failed ({0}).": "构建失败({0})。" } as const; + +test("format substitutes positional placeholders", () => { + assert.equal(format("a {0} b {1}", ["x", 2]), "a x b 2"); + assert.equal(format("no placeholders", []), "no placeholders"); + // An out-of-range placeholder is left alone rather than becoming "undefined". + assert.equal(format("{0} {5}", ["x"]), "x {5}"); +}); + +test("translate falls back to English for a missing entry", () => { + assert.equal(translate("zh-cn", "Never translated", [], BUNDLE), "Never translated"); + assert.equal(translate("zh-cn", "Build failed ({0}).", ["gcc"], BUNDLE), "构建失败(gcc)。"); +}); + +test("translate in English mode ignores the bundle", () => { + assert.equal(translate("en", "Build failed ({0}).", ["gcc"], BUNDLE), "Build failed (gcc)."); +}); + +test("an empty translation is treated as missing", () => { + assert.equal(translate("zh-cn", "Empty", [], { Empty: "" }), "Empty"); +}); + +test("localeFromEditorLanguage maps Chinese locales and everything else to English", () => { + assert.equal(localeFromEditorLanguage("zh-cn"), "zh-cn"); + assert.equal(localeFromEditorLanguage("zh-TW"), "zh-cn"); + assert.equal(localeFromEditorLanguage("en"), "en"); + assert.equal(localeFromEditorLanguage("de"), "en"); + assert.equal(localeFromEditorLanguage(undefined), undefined); +}); + +test("missingTranslations reports only the gaps", () => { + assert.deepEqual(missingTranslations(["a", "b"], { a: "A" }), ["b"]); + assert.deepEqual(missingTranslations(["a"], { a: "A" }), []); + assert.deepEqual(missingTranslations(["a"], { a: "" }), ["a"]); +}); diff --git a/test/util/format.test.ts b/test/util/format.test.ts new file mode 100644 index 0000000..9d48cba --- /dev/null +++ b/test/util/format.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ageInDays, + bucketIndex, + formatBytes, + formatCount, + fromUnixSeconds, + parseAgeBuckets, + projectGc, +} from "../../src/util/format"; + +test("formatBytes uses binary units by default and three significant digits", () => { + assert.equal(formatBytes(0), "0.00 B"); + assert.equal(formatBytes(999), "999 B"); + assert.equal(formatBytes(1024), "1.00 KiB"); + assert.equal(formatBytes(1024 * 1024 * 1.5), "1.50 MiB"); + assert.equal(formatBytes(1024 * 1024 * 12), "12.0 MiB"); + assert.equal(formatBytes(1024 * 1024 * 123), "123 MiB"); + // 7.22 GiB — the figure the plan quotes from a real cache. + assert.equal(formatBytes(7_736_306_884), "7.20 GiB"); +}); + +test("formatBytes can speak decimal units", () => { + assert.equal(formatBytes(1_000_000, "decimal"), "1.00 MB"); + assert.equal(formatBytes(1_000_000, "binary"), "977 KiB"); +}); + +test("formatBytes survives nonsense", () => { + assert.equal(formatBytes(Number.NaN), "0.00 B"); + assert.equal(formatBytes(-5), "0.00 B"); + assert.equal(formatBytes(Number.POSITIVE_INFINITY), "0.00 B"); +}); + +test("formatCount rounds and groups", () => { + assert.equal(formatCount(0), "0"); + assert.equal(formatCount(657), "657"); + assert.equal(formatCount(1234.6), (1235).toLocaleString()); +}); + +test("fromUnixSeconds rejects zero and non-finite values", () => { + assert.equal(fromUnixSeconds(undefined), undefined); + assert.equal(fromUnixSeconds(0), undefined); + assert.equal(fromUnixSeconds(Number.NaN), undefined); + assert.equal(fromUnixSeconds(1_790_804_836)?.getTime(), 1_790_804_836_000); +}); + +test("ageInDays floors at zero for a future timestamp", () => { + const now = new Date("2026-10-02T12:00:00Z"); + assert.equal(ageInDays(new Date("2026-10-02T12:00:00Z"), now), 0); + assert.equal(ageInDays(new Date("2026-10-05T12:00:00Z"), now), 0); + assert.equal(ageInDays(new Date("2026-09-29T12:00:00Z"), now), 3); +}); + +test("parseAgeBuckets accepts d/h/w/m and falls back to the documented default", () => { + assert.deepEqual(parseAgeBuckets(["1d", "7d", "30d"]), [1, 7, 30]); + assert.deepEqual(parseAgeBuckets(["48h", "1w", "2m"]), [2, 7, 60]); + assert.deepEqual(parseAgeBuckets([]), [1, 7, 30]); + assert.deepEqual(parseAgeBuckets(["nonsense"]), [1, 7, 30]); + // Sorted and de-duplicated, so a hand-edited setting cannot overlap. + assert.deepEqual(parseAgeBuckets(["30d", "7d", "7d", "1d"]), [1, 7, 30]); +}); + +test("bucketIndex puts the oldest entries in the overflow bucket", () => { + const bounds = [1, 7, 30]; + assert.equal(bucketIndex(0, bounds), 0); + assert.equal(bucketIndex(1, bounds), 1); + assert.equal(bucketIndex(7, bounds), 2); + assert.equal(bucketIndex(29, bounds), 2); + assert.equal(bucketIndex(30, bounds), 3); +}); + +test("projectGc keeps everything when the budget already fits", () => { + const entries = [{ bytes: 100, accessed: 1 }, { bytes: 200, accessed: 2 }]; + const projection = projectGc(entries, 1000); + assert.deepEqual(projection.removed, []); + assert.equal(projection.freedBytes, 0); + assert.equal(projection.remainingBytes, 300); +}); + +test("projectGc drops least-recently-used entries first", () => { + const entries = [ + { bytes: 400, accessed: 30 }, + { bytes: 300, accessed: 10 }, + { bytes: 300, accessed: 20 }, + ]; + // 1000 bytes total, budget 600: the two oldest (10, then 20) go. + const projection = projectGc(entries, 600); + assert.deepEqual(projection.removed.map((entry) => entry.accessed), [10, 20]); + assert.equal(projection.freedBytes, 600); + assert.equal(projection.remainingBytes, 400); + assert.equal(projection.kept.length, 1); +}); + +test("projectGc treats entries without a timestamp as newest", () => { + const entries = [ + { bytes: 500, accessed: undefined }, + { bytes: 500, accessed: 1 }, + ]; + const projection = projectGc(entries, 500); + assert.deepEqual(projection.removed.map((entry) => entry.accessed), [1]); +}); + +test("projectGc never reports a negative remaining size", () => { + const projection = projectGc([{ bytes: 0, accessed: 1 }, { bytes: 0, accessed: 2 }], 0); + assert.equal(projection.remainingBytes, 0); + assert.equal(projection.freedBytes, 0); +}); diff --git a/test/util/text.test.ts b/test/util/text.test.ts new file mode 100644 index 0000000..9c29970 --- /dev/null +++ b/test/util/text.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clampOutput, escapeHtml, firstLine, formatArguments, splitList, stripAnsi } from "../../src/util/text"; + +test("stripAnsi removes colour codes", () => { + assert.equal(stripAnsi("\u001b[32mok\u001b[0m"), "ok"); + assert.equal(stripAnsi("plain"), "plain"); +}); + +test("clampOutput keeps the tail, because that is where the answer is", () => { + const lines = Array.from({ length: 10 }, (_, index) => `line ${index}`); + const clamped = clampOutput(lines.join("\n"), { maxLines: 3 }); + assert.equal(clamped.truncated, true); + assert.equal(clamped.droppedLines, 7); + assert.equal(clamped.text, "line 7\nline 8\nline 9"); +}); + +test("clampOutput leaves a short output untouched", () => { + const clamped = clampOutput("only\nlines", { maxLines: 10, maxChars: 1000 }); + assert.equal(clamped.truncated, false); + assert.equal(clamped.droppedLines, 0); + assert.equal(clamped.text, "only\nlines"); +}); + +test("clampOutput also bounds characters", () => { + const clamped = clampOutput("abcdefghij", { maxLines: 100, maxChars: 4 }); + assert.equal(clamped.truncated, true); + assert.equal(clamped.text, "ghij"); +}); + +test("clampOutput normalises CRLF and strips ANSI before measuring", () => { + const clamped = clampOutput("a\r\n\u001b[31mb\u001b[0m", { maxLines: 2, maxChars: 100 }); + assert.equal(clamped.text, "a\nb"); +}); + +test("firstLine trims and stops at the newline", () => { + assert.equal(firstLine(" hello \nworld"), "hello"); + assert.equal(firstLine("only"), "only"); +}); + +test("escapeHtml neutralises markup", () => { + assert.equal(escapeHtml('&\''), "<a href="x">&'</a>"); +}); + +test("splitList trims, drops blanks and tolerates undefined", () => { + assert.deepEqual(splitList("mcpp, cmake ,,xmake"), ["mcpp", "cmake", "xmake"]); + assert.deepEqual(splitList(undefined), []); + assert.deepEqual(splitList(""), []); +}); + +test("formatArguments quotes entries containing whitespace", () => { + assert.equal(formatArguments(["-j", "4"]), "-j 4"); + assert.equal(formatArguments(["--flag=a b"]), '"--flag=a b"'); + assert.equal(formatArguments([]), ""); +}); diff --git a/tools/check-config.mjs b/tools/check-config.mjs new file mode 100644 index 0000000..4134454 --- /dev/null +++ b/tools/check-config.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * The registry is the single source of truth for settings; `package.json` is + * hand-written but must agree with it. This is the gate. + * + * Checks: + * registry – version present, groups unique, keys unique and prefixed, + * every `group` declared, `order` ascending within a group, + * enums non-empty and containing the default, numeric bounds + * consistent with the default, arrays hold strings; + * package – exactly the registry's keys under `contributes.configuration`, + * each with the same type/default/enum/scope and the `%…%` + * title/description the registry names; + * nls – `.title` and `.description` exist, and deprecated + * entries also carry `.deprecationMessage`. + * + * Exit 1 lists every mismatch; there is no "fix it for me" mode on purpose – + * rewriting package.json would produce an unreviewable diff. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const problems = []; + +const read = (relative) => { + const file = path.join(root, relative); + if (!fs.existsSync(file)) { + problems.push(`${relative}: missing`); + return undefined; + } + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + problems.push(`${relative}: invalid JSON (${error.message})`); + return undefined; + } +}; + +const registry = read("data/config-registry.json"); +const manifest = read("package.json"); +const nlsEn = read("package.nls.json") ?? {}; + +if (registry === undefined || manifest === undefined) { + for (const problem of problems) console.error(`error: ${problem}`); + process.exit(1); +} + +const groups = registry.groups ?? []; +const settings = registry.settings ?? []; +const groupIds = new Set(); +for (const group of groups) { + if (typeof group.id !== "string" || group.id.length === 0) problems.push("registry: a group has no id"); + if (groupIds.has(group.id)) problems.push(`registry: duplicate group ${group.id}`); + groupIds.add(group.id); + if (typeof group.title !== "string" || group.title.length === 0) problems.push(`registry: group ${group.id} has no title`); + if (typeof group.order !== "number") problems.push(`registry: group ${group.id} has no numeric order`); +} + +const seen = new Map(); +const orderInGroup = new Map(); +for (const entry of settings) { + const where = entry.key ?? ""; + if (typeof entry.key !== "string" || !entry.key.startsWith("mcpp.")) { + problems.push(`registry: ${where} is not a mcpp.* key`); + continue; + } + if (seen.has(entry.key)) problems.push(`registry: duplicate key ${entry.key}`); + seen.set(entry.key, entry); + if (!groupIds.has(entry.group)) problems.push(`registry: ${where} names undeclared group ${entry.group}`); + const previous = orderInGroup.get(entry.group); + if (previous !== undefined && !(entry.order > previous)) { + problems.push(`registry: ${entry.group} order is not ascending at ${where} (${previous} -> ${entry.order})`); + } + orderInGroup.set(entry.group, entry.order); + if (typeof entry.title !== "string" || entry.title.length === 0) problems.push(`registry: ${where} has no title`); + if (typeof entry.description !== "string" || entry.description.length === 0) { + problems.push(`registry: ${where} has no description`); + } + if (!["public", "advanced"].includes(entry.tier)) problems.push(`registry: ${where} has tier ${entry.tier}`); + if (!["immediate", "next-build", "next-clean", "view-reload"].includes(entry.applies)) { + problems.push(`registry: ${where} has applies ${entry.applies}`); + } + if (!["resource", "window"].includes(entry.scope)) problems.push(`registry: ${where} has scope ${entry.scope}`); + if (entry.enum !== undefined) { + if (!Array.isArray(entry.enum) || entry.enum.length === 0) problems.push(`registry: ${where} has an empty enum`); + else if (!entry.enum.includes(entry.default)) problems.push(`registry: ${where} default is not in its enum`); + } + const declaredType = entry.type === "array" ? "array" : entry.type; + const actualType = Array.isArray(entry.default) ? "array" : typeof entry.default; + if (declaredType !== actualType) { + problems.push(`registry: ${where} type ${entry.type} does not match default ${JSON.stringify(entry.default)}`); + } + if (entry.type === "number") { + if (entry.minimum !== undefined && entry.default < entry.minimum) problems.push(`registry: ${where} default < minimum`); + if (entry.maximum !== undefined && entry.default > entry.maximum) problems.push(`registry: ${where} default > maximum`); + } +} + +// ------------------------------------------------------------------ package +const properties = manifest.contributes?.configuration?.properties ?? {}; +const packageKeys = Object.keys(properties).filter((key) => key.startsWith("mcpp.")); +for (const key of packageKeys) { + if (!seen.has(key)) problems.push(`package.json: ${key} is not in the registry`); +} +for (const [key, entry] of seen) { + const property = properties[key]; + if (property === undefined) { + problems.push(`package.json: ${key} is missing from contributes.configuration`); + continue; + } + if (property.type !== entry.type) problems.push(`package.json: ${key} type ${property.type} != registry ${entry.type}`); + if (JSON.stringify(property.default) !== JSON.stringify(entry.default)) { + problems.push(`package.json: ${key} default ${JSON.stringify(property.default)} != registry ${JSON.stringify(entry.default)}`); + } + if (entry.enum !== undefined && JSON.stringify(property.enum) !== JSON.stringify(entry.enum)) { + problems.push(`package.json: ${key} enum differs from the registry`); + } + if (property.scope !== undefined && property.scope !== entry.scope) { + problems.push(`package.json: ${key} scope ${property.scope} != registry ${entry.scope}`); + } + if (property.minimum !== undefined && property.minimum !== entry.minimum) { + problems.push(`package.json: ${key} minimum differs from the registry`); + } + if (property.maximum !== undefined && property.maximum !== entry.maximum) { + problems.push(`package.json: ${key} maximum differs from the registry`); + } + if (property.description !== `%${key}.title%`) { + problems.push(`package.json: ${key} description must be %${key}.title%`); + } + if (property.markdownDescription !== `%${key}.description%`) { + problems.push(`package.json: ${key} markdownDescription must be %${key}.description%`); + } + if (entry.deprecated === true && property.deprecationMessage !== `%${key}.deprecationMessage%`) { + problems.push(`package.json: ${key} is deprecated but has no deprecationMessage reference`); + } +} + +// ---------------------------------------------------------------------- nls +for (const [key, entry] of seen) { + for (const suffix of ["title", "description"]) { + if (typeof nlsEn[`${key}.${suffix}`] !== "string") problems.push(`package.nls.json: ${key}.${suffix} is missing`); + } + if (nlsEn[`${key}.title`] !== entry.title) problems.push(`package.nls.json: ${key}.title differs from the registry title`); + if (nlsEn[`${key}.description`] !== entry.description) { + problems.push(`package.nls.json: ${key}.description differs from the registry description`); + } + if (entry.deprecated === true && typeof nlsEn[`${key}.deprecationMessage`] !== "string") { + problems.push(`package.nls.json: ${key}.deprecationMessage is missing`); + } +} + +if (problems.length > 0) { + for (const problem of problems) console.error(`error: ${problem}`); + console.error(`check-config: ${problems.length} problem(s)`); + process.exit(1); +} +console.log( + `check-config: ok (${groups.length} groups, ${settings.length} settings, ` + + `${settings.filter((entry) => entry.tier === "public").length} public)`, +); diff --git a/tools/generate-l10n.mjs b/tools/generate-l10n.mjs new file mode 100644 index 0000000..cef735d --- /dev/null +++ b/tools/generate-l10n.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * `data/i18n/.json` -> `l10n/bundle.l10n..json`. + * + * The runtime bundle is what `vscode.l10n` reads, so it must be generated rather + * than hand-maintained: `data/i18n` is the single source of truth and + * `tools/l10n-check.mjs` holds the source strings to it. + * + * The English bundle is intentionally empty: English is the key, so a missing + * translation already degrades to English without a lookup table. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceDir = path.join(root, "data", "i18n"); +const targetDir = path.join(root, "l10n"); + +function readJson(file, fallback) { + if (!fs.existsSync(file)) return fallback; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function assertStringMap(file, value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${file}: expected a JSON object of string -> string`); + } + for (const [key, text] of Object.entries(value)) { + if (typeof text !== "string" || text.length === 0) { + throw new Error(`${file}: key ${JSON.stringify(key)} does not map to a non-empty string`); + } + if (key.trim().length === 0) { + throw new Error(`${file}: empty key`); + } + } +} + +fs.mkdirSync(targetDir, { recursive: true }); + +const locales = fs.existsSync(sourceDir) + ? fs.readdirSync(sourceDir).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5)) + : []; + +let written = 0; +for (const locale of locales) { + const file = path.join(sourceDir, `${locale}.json`); + const bundle = readJson(file, {}); + assertStringMap(file, bundle); + const out = path.join(targetDir, `bundle.l10n.${locale}.json`); + fs.writeFileSync(out, `${JSON.stringify(bundle, null, 2)}\n`); + written += 1; +} + +// The default bundle carries no translations: English text is the key. +const defaultBundle = path.join(targetDir, "bundle.l10n.json"); +fs.writeFileSync(defaultBundle, "{}\n"); + +console.log(`generate-l10n: wrote ${written} locale bundle(s) + the default bundle into l10n/`); diff --git a/tools/generate-settings-docs.mjs b/tools/generate-settings-docs.mjs new file mode 100644 index 0000000..75e835e --- /dev/null +++ b/tools/generate-settings-docs.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * `data/config-registry.json` -> `docs/settings.md`. + * + * The user documentation of settings is generated so it cannot drift from the + * manifest: `tools/check-config.mjs` already holds the registry to + * `package.json`, and this holds the docs to the registry. Make targets run + * `npm run gen:docs` then `git diff --exit-code`. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const registry = JSON.parse(fs.readFileSync(path.join(root, "data", "config-registry.json"), "utf8")); + +const APPLIES = { + immediate: "Takes effect immediately", + "next-build": "Takes effect on the next build/run/test", + "next-clean": "Takes effect on the next clean", + "view-reload": "Takes effect after the window reloads", +}; + +const SCOPE = { + resource: "per folder", + window: "per window", +}; + +const groups = [...registry.groups].sort((a, b) => a.order - b.order); +const lines = []; +lines.push("# Settings"); +lines.push(""); +lines.push(""); +lines.push(""); +lines.push(`${registry.settings.length} settings, in ${groups.length} groups. All of them are read by the`); +lines.push("**mcpp** extension only. `mcppls.*` belongs to the C++ Modules extension and is never"); +lines.push("written by this one; the configuration panel says so on every screen."); +lines.push(""); +lines.push("| | |"); +lines.push("|---|---|"); +lines.push("| Panel | **mcpp: Open Settings Panel** (`mcpp.openSettings`) |"); +lines.push("| Native UI | Settings → Extensions → mcpp, or `@ext:mcpp-community.mcpp-vscode` |"); +lines.push("| Defaults | Every default is deliberately quiet: nothing here changes behaviour until you ask |"); +lines.push(""); + +for (const group of groups) { + const rows = registry.settings + .filter((entry) => entry.group === group.id) + .sort((a, b) => a.order - b.order); + if (rows.length === 0) continue; + lines.push(`## ${group.title}`); + lines.push(""); + for (const entry of rows) { + const badges = []; + if (entry.deprecated) badges.push("**deprecated**"); + if (entry.tier === "advanced") badges.push("advanced"); + lines.push(`### \`${entry.key}\`${badges.length > 0 ? ` — ${badges.join(", ")}` : ""}`); + lines.push(""); + lines.push(entry.description); + lines.push(""); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(`| Type | \`${entry.type}\`${entry.enum ? `: ${entry.enum.map((value) => `\`${value}\``).join(" \\| ")}` : ""} |`); + lines.push(`| Default | \`${JSON.stringify(entry.default)}\` |`); + if (entry.minimum !== undefined || entry.maximum !== undefined) { + lines.push(`| Range | ${entry.minimum ?? "−∞"} … ${entry.maximum ?? "∞"} |`); + } + lines.push(`| Scope | ${SCOPE[entry.scope] ?? entry.scope} |`); + lines.push(`| Applies | ${APPLIES[entry.applies] ?? entry.applies} |`); + lines.push(`| Since | ${entry.since} |`); + if (entry.aliases?.length) { + lines.push(`| Old name | ${entry.aliases.map((alias) => `\`${alias}\``).join(", ")} (still read) |`); + } + if (entry.deprecationMessage) { + lines.push(`| Deprecated | ${entry.deprecationMessage} |`); + } + lines.push(""); + } +} + +lines.push("## Language"); +lines.push(""); +lines.push("`mcpp.ui.language` overrides the language of **runtime messages and this extension's"); +lines.push("panels**. The command palette and the Settings UI always follow the editor's own"); +lines.push("language: VS Code resolves `package.nls.*` once at startup, so a manual override"); +lines.push("cannot reach them. `auto` (the default) follows the editor everywhere."); +lines.push(""); + +const target = path.join(root, "docs", "settings.md"); +fs.mkdirSync(path.dirname(target), { recursive: true }); +fs.writeFileSync(target, `${lines.join("\n")}`); +console.log(`generate-settings-docs: wrote ${path.relative(root, target)} (${registry.settings.length} settings)`); diff --git a/tools/l10n-check.mjs b/tools/l10n-check.mjs new file mode 100644 index 0000000..ba3ef0b --- /dev/null +++ b/tools/l10n-check.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Localization gates. Exits 1 on any of: + * + * 1. a runtime string used through `t("…")` in `src/**` has no entry in + * `data/i18n/zh-cn.json` (a new hardcoded string is also a new *missing* + * translation, which is what stops the drift); + * 2. `package.nls.json` and `package.nls.zh-cn.json` do not have identical key + * sets (a translated key that no longer exists is dead weight, a missing one + * shows English to a Chinese user); + * 3. a `%key%` reference in `package.json` is absent from `package.nls.json`. + * + * Unused translations are reported as warnings only: a string may legitimately + * be waiting for the feature that uses it. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const problems = []; +const warnings = []; + +function readJson(file) { + if (!fs.existsSync(file)) { + problems.push(`${path.relative(root, file)}: missing`); + return undefined; + } + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + problems.push(`${path.relative(root, file)}: invalid JSON (${error.message})`); + return undefined; + } +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (entry.name.endsWith(".ts")) out.push(full); + } + return out; +} + +// ---------------------------------------------------------------- 1. t("…") +const USAGE = /\bt\(\s*"((?:[^"\\]|\\.)*)"/g; +const used = new Map(); +for (const file of walk(path.join(root, "src"))) { + const text = fs.readFileSync(file, "utf8"); + for (const match of text.matchAll(USAGE)) { + const key = match[1].replace(/\\"/g, '"').replace(/\\n/g, "\n"); + if (!used.has(key)) used.set(key, path.relative(root, file)); + } +} + +const zhRuntime = readJson(path.join(root, "data", "i18n", "zh-cn.json")) ?? {}; +for (const [key, where] of used) { + if (!(key in zhRuntime)) { + problems.push(`data/i18n/zh-cn.json: missing translation for ${JSON.stringify(key)} (used in ${where})`); + } +} +for (const key of Object.keys(zhRuntime)) { + if (!used.has(key)) warnings.push(`data/i18n/zh-cn.json: unused string ${JSON.stringify(key)}`); +} + +// ------------------------------------------------- 2. package.nls key sets +const nlsEn = readJson(path.join(root, "package.nls.json")) ?? {}; +const nlsZh = readJson(path.join(root, "package.nls.zh-cn.json")) ?? {}; +const enKeys = new Set(Object.keys(nlsEn)); +const zhKeys = new Set(Object.keys(nlsZh)); +for (const key of enKeys) { + if (!zhKeys.has(key)) problems.push(`package.nls.zh-cn.json: missing key ${JSON.stringify(key)}`); +} +for (const key of zhKeys) { + if (!enKeys.has(key)) problems.push(`package.nls.zh-cn.json: key ${JSON.stringify(key)} is not in package.nls.json`); +} + +// --------------------------------------------- 3. %key% references resolve +const manifest = readJson(path.join(root, "package.json")) ?? {}; +const references = new Set(); +(function scan(value) { + if (typeof value === "string") { + for (const match of value.matchAll(/%([^%]+)%/g)) references.add(match[1]); + } else if (Array.isArray(value)) { + value.forEach(scan); + } else if (value && typeof value === "object") { + Object.values(value).forEach(scan); + } +})(manifest); +for (const key of references) { + if (!enKeys.has(key)) problems.push(`package.json: %${key}% has no entry in package.nls.json`); +} + +for (const warning of warnings) console.warn(`warning: ${warning}`); +if (problems.length > 0) { + for (const problem of problems) console.error(`error: ${problem}`); + console.error(`l10n-check: ${problems.length} problem(s)`); + process.exit(1); +} +console.log(`l10n-check: ok (${used.size} runtime string(s), ${enKeys.size} manifest key(s))`); diff --git a/tsconfig.json b/tsconfig.json index f28e625..e53007e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "outDir": "dist", "strict": true, "esModuleInterop": true, + "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "sourceMap": true From 73d7dbd8fb4e155359cea8312706de4a52bf9b81 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:36:32 +0800 Subject: [PATCH 03/56] =?UTF-8?q?feat(mcppls,cli):=20=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B/=E9=99=8D=E7=BA=A7=E3=80=81C++=20Modules=20?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E9=80=82=E9=85=8D=E5=B1=82=E3=80=81mcpp=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E4=B8=8E=E9=94=99=E8=AF=AF=E5=88=86=E5=B1=82?= =?UTF-8?q?=E3=80=81=E7=BC=93=E5=AD=98=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcppls(方案 §2、§3.9) - src/mcppls/contract.ts:能力表(15 项),全部 required=false;不安全操作带 danger 与 confirmHint;契约自带断言(不得 required、forward 必须有候选命令、本扩展不得用 mcppls. 前缀) - src/mcppls/capabilities.ts:静态声明 + 惰性运行期分类。UI 命令不做探针调用(会弹窗); 记忆 missing 后不再打扰 VS Code;refresh 走候选链,上游哪天注册轻量重载即自动优先 - src/mcppls/state.ts:读取 mcppls 的 lastStatus() 并归一化。非契约通道,因此形状探测 + try/catch + state 白名单;未知形状只降级不报错;透出 S3 自带的 issue 修复命令 - src/mcppls/bridge.ts:结构化返回(不再拼文案),保留单飞刷新;新增 10 个转发命令 - src/mcppls/messages.ts:文案与逻辑分离,全部走 i18n cli - src/cli/protocol.ts:信封解析(解析 stdout,不看退出码)、协议能力探测、readData - src/cli/errors.ts:SPEC-003 错误分层(含 101 仅对 mcpp run、4 未就绪、127 未知命令)与 MCPP_* 诊断码提取 - src/cli/cache.ts:mcpp.cache 信封解析与聚合(按类型/标签/年龄分桶,incomplete,TopN) - src/cli/artifacts.ts:target/ 体积估算(不跟随符号链接,带条目/深度预算) 验证:npm test 294 通过;protocol.ts 已对真实 mcpp 2026.9.30.2 交叉验证 (--protocol-version / toolchain list --format json / 退出码 2 与 127) --- data/i18n/zh-cn.json | 11 +- l10n/bundle.l10n.zh-cn.json | 11 +- src/cli/artifacts.ts | 182 +++++++++++++++++++ src/cli/cache.ts | 293 +++++++++++++++++++++++++++++++ src/cli/errors.ts | 156 ++++++++++++++++ src/cli/protocol.ts | 259 +++++++++++++++++++++++++++ src/extension.ts | 42 ++++- src/mcppls/bridge.ts | 134 ++++++++------ src/mcppls/capabilities.ts | 192 ++++++++++++++++++++ src/mcppls/contract.ts | 240 +++++++++++++++++++++++++ src/mcppls/messages.ts | 61 +++++++ src/mcppls/state.ts | 229 ++++++++++++++++++++++++ test/cli/artifacts.test.ts | 99 +++++++++++ test/cli/cache.test.ts | 264 ++++++++++++++++++++++++++++ test/cli/errors.test.ts | 105 +++++++++++ test/cli/protocol.test.ts | 192 ++++++++++++++++++++ test/mcppls/bridge.test.ts | 149 ++++++++++------ test/mcppls/capabilities.test.ts | 155 ++++++++++++++++ test/mcppls/contract.test.ts | 57 ++++++ test/mcppls/state.test.ts | 140 +++++++++++++++ 20 files changed, 2856 insertions(+), 115 deletions(-) create mode 100644 src/cli/artifacts.ts create mode 100644 src/cli/cache.ts create mode 100644 src/cli/errors.ts create mode 100644 src/cli/protocol.ts create mode 100644 src/mcppls/capabilities.ts create mode 100644 src/mcppls/contract.ts create mode 100644 src/mcppls/messages.ts create mode 100644 src/mcppls/state.ts create mode 100644 test/cli/artifacts.test.ts create mode 100644 test/cli/cache.test.ts create mode 100644 test/cli/errors.test.ts create mode 100644 test/cli/protocol.test.ts create mode 100644 test/mcppls/capabilities.test.ts create mode 100644 test/mcppls/contract.test.ts create mode 100644 test/mcppls/state.test.ts diff --git a/data/i18n/zh-cn.json b/data/i18n/zh-cn.json index 0967ef4..b4390d9 100644 --- a/data/i18n/zh-cn.json +++ b/data/i18n/zh-cn.json @@ -1 +1,10 @@ -{} +{ + "{0}: done.": "{0}:完成。", + "The C++ Modules extension ({0}) is not installed or is disabled.": "C++ Modules 扩展({0})未安装或已禁用。", + "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。", + "{0} failed: {1}": "{0} 失败:{1}", + "unknown error": "未知错误", + "Direction: enable.": "方向:启用。", + "Direction: disable.": "方向:停用。", + "Install extension": "安装扩展" +} diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 0967ef4..b4390d9 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -1 +1,10 @@ -{} +{ + "{0}: done.": "{0}:完成。", + "The C++ Modules extension ({0}) is not installed or is disabled.": "C++ Modules 扩展({0})未安装或已禁用。", + "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。", + "{0} failed: {1}": "{0} 失败:{1}", + "unknown error": "未知错误", + "Direction: enable.": "方向:启用。", + "Direction: disable.": "方向:停用。", + "Install extension": "安装扩展" +} diff --git a/src/cli/artifacts.ts b/src/cli/artifacts.ts new file mode 100644 index 0000000..2092736 --- /dev/null +++ b/src/cli/artifacts.ts @@ -0,0 +1,182 @@ +/** + * 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 { + const root = path.join(projectRoot, "target"); + 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/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/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/extension.ts b/src/extension.ts index 4afcefe..ff04714 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,8 @@ import { type LanguageServerBridge, type LanguageServerCommandResult, } from "./mcppls/bridge"; +import { MCPPLS_EXTENSION_ID } from "./mcppls/contract"; +import { formatResult } from "./mcppls/messages"; import { computeMcppTomlCompletions } from "./toml/completion"; import { buildModuleSetupPlan, @@ -19,7 +21,24 @@ import { } from "./workflows/moduleSetup"; import type { TaskCompletion } from "./cli/tasks"; import { onDidChange as onConfigurationChanged, read } from "./config/access"; -import { setLanguagePreference, type LanguagePreference } from "./i18n/t"; +import { setLanguagePreference, t, type LanguagePreference } from "./i18n/t"; + +/** + * 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"); +} /** `mcpp.ui.language` decides which of our strings the user sees. */ function applyLanguagePreference(): void { @@ -59,15 +78,21 @@ 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); } } @@ -141,7 +166,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, }; }, }); @@ -206,6 +231,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const bridge = createLanguageServerBridge({ extensionInstalled: (id) => vscode.extensions.getExtension(id) !== undefined, + declaredCommands: declaredCommandsOf, activateExtension: async (id) => { await vscode.extensions.getExtension(id)?.activate(); }, diff --git a/src/mcppls/bridge.ts b/src/mcppls/bridge.ts index 509cde8..55a7738 100644 --- a/src/mcppls/bridge.ts +++ b/src/mcppls/bridge.ts @@ -1,80 +1,106 @@ -export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; +/** + * 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. + */ -export const MCPPLS_COMMANDS = { - restart: "mcppls.restartServer", - selectContext: "mcppls.selectContext", - graph: "mcppls.showModuleGraph", - logs: "mcppls.showLogs", -} as const; - -export type LanguageServerCommandState = "completed" | "unavailable" | "failed"; +import { CapabilityRegistry, type CapabilityEnvironment, type InvokeResult } from "./capabilities"; +import { capability } from "./contract"; -export interface LanguageServerCommandResult { - state: LanguageServerCommandState; - message: string; -} +export { MCPPLS_EXTENSION_ID } from "./contract"; -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 type LanguageServerCommandState = InvokeResult["state"]; +export type LanguageServerCommandResult = InvokeResult; +export { CapabilityRegistry } from "./capabilities"; +export type { CapabilityEnvironment } from "./capabilities"; export interface LanguageServerBridge { - restartLanguageServer(): Promise; + /** 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; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} +/** 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( - executor: LanguageServerCommandExecutor, -): LanguageServerBridge { +export function createLanguageServerBridge(environment: CapabilityEnvironment): LanguageServerBridge { + const capabilities = new CapabilityRegistry(environment); 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++ 模块语言服务已重启。"); - } + 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(MCPPLS_COMMANDS.restart, "C++ 模块语言服务已刷新。") - .finally(() => { refreshInFlight = undefined; }); + 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 { - restartLanguageServer, + capabilities, + invoke, + isGone: (key) => capabilities.isGone(key), + isUnconfirmed: (key) => capabilities.isUnconfirmed(key), + dangerOf: (key) => capability(key)?.danger ?? "none", refreshLanguageServerAfterBuild, - selectContext: () => invoke(MCPPLS_COMMANDS.selectContext, "已打开 C++ 模块上下文选择。"), - showModuleGraph: () => invoke(MCPPLS_COMMANDS.graph, "已打开 C++ 模块图。"), - showLanguageServerLogs: () => invoke(MCPPLS_COMMANDS.logs, "已打开 C++ Modules 日志。"), + ...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..a4c37b4 --- /dev/null +++ b/src/mcppls/capabilities.ts @@ -0,0 +1,192 @@ +/** + * 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; +} + +/** `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); + await this.environment.executeCommand(candidate, ...args); + this.statuses.set(key, { key, state: "available", command: candidate }); + return { state: "completed", capabilityKey: key, command: candidate }; + } 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 { + await this.environment.executeCommand(next, ...args); + this.statuses.set(key, { key, state: "available", command: next }); + return { state: "completed", capabilityKey: key, command: next }; + } 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..e67d87f --- /dev/null +++ b/src/mcppls/contract.ts @@ -0,0 +1,240 @@ +/** + * 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. + */ +export const VERIFIED_MCPPLS_RANGE = ">=0.0.4"; + +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: "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/test/cli/artifacts.test.ts b/test/cli/artifacts.test.ts new file mode 100644 index 0000000..e44cfa4 --- /dev/null +++ b/test/cli/artifacts.test.ts @@ -0,0 +1,99 @@ +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 { estimateArtifacts, formatArtifactEstimate } from "../../src/cli/artifacts"; + +/** Creates a throwaway workspace; `onTest` cleans it up. */ +function workspace(onTest: { after(callback: () => void): void }): string { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-artifacts-")); + onTest.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} + +test("估算 target/ 的体积并按顶层目录分组", (t) => { + const root = workspace(t); + const target = path.join(root, "target"); + mkdirSync(path.join(target, "a", "nested"), { recursive: true }); + mkdirSync(path.join(target, "b"), { recursive: true }); + writeFileSync(path.join(target, "a", "fileA.bin"), Buffer.alloc(100)); + writeFileSync(path.join(target, "a", "nested", "deep.bin"), Buffer.alloc(50)); + writeFileSync(path.join(target, "b", "fileB.bin"), Buffer.alloc(200)); + writeFileSync(path.join(target, "loose.bin"), Buffer.alloc(10)); + // A symlink back to its own ancestor: followed, this would never terminate. + symlinkSync(path.join(target, "a"), path.join(target, "a", "loop"), "dir"); + + const estimate = estimateArtifacts(root); + + assert.equal(estimate.path, target); + assert.equal(estimate.exists, true); + // 100 + 50 + 200 + 10 bytes; the symlink contributes 0. + assert.equal(estimate.totalBytes, 360); + // fileA, deep.bin, fileB, loose.bin, plus the symlink counted as an entry. + assert.equal(estimate.files, 5); + assert.deepEqual(estimate.byTopLevel, [ + { name: "b", bytes: 200, files: 1 }, + { name: "a", bytes: 150, files: 3 }, + ]); + assert.equal(estimate.truncated, undefined); + assert.equal(formatArtifactEstimate(estimate), "360 B in 2 groups"); +}); + +test("空的 target/ 是 0 字节和 0 组", (t) => { + const root = workspace(t); + mkdirSync(path.join(root, "target"), { recursive: true }); + + const estimate = estimateArtifacts(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 0); + assert.equal(estimate.files, 0); + assert.deepEqual(estimate.byTopLevel, []); + // `formatBytes` keeps three significant digits, so an exact zero renders as "0.00 B". + assert.equal(formatArtifactEstimate(estimate), "0.00 B in 0 groups"); +}); + +test("缺少 target/ 时给出零估算且不抛错", (t) => { + const root = workspace(t); + const estimate = estimateArtifacts(path.join(root, "does-not-exist")); + + assert.deepEqual(estimate, { + path: path.join(root, "does-not-exist", "target"), + exists: false, + totalBytes: 0, + files: 0, + byTopLevel: [], + }); + assert.equal(formatArtifactEstimate(estimate), "no target/ directory"); +}); + +test("条目预算用尽时标记 truncated 为 entries", (t) => { + const root = workspace(t); + const target = path.join(root, "target"); + mkdirSync(target, { recursive: true }); + for (let index = 0; index < 10; index += 1) { + writeFileSync(path.join(target, `f${index}.bin`), Buffer.alloc(100)); + } + + const estimate = estimateArtifacts(root, { maxEntries: 3 }); + assert.equal(estimate.exists, true); + assert.equal(estimate.truncated, "entries"); + assert.equal(estimate.files, 3); + assert.ok(estimate.totalBytes > 0 && estimate.totalBytes <= 1000); + assert.match(formatArtifactEstimate(estimate), /truncated: entries/); +}); + +test("深度预算用尽时标记 truncated 为 depth", (t) => { + const root = workspace(t); + const deep = path.join(root, "target", "a", "b", "c"); + mkdirSync(deep, { recursive: true }); + writeFileSync(path.join(deep, "x.bin"), Buffer.alloc(7)); + + const estimate = estimateArtifacts(root, { maxDepth: 2 }); + assert.equal(estimate.exists, true); + assert.equal(estimate.truncated, "depth"); + assert.equal(estimate.totalBytes, 0); + assert.equal(estimate.files, 0); + assert.match(formatArtifactEstimate(estimate), /truncated: depth/); +}); diff --git a/test/cli/cache.test.ts b/test/cli/cache.test.ts new file mode 100644 index 0000000..bfb96cf --- /dev/null +++ b/test/cli/cache.test.ts @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseCacheDir, parseCacheList, summarizeCache, type CacheEntry } from "../../src/cli/cache"; + +const CACHE_ROOT = "/home/u/.mcpp/build-cache/v1"; +const DAY = 86_400; + +/** Truncated "now", like the capture: `accessed` is Unix seconds. */ +const nowSeconds = Math.floor(Date.now() / 1000); + +interface RawEntry { + accessed?: number; + bytes: unknown; + complete?: unknown; + dir?: unknown; + files?: unknown; + key?: unknown; + kind?: unknown; + label?: unknown; +} + +/** A real `mcpp cache list --format json` envelope around the given entries. */ +function envelope(entries: unknown[]): string { + return JSON.stringify({ + schemaVersion: 1, + kind: "mcpp.cache", + kindVersion: 1, + mcpp: { version: "2026.9.30.2", protocol: { min: 1, max: 1 } }, + data: { root: CACHE_ROOT, entries }, + diagnostics: [], + effects: [], + }); +} + +const realistic: RawEntry[] = [ + { + accessed: nowSeconds - 2 * 3600, + bytes: 24257, + complete: true, + dir: `${CACHE_ROOT}/pkg/ns/name@1.0.0/c0d9ff40c3354f15`, + files: 7, + key: "c0d9ff40c3354f15", + kind: "pkg", + label: "ns/name@1.0.0", + }, + { + accessed: nowSeconds - 3 * DAY, + bytes: 1000, + complete: false, + dir: `${CACHE_ROOT}/pkg/ns/name@1.0.0/1f7ab3c2d4e5f607`, + files: 4, + key: "1f7ab3c2d4e5f607", + kind: "pkg", + label: "ns/name@1.0.0", + }, + { + accessed: nowSeconds - 10 * DAY, + bytes: 5000, + complete: true, + dir: `${CACHE_ROOT}/pkg/ns/other@2.0.0/aa11bb22cc33dd44`, + files: 3, + key: "aa11bb22cc33dd44", + kind: "pkg", + label: "ns/other@2.0.0", + }, + { + accessed: nowSeconds - 40 * DAY, + bytes: 8000, + complete: true, + dir: `${CACHE_ROOT}/std/std.pcm/9988776655443322`, + files: 2, + key: "9988776655443322", + kind: "std", + label: "std", + }, +]; + +test("解析并聚合两类缓存的真实清单", () => { + const parsed = parseCacheList(envelope(realistic)); + assert.ok(parsed); + assert.equal(parsed.root, CACHE_ROOT); + assert.equal(parsed.entries.length, 4); + assert.deepEqual(parsed.entries[0], { + accessed: nowSeconds - 2 * 3600, + bytes: 24257, + complete: true, + dir: `${CACHE_ROOT}/pkg/ns/name@1.0.0/c0d9ff40c3354f15`, + files: 7, + key: "c0d9ff40c3354f15", + kind: "pkg", + label: "ns/name@1.0.0", + }); + + const inventory = summarizeCache(parsed.root, parsed.entries, { + topN: 2, + ageBoundaries: ["1d", "7d", "30d"], + }); + + assert.equal(inventory.root, CACHE_ROOT); + assert.equal(inventory.totalEntries, 4); + assert.equal(inventory.totalBytes, 38_257); + + // pkg is larger than std, so it must come first. + assert.deepEqual(inventory.byKind, [ + { kind: "pkg", entries: 3, bytes: 30_257 }, + { kind: "std", entries: 1, bytes: 8_000 }, + ]); + + assert.deepEqual(inventory.topLabels, [ + { label: "ns/name@1.0.0", entries: 2, bytes: 25_257, oldestAccessed: nowSeconds - 3 * DAY }, + { label: "std", entries: 1, bytes: 8_000, oldestAccessed: nowSeconds - 40 * DAY }, + ]); + + assert.equal(inventory.incomplete.length, 1); + assert.equal(inventory.incomplete[0]?.key, "1f7ab3c2d4e5f607"); + assert.equal(inventory.oldestAccessed, nowSeconds - 40 * DAY); + assert.equal(inventory.newestAccessed, nowSeconds - 2 * 3600); + + // Exactly one entry per bucket against the fixed 1/7/30 day boundaries. + assert.deepEqual(inventory.ageBuckets, [ + { fromDays: 0, toDays: 1, entries: 1, bytes: 24_257 }, + { fromDays: 1, toDays: 7, entries: 1, bytes: 1_000 }, + { fromDays: 7, toDays: 30, entries: 1, bytes: 5_000 }, + { fromDays: 30, entries: 1, bytes: 8_000 }, + ]); +}); + +test("topLabels 默认取前 5 且按体积降序", () => { + const many: CacheEntry[] = Array.from({ length: 7 }, (_unused, index) => ({ + bytes: (index + 1) * 100, + complete: true, + dir: `${CACHE_ROOT}/pkg/pkg${index}`, + key: `key${index}`, + kind: "pkg", + label: `pkg${index}`, + })); + + const inventory = summarizeCache(CACHE_ROOT, many); + assert.equal(inventory.topLabels.length, 5); + assert.deepEqual( + inventory.topLabels.map((entry) => entry.label), + ["pkg6", "pkg5", "pkg4", "pkg3", "pkg2"], + ); + assert.equal(inventory.topLabels[0]?.bytes, 700); + assert.equal(inventory.topLabels[0]?.oldestAccessed, undefined); + assert.equal(inventory.oldestAccessed, undefined); + assert.equal(inventory.newestAccessed, undefined); +}); + +test("空清单返回全零统计", () => { + const inventory = summarizeCache("/nowhere", []); + assert.equal(inventory.totalEntries, 0); + assert.equal(inventory.totalBytes, 0); + assert.deepEqual(inventory.byKind, []); + assert.deepEqual(inventory.topLabels, []); + assert.deepEqual(inventory.incomplete, []); + assert.equal(inventory.oldestAccessed, undefined); + assert.equal(inventory.newestAccessed, undefined); + assert.deepEqual( + inventory.ageBuckets.map((bucket) => [bucket.fromDays, bucket.toDays, bucket.entries, bucket.bytes]), + [ + [0, 1, 0, 0], + [1, 7, 0, 0], + [7, 30, 0, 0], + [30, undefined, 0, 0], + ], + ); +}); + +test("拒绝非 mcpp.cache 信封,坏条目不抛错", () => { + // Not that document at all. + assert.equal(parseCacheList(""), undefined); + assert.equal(parseCacheList("not json at all"), undefined); + assert.equal(parseCacheList("null"), undefined); + assert.equal(parseCacheList("[]"), undefined); + assert.equal(parseCacheList(JSON.stringify({ kind: "mcpp.toolchain", data: { entries: [] } })), undefined); + assert.equal(parseCacheList(JSON.stringify({ kind: "mcpp.cache" })), undefined); + assert.equal(parseCacheList(JSON.stringify({ kind: "mcpp.cache", data: {} })), undefined); + assert.equal(parseCacheList(JSON.stringify({ kind: "mcpp.cache", data: { entries: "nope" } })), undefined); + + // The right document with an empty list is valid, not an error. + const empty = parseCacheList(envelope([])); + assert.ok(empty); + assert.equal(empty.root, CACHE_ROOT); + assert.deepEqual(empty.entries, []); + + // Malformed entries are dropped, negative bytes are clamped to 0. + const mixed = parseCacheList( + envelope([ + { accessed: nowSeconds, bytes: -5, complete: true, dir: `${CACHE_ROOT}/neg`, key: "neg", kind: "pkg", label: "neg" }, + { bytes: 10, complete: true, dir: `${CACHE_ROOT}/no-label`, key: "no-label", kind: "pkg" }, + { bytes: "12", complete: true, dir: `${CACHE_ROOT}/string`, key: "string", kind: "pkg", label: "string" }, + { bytes: 10, complete: true, key: "no-dir", kind: "pkg", label: "no-dir" }, + { bytes: 10, complete: true, dir: `${CACHE_ROOT}/no-key`, kind: "pkg", label: "no-key" }, + { bytes: 10, complete: true, dir: `${CACHE_ROOT}/blank-label`, key: "blank", kind: "pkg", label: "" }, + "garbage", + null, + ]), + ); + assert.ok(mixed); + assert.equal(mixed.entries.length, 1); + assert.deepEqual(mixed.entries[0], { + accessed: nowSeconds, + bytes: 0, + complete: true, + dir: `${CACHE_ROOT}/neg`, + key: "neg", + kind: "pkg", + label: "neg", + }); + + const summary = summarizeCache(mixed.root, mixed.entries); + assert.equal(summary.totalEntries, 1); + assert.equal(summary.totalBytes, 0); + assert.deepEqual(summary.incomplete, []); + assert.deepEqual(summary.ageBuckets.map((bucket) => bucket.entries), [1, 0, 0, 0]); +}); + +test("条目缺少 kind 时归入 unknown 而不是丢弃", () => { + const parsed = parseCacheList(envelope([{ bytes: 7, dir: `${CACHE_ROOT}/x`, key: "x", label: "x" }])); + assert.ok(parsed); + assert.equal(parsed.entries[0]?.kind, "unknown"); + // `complete` is absent, which is treated as incomplete on purpose. + assert.equal(parsed.entries[0]?.complete, false); + const summary = summarizeCache(parsed.root, parsed.entries); + assert.deepEqual(summary.byKind, [{ kind: "unknown", entries: 1, bytes: 7 }]); + assert.equal(summary.incomplete.length, 1); +}); + +test("解析 cache dir 的两行输出", () => { + const output = [ + "/home/u/.mcpp/build-cache/v1", + "legacy (unused, removable with `mcpp cache clean --legacy`): /home/u/.mcpp/bmi", + "", + ].join("\n"); + assert.deepEqual(parseCacheDir(output), { + root: "/home/u/.mcpp/build-cache/v1", + legacyPath: "/home/u/.mcpp/bmi", + }); +}); + +test("cache dir 没有 legacy 行时只给根目录", () => { + assert.deepEqual(parseCacheDir("/home/u/.mcpp/build-cache/v1\n"), { + root: "/home/u/.mcpp/build-cache/v1", + }); + assert.deepEqual(parseCacheDir("\n /srv/mcpp cache/v1 \n"), { root: "/srv/mcpp cache/v1" }); + assert.deepEqual(parseCacheDir(""), {}); + assert.deepEqual(parseCacheDir("\n\n"), {}); +}); + +test("legacy 行中的反引号路径不会被当作 legacy 路径", () => { + const parsed = parseCacheDir([ + "/home/u/.mcpp/build-cache/v1", + "legacy (unused, removable with `mcpp cache clean --legacy`): /home/u/.mcpp/bmi", + ].join("\n")); + assert.equal(parsed.legacyPath, "/home/u/.mcpp/bmi"); + assert.notEqual(parsed.legacyPath, "mcpp cache clean --legacy"); + + // A legacy line without a path contributes nothing. + assert.deepEqual(parseCacheDir("/root/cache\nlegacy (unused, removable)\n"), { + root: "/root/cache", + }); +}); diff --git a/test/cli/errors.test.ts b/test/cli/errors.test.ts new file mode 100644 index 0000000..06228aa --- /dev/null +++ b/test/cli/errors.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyExit, + diagnosticCodeIn, + explainHint, + hasUsableOutput, +} from "../../src/cli/errors"; + +test("退出码契约表 0/1/2/4/70/127", () => { + assert.deepEqual(classifyExit(0), { kind: "none", exitCode: 0 }); + assert.deepEqual(classifyExit(1), { kind: "failed", exitCode: 1 }); + assert.deepEqual(classifyExit(2), { kind: "usage", exitCode: 2 }); + assert.deepEqual(classifyExit(4), { kind: "environment", exitCode: 4 }); + assert.deepEqual(classifyExit(70), { kind: "internal", exitCode: 70 }); + assert.deepEqual(classifyExit(127), { kind: "unknown-command", exitCode: 127 }); + assert.deepEqual(classifyExit(127, ["frobnicate"]), { kind: "unknown-command", exitCode: 127 }); + assert.equal(classifyExit(1).detail, undefined); + assert.equal(classifyExit(1).diagnosticCode, undefined); +}); + +test("101 只对 mcpp run 是构建失败", () => { + assert.deepEqual(classifyExit(101, ["run"]), { kind: "build-failed", exitCode: 101 }); + assert.deepEqual(classifyExit(101, ["run", "--", "arg"]), { kind: "build-failed", exitCode: 101 }); + assert.deepEqual(classifyExit(101, ["build"]), { kind: "failed", exitCode: 101 }); + assert.deepEqual(classifyExit(101, ["emit", "build-database"]), { kind: "failed", exitCode: 101 }); + assert.deepEqual(classifyExit(101, ["toolchain", "list"]), { kind: "failed", exitCode: 101 }); + assert.deepEqual(classifyExit(101), { kind: "failed", exitCode: 101 }); +}); + +test("没有退出码(spawn 失败或取消)是 cancelled", () => { + assert.deepEqual(classifyExit(undefined), { kind: "cancelled", exitCode: -1 }); + assert.deepEqual(classifyExit(undefined, ["build"]), { kind: "cancelled", exitCode: -1 }); + assert.deepEqual(classifyExit(-1), { kind: "cancelled", exitCode: -1 }); + assert.deepEqual(classifyExit(-15), { kind: "cancelled", exitCode: -1 }); +}); + +test("契约之外的退出码回退为 failed", () => { + // `mcpp run` 透传被运行程序的退出码(0–124) + assert.deepEqual(classifyExit(3, ["run"]), { kind: "failed", exitCode: 3 }); + assert.deepEqual(classifyExit(124, ["run"]), { kind: "failed", exitCode: 124 }); + assert.deepEqual(classifyExit(130), { kind: "failed", exitCode: 130 }); +}); + +test("diagnosticCodeIn 只认大写的 MCPP_ 错误码", () => { + const line = "error: cannot download the toolchain [MCPP_OFFLINE_DOWNLOAD_REQUIRED]"; + assert.equal(diagnosticCodeIn(line), "MCPP_OFFLINE_DOWNLOAD_REQUIRED"); + assert.equal( + diagnosticCodeIn("note: MCPP_CACHE_STALE; run mcpp cache gc"), + "MCPP_CACHE_STALE", + ); + + assert.equal(diagnosticCodeIn("error: mcpp_offline_download_required"), undefined); + assert.equal(diagnosticCodeIn("the word offline is not a code"), undefined); + assert.equal(diagnosticCodeIn("MCPP_ alone is not a code"), undefined); + assert.equal(diagnosticCodeIn("MCPP_lower is not a code"), undefined); + assert.equal(diagnosticCodeIn("XMCPP_FAKE is not a code"), undefined); + assert.equal(diagnosticCodeIn(""), undefined); +}); + +test("hasUsableOutput 抢救 emit build-database 的部分失败输出", () => { + const envelope = JSON.stringify({ + schemaVersion: 1, + kind: "mcpp.build-database", + kindVersion: 1, + mcpp: { version: "2026.9.30.2", protocol: { min: 1, max: 1 } }, + data: { watch: [], records: [] }, + diagnostics: [ + { code: "MCPP_DEP_RESOLVE_FAILED", severity: "error", message: "1 of 6 records left out" }, + ], + effects: [], + }); + + const partial = classifyExit(1, ["emit", "build-database"]); + assert.equal(hasUsableOutput(partial, envelope), true); + assert.equal(hasUsableOutput(partial, `${envelope}\n`), true); + // 裸 --json 文档(没有信封)同样可用 + assert.equal(hasUsableOutput(partial, '{"entries":[]}'), true); + assert.equal(hasUsableOutput(partial, '[{"dir":"/tmp/x"}]'), true); + + assert.equal(hasUsableOutput(partial, ""), false); + assert.equal(hasUsableOutput(partial, " "), false); + assert.equal(hasUsableOutput(partial, "error: dependency resolution failed\n"), false); + assert.equal(hasUsableOutput(classifyExit(4, ["build"]), "mcpp: environment not ready"), false); + assert.equal(hasUsableOutput(classifyExit(undefined, ["build"]), ""), false); + + // 成功时没有需要抢救的输出 + assert.equal(hasUsableOutput(classifyExit(0, ["emit", "build-database"]), envelope), false); +}); + +test("explainHint 给出 mcpp self explain 命令", () => { + const withCode = classifyExit(1, ["build"]); + withCode.diagnosticCode = "MCPP_OFFLINE_DOWNLOAD_REQUIRED"; + assert.equal(explainHint(withCode), "mcpp self explain MCPP_OFFLINE_DOWNLOAD_REQUIRED"); + + const fromDetail = classifyExit(4, ["build"]); + fromDetail.detail = "error: environment not ready [MCPP_TOOLCHAIN_MISSING]"; + assert.equal(explainHint(fromDetail), "mcpp self explain MCPP_TOOLCHAIN_MISSING"); + + assert.equal(explainHint(classifyExit(2, ["build"])), undefined); + const withNoise = classifyExit(1, ["build"]); + withNoise.detail = "error: build failed"; + assert.equal(explainHint(withNoise), undefined); +}); diff --git a/test/cli/protocol.test.ts b/test/cli/protocol.test.ts new file mode 100644 index 0000000..e5b21c9 --- /dev/null +++ b/test/cli/protocol.test.ts @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseEnvelope, + parseProtocolInfo, + readData, + supportsKind, +} from "../../src/cli/protocol"; + +/** The shape documented for `mcpp --protocol-version` (docs/50-machine-output.md). */ +const protocolOutput = JSON.stringify({ + schemaVersion: 1, + kind: "mcpp.protocol", + envelope: { min: 1, max: 1 }, + kinds: { "mcpp.env": 1, "mcpp.toolchain.list": 1, "mcpp.build-database": 1 }, + commands: { + "self env": { effects: ["read"] }, + "toolchain list": { effects: ["read"] }, + "cache clean": { effects: ["write"] }, + "emit build-database": { effects: ["read", "write"] }, + }, + mcpp: { version: "2026.9.30.2", protocol: { min: 1, max: 1 } }, +}); + +const toolchainListOutput = JSON.stringify({ + schemaVersion: 1, + kind: "mcpp.toolchain.list", + kindVersion: 1, + mcpp: { version: "2026.9.30.2", protocol: { min: 1, max: 1 } }, + data: { + host: "x86_64-linux-gnu", + toolchains: [{ family: "llvm", version: "22.1.8", default: true }], + targets: [{ target: "x86_64-linux-gnu", toolchain: "llvm@22.1.8", status: "installed" }], + }, + diagnostics: [], + effects: [], +}); + +test("解析 mcpp.protocol 探测输出", () => { + const envelope = parseEnvelope(protocolOutput); + assert.equal(envelope?.schemaVersion, 1); + assert.equal(envelope?.kind, "mcpp.protocol"); + assert.equal(envelope?.mcpp?.version, "2026.9.30.2"); + assert.equal(envelope?.mcpp?.protocol?.max, 1); + + const info = parseProtocolInfo(protocolOutput); + assert.ok(info); + assert.equal(info.mcppVersion, "2026.9.30.2"); + assert.equal(info.envelopeMax, 1); + assert.deepEqual(info.kinds, { + "mcpp.env": 1, + "mcpp.toolchain.list": 1, + "mcpp.build-database": 1, + }); + assert.deepEqual(info.effects["emit build-database"], ["read", "write"]); + assert.deepEqual(info.effects["cache clean"], ["write"]); + assert.deepEqual(info.effects["self env"], ["read"]); +}); + +test("supportsKind 只认 mcpp 广告过的 kind", () => { + const info = parseProtocolInfo(protocolOutput); + assert.equal(supportsKind(info, "mcpp.env"), true); + assert.equal(supportsKind(info, "mcpp.toolchain.list"), true); + assert.equal(supportsKind(info, "mcpp.build-database"), true); + assert.equal(supportsKind(info, "mcpp.why.toolchain"), false); + assert.equal(supportsKind(info, ""), false); + assert.equal(supportsKind(undefined, "mcpp.env"), false); + assert.equal(supportsKind(info, "toString"), false); +}); + +test("解析 mcpp.toolchain.list 信封并取出 data", () => { + const envelope = parseEnvelope(toolchainListOutput); + assert.equal(envelope?.kind, "mcpp.toolchain.list"); + assert.equal(envelope?.kindVersion, 1); + + const data = readData<{ host: string; toolchains: Array<{ family: string }> }>( + toolchainListOutput, + "mcpp.toolchain.list", + ); + assert.equal(data?.host, "x86_64-linux-gnu"); + assert.equal(data?.toolchains[0]?.family, "llvm"); + assert.equal(readData(toolchainListOutput, "mcpp.env"), undefined); + + // kind 正确但没有 data 的合法信封 + const empty = JSON.stringify({ schemaVersion: 1, kind: "mcpp.env" }); + assert.equal(readData(empty, "mcpp.env"), undefined); +}); + +test("未知 kind 的信封仍被识别为机读输出", () => { + const output = JSON.stringify({ schemaVersion: 2, kind: "mcpp.future.thing", data: { x: 1 } }); + const envelope = parseEnvelope(output); + assert.equal(envelope?.kind, "mcpp.future.thing"); + assert.equal(envelope?.schemaVersion, 2); + + assert.deepEqual(readData(output, "mcpp.future.thing"), { x: 1 }); + assert.equal(readData(output, "mcpp.env"), undefined); + // 不是协议探测输出,不能当成 --protocol-version 的结果 + assert.equal(parseProtocolInfo(output), undefined); +}); + +test("人类文本、空输入都不是信封", () => { + const human = [ + "mcpp 0.9.0", + "usage: mcpp [command]", + "unknown option --protocol-version", + ].join("\n"); + assert.equal(parseEnvelope(human), undefined); + assert.equal(parseProtocolInfo(human), undefined); + assert.equal(readData(human, "mcpp.env"), undefined); + + assert.equal(parseEnvelope(""), undefined); + assert.equal(parseEnvelope(" \n\n "), undefined); + assert.equal(parseProtocolInfo(""), undefined); +}); + +test("拒绝 null、数组和标量 JSON", () => { + assert.equal(parseEnvelope("null"), undefined); + assert.equal(parseEnvelope("[]"), undefined); + assert.equal(parseEnvelope('[{"schemaVersion":1,"kind":"mcpp.env"}]'), undefined); + assert.equal(parseEnvelope("42"), undefined); + assert.equal(parseEnvelope('"mcpp.env"'), undefined); + assert.equal(parseEnvelope("true"), undefined); +}); + +test("schemaVersion 与 kind 缺一不可且类型必须正确", () => { + assert.equal(parseEnvelope('{"schemaVersion":1}'), undefined); + assert.equal(parseEnvelope('{"kind":"mcpp.env"}'), undefined); + assert.equal(parseEnvelope('{"schemaVersion":"1","kind":"mcpp.env"}'), undefined); + assert.equal(parseEnvelope('{"schemaVersion":1,"kind":""}'), undefined); + assert.equal(parseEnvelope('{"schemaVersion":1,"kind":7}'), undefined); +}); + +test("从前后 narration 行之间提取 JSON", () => { + const output = [ + "warning: using the legacy text interface", + "", + ` ${toolchainListOutput} `, + "", + "note: run mcpp self doctor", + ].join("\n"); + const envelope = parseEnvelope(output); + assert.equal(envelope?.kind, "mcpp.toolchain.list"); + assert.equal(readData<{ host: string }>(output, "mcpp.toolchain.list")?.host, "x86_64-linux-gnu"); + + // narration 自带花括号(在 JSON 之前、之后)都不能破坏提取 + const braces = `note: merged {deps} graph\n${JSON.stringify({ + schemaVersion: 1, + kind: "mcpp.env", + data: { mcppVersion: "2026.9.30.2" }, + })}\ntrailing {docs} line\n`; + assert.equal(parseEnvelope(braces)?.kind, "mcpp.env"); + assert.equal(readData<{ mcppVersion: string }>(braces, "mcpp.env")?.mcppVersion, "2026.9.30.2"); +}); + +test("任何输入都不抛异常", () => { + const inputs = ["{", "}", "{]", '{"schemaVersion":', "}{", "{{{{", "[{]}", "\u0000\u0001", "{\"a\":}"]; + for (const input of inputs) { + assert.doesNotThrow(() => parseEnvelope(input)); + assert.doesNotThrow(() => parseProtocolInfo(input)); + assert.doesNotThrow(() => readData(input, "mcpp.env")); + } + assert.equal(parseEnvelope("{{{{"), undefined); + // 形状坏掉的探测输出不能让调用方崩,只是"什么都没广告" + assert.deepEqual(parseProtocolInfo('{"schemaVersion":1,"kind":"mcpp.protocol","kinds":null}'), { + kinds: {}, + effects: {}, + }); + assert.deepEqual( + parseProtocolInfo('{"schemaVersion":1,"kind":"mcpp.protocol","kinds":{"mcpp.env":"1"}}'), + { kinds: {}, effects: {} }, + ); +}); + +test("protocol 探测接受 data 内嵌的防御形状", () => { + const nested = JSON.stringify({ + schemaVersion: 1, + kind: "mcpp.protocol", + data: { + envelope: { min: 1, max: 2 }, + kinds: { "mcpp.env": 1 }, + commands: { "self env": { effects: ["read"] } }, + }, + mcpp: { version: "2026.10.1.3", protocol: { min: 1, max: 2 } }, + }); + assert.deepEqual(parseProtocolInfo(nested), { + mcppVersion: "2026.10.1.3", + envelopeMax: 2, + kinds: { "mcpp.env": 1 }, + effects: { "self env": ["read"] }, + }); +}); diff --git a/test/mcppls/bridge.test.ts b/test/mcppls/bridge.test.ts index dd398d2..20cbf54 100644 --- a/test/mcppls/bridge.test.ts +++ b/test/mcppls/bridge.test.ts @@ -1,91 +1,124 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - createLanguageServerBridge, - MCPPLS_COMMANDS, - MCPPLS_EXTENSION_ID, -} from "../../src/mcppls/bridge"; +import { createLanguageServerBridge, MCPPLS_EXTENSION_ID } from "../../src/mcppls/bridge"; +import type { CapabilityEnvironment } from "../../src/mcppls/capabilities"; -function harness(installed = true) { +function harness(options: { installed?: boolean; behaviour?: (command: string) => void } = {}) { + const installed = options.installed ?? true; const calls: Array<{ command: string; args: unknown[] }> = []; const activations: string[] = []; - const bridge = createLanguageServerBridge({ + const environment: CapabilityEnvironment = { extensionInstalled: (id) => installed && id === MCPPLS_EXTENSION_ID, - activateExtension: async (id) => { activations.push(id); }, + declaredCommands: () => undefined, + activateExtension: async (id) => { + activations.push(id); + }, executeCommand: async (command: string, ...args: unknown[]): Promise => { calls.push({ command, args }); + options.behaviour?.(command); return undefined as T; }, - }); - return { bridge, calls, activations }; + }; + return { bridge: createLanguageServerBridge(environment), calls, activations }; } -test("uses the published mcppls extension and command identifiers", () => { +test("uses the published mcppls extension id", () => { assert.equal(MCPPLS_EXTENSION_ID, "sunrisepeak.mcpp-language-server"); - assert.deepEqual(MCPPLS_COMMANDS, { - restart: "mcppls.restartServer", - selectContext: "mcppls.selectContext", - graph: "mcppls.showModuleGraph", - logs: "mcppls.showLogs", - }); }); -test("forwards the public mcppls UI commands", async () => { +test("forwards one command per capability and activates the dependency first", async () => { const { bridge, calls, activations } = harness(); assert.deepEqual(await bridge.restartLanguageServer(), { state: "completed", - message: "C++ 模块语言服务已重启。", + capabilityKey: "restartServer", + command: "mcppls.restartServer", }); await bridge.selectContext(); await bridge.showModuleGraph(); await bridge.showLanguageServerLogs(); + await bridge.restartEngine(); + await bridge.resetWorkspaceCache(); + await bridge.collectReport(); + await bridge.exportDiagnosticBundle(); + await bridge.runBuildToolInTerminal(); + await bridge.installCommandLineTools(); - assert.deepEqual(activations, [MCPPLS_EXTENSION_ID, MCPPLS_EXTENSION_ID, MCPPLS_EXTENSION_ID, MCPPLS_EXTENSION_ID]); - assert.deepEqual(calls, [ - { command: MCPPLS_COMMANDS.restart, args: [] }, - { command: MCPPLS_COMMANDS.selectContext, args: [] }, - { command: MCPPLS_COMMANDS.graph, args: [] }, - { command: MCPPLS_COMMANDS.logs, args: [] }, + assert.deepEqual(calls.map((call) => call.command), [ + "mcppls.restartServer", + "mcppls.selectContext", + "mcppls.showModuleGraph", + "mcppls.showLogs", + "mcppls.restartClangd", + "mcppls.resetWorkspaceCache", + "mcppls.collectReport", + "mcppls.exportDiagnosticBundle", + "mcppls.runBuildToolInTerminal", + "mcppls.installCommandLineTools", ]); + assert.equal(activations.length, 10); + assert.ok(activations.every((id) => id === MCPPLS_EXTENSION_ID)); }); -test("returns a stable unavailable result instead of throwing", async () => { - const { bridge, calls } = harness(false); +test("the refresh capability prefers the cheap reload", async () => { + const { bridge, calls } = harness(); + const result = await bridge.refreshLanguageServerAfterBuild(); + assert.deepEqual(result, { + state: "completed", + capabilityKey: "refresh", + command: "mcppls.reloadBuildDescription", + }); + assert.deepEqual(calls.map((call) => call.command), ["mcppls.reloadBuildDescription"]); +}); +test("an uninstalled dependency yields unavailable without calling anything", async () => { + const { bridge, calls } = harness({ installed: false }); assert.deepEqual(await bridge.restartLanguageServer(), { state: "unavailable", - message: "C++ 模块语言服务依赖未安装或已禁用:sunrisepeak.mcpp-language-server。", + capabilityKey: "restartServer", }); + assert.equal(bridge.isGone("restartServer"), true); assert.deepEqual(calls, []); }); -test("returns a stable failed result when mcppls rejects a command", async () => { - const calls: string[] = []; - const bridge = createLanguageServerBridge({ - extensionInstalled: (id) => id === MCPPLS_EXTENSION_ID, - executeCommand: async (command: string): Promise => { - calls.push(command); +test("a failure is reported verbatim and does not remove the capability", async () => { + const { bridge } = harness({ + behaviour: () => { throw new Error("server is not running"); }, }); + const result = await bridge.restartLanguageServer(); + assert.equal(result.state, "failed"); + assert.match(result.error ?? "", /server is not running/); + assert.equal(bridge.isGone("restartServer"), false); +}); - assert.deepEqual(await bridge.restartLanguageServer(), { - state: "failed", - message: "C++ Modules 命令执行失败:server is not running", +test("a missing command is not called again", async () => { + const { bridge, calls } = harness({ + behaviour: (command) => { + throw new Error(`command '${command}' not found`); + }, }); - assert.deepEqual(calls, [MCPPLS_COMMANDS.restart]); + const first = await bridge.showModuleGraph(); + assert.equal(first.state, "missing"); + assert.equal(bridge.isGone("moduleGraph"), true); + const second = await bridge.showModuleGraph(); + assert.equal(second.state, "missing"); + assert.equal(calls.length, 1); }); -test("coalesces concurrent build refresh requests into one restart", async () => { +test("coalesces concurrent build refresh requests into one call", async () => { let release: (() => void) | undefined; const calls: string[] = []; const bridge = createLanguageServerBridge({ - extensionInstalled: (id) => id === MCPPLS_EXTENSION_ID, + extensionInstalled: () => true, + declaredCommands: () => undefined, executeCommand: async (command: string): Promise => { calls.push(command); - await new Promise((resolve) => { release = resolve; }); + await new Promise((resolve) => { + release = resolve; + }); return undefined as T; }, }); @@ -93,15 +126,29 @@ test("coalesces concurrent build refresh requests into one restart", async () => const first = bridge.refreshLanguageServerAfterBuild(); const second = bridge.refreshLanguageServerAfterBuild(); await new Promise((resolve) => setImmediate(resolve)); - assert.deepEqual(calls, [MCPPLS_COMMANDS.restart]); + assert.deepEqual(calls, ["mcppls.reloadBuildDescription"]); release?.(); - assert.deepEqual(await first, { - state: "completed", - message: "C++ 模块语言服务已刷新。", - }); - assert.deepEqual(await second, { - state: "completed", - message: "C++ 模块语言服务已刷新。", - }); - assert.deepEqual(calls, [MCPPLS_COMMANDS.restart]); + assert.equal((await first).state, "completed"); + assert.equal((await second).state, "completed"); + assert.deepEqual(calls, ["mcppls.reloadBuildDescription"]); +}); + +test("directional commands pass their argument through", async () => { + const { bridge, calls } = harness(); + await bridge.manageConflicts(true); + await bridge.toggleInWorkspace(false); + await bridge.reviewChanges(true); + assert.deepEqual(calls, [ + { command: "mcppls.turnOffOtherCppFeatures", args: [true] }, + { command: "mcppls.turnOffInWorkspace", args: [false] }, + { command: "mcppls.review.run", args: [true] }, + ]); +}); + +test("the danger level comes from the capability table", () => { + const { bridge } = harness(); + assert.equal(bridge.dangerOf("resetCache"), "destructive"); + assert.equal(bridge.dangerOf("restartEngine"), "confirm"); + assert.equal(bridge.dangerOf("selectContext"), "none"); + assert.equal(bridge.dangerOf("nope"), "none"); }); diff --git a/test/mcppls/capabilities.test.ts b/test/mcppls/capabilities.test.ts new file mode 100644 index 0000000..f157776 --- /dev/null +++ b/test/mcppls/capabilities.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { MCPPLS_EXTENSION_ID } from "../../src/mcppls/contract"; +import { CapabilityRegistry, classifyCommandError, type CapabilityEnvironment } from "../../src/mcppls/capabilities"; + +interface Harness { + registry: CapabilityRegistry; + calls: Array<{ command: string; args: unknown[] }>; + activations: string[]; +} + +function harness(options: { + installed?: boolean; + declared?: readonly string[] | undefined; + behaviour?: (command: string, attempt: number) => void; +} = {}): Harness { + const installed = options.installed ?? true; + const calls: Array<{ command: string; args: unknown[] }> = []; + const activations: string[] = []; + const attempts = new Map(); + const environment: CapabilityEnvironment = { + extensionInstalled: (id) => installed && id === MCPPLS_EXTENSION_ID, + declaredCommands: () => options.declared, + activateExtension: async (id) => { + activations.push(id); + }, + executeCommand: async (command: string, ...args: unknown[]): Promise => { + calls.push({ command, args }); + const attempt = (attempts.get(command) ?? 0) + 1; + attempts.set(command, attempt); + options.behaviour?.(command, attempt); + return undefined as T; + }, + }; + return { registry: new CapabilityRegistry(environment), calls, activations }; +} + +const notFound = (command: string): never => { + throw new Error(`command '${command}' not found`); +}; + +test("classifyCommandError separates a missing command from a real failure", () => { + assert.equal(classifyCommandError(new Error("command 'mcppls.showLogs' not found")), "missing"); + assert.equal(classifyCommandError(new Error("Command not found")), "missing"); + assert.equal(classifyCommandError(new Error("not registered")), "missing"); + assert.equal(classifyCommandError(new Error("server is not running")), "failed"); + assert.equal(classifyCommandError("plain string"), "failed"); +}); + +test("with no static information a capability is assumed usable until a call says otherwise", () => { + const { registry, calls } = harness({ declared: undefined }); + assert.equal(registry.status("moduleGraph").state, "declared"); + assert.equal(registry.isGone("moduleGraph"), false); + return registry.invoke("moduleGraph").then((result) => { + assert.deepEqual(result, { state: "completed", capabilityKey: "moduleGraph", command: "mcppls.showModuleGraph" }); + assert.deepEqual(calls, [{ command: "mcppls.showModuleGraph", args: [] }]); + }); +}); + +test("a statically undeclared command is only greyed out, never hidden", () => { + const { registry } = harness({ declared: ["mcppls.restartServer"] }); + assert.equal(registry.status("moduleGraph").state, "undeclared"); + assert.equal(registry.isUnconfirmed("moduleGraph"), true); + assert.equal(registry.isGone("moduleGraph"), false); +}); + +test("an uninstalled dependency makes every capability unavailable without calling anything", async () => { + const { registry, calls } = harness({ installed: false }); + assert.equal(registry.status("refresh").state, "unavailable"); + assert.equal(registry.isGone("refresh"), true); + assert.deepEqual(await registry.invoke("refresh"), { state: "unavailable", capabilityKey: "refresh" }); + assert.deepEqual(calls, []); +}); + +test("refresh falls back down its candidate chain when the preferred command is missing", async () => { + const { registry, calls } = harness({ + declared: undefined, + behaviour: (command, attempt) => { + if (command === "mcppls.reloadBuildDescription" && attempt === 1) { + notFound(command); + } + }, + }); + const result = await registry.invoke("refresh"); + assert.deepEqual(result, { state: "completed", capabilityKey: "refresh", command: "mcppls.restartServer" }); + assert.deepEqual(calls.map((call) => call.command), [ + "mcppls.reloadBuildDescription", + "mcppls.restartServer", + ]); + assert.equal(registry.status("refresh").state, "available"); +}); + +test("a capability whose whole chain is missing is remembered as gone", async () => { + const { registry, calls } = harness({ + declared: undefined, + behaviour: (command) => notFound(command), + }); + assert.deepEqual(await registry.invoke("refresh"), { + state: "missing", + capabilityKey: "refresh", + command: "mcppls.reloadBuildDescription", + }); + assert.equal(registry.status("refresh").state, "missing"); + assert.equal(registry.isGone("refresh"), true); + assert.equal(calls.length, 2, "the chain is tried once, not forever"); +}); + +test("a real failure keeps the capability and reports the error verbatim", async () => { + const { registry } = harness({ + declared: undefined, + behaviour: (command) => { + throw new Error(`${command}: server is not running`); + }, + }); + const result = await registry.invoke("logs"); + assert.equal(result.state, "failed"); + assert.match(result.error ?? "", /server is not running/); + assert.equal(registry.isGone("logs"), false); +}); + +test("the dependency is activated before the first forward", async () => { + const { registry, activations } = harness({ declared: undefined }); + await registry.invoke("selectContext"); + assert.deepEqual(activations, [MCPPLS_EXTENSION_ID]); +}); + +test("invalidate re-reads the static declaration", () => { + let declared: readonly string[] = []; + const environment: CapabilityEnvironment = { + extensionInstalled: () => true, + declaredCommands: () => declared, + executeCommand: async (): Promise => undefined as T, + }; + const registry = new CapabilityRegistry(environment); + assert.equal(registry.status("moduleGraph").state, "undeclared"); + declared = ["mcppls.showModuleGraph"]; + registry.invalidate(); + assert.equal(registry.status("moduleGraph").state, "declared"); + assert.ok(registry.availableKeys().includes("moduleGraph")); +}); + +test("readState is never probed by calling a command", () => { + const { registry, calls } = harness({ declared: [] }); + assert.equal(registry.status("readState").state, "declared"); + assert.deepEqual(calls, []); +}); + +test("an unknown capability fails without touching the dependency", async () => { + const { registry, calls } = harness(); + const result = await registry.invoke("nope"); + assert.equal(result.state, "failed"); + assert.match(result.error ?? "", /unknown capability/); + assert.deepEqual(calls, []); +}); diff --git a/test/mcppls/contract.test.ts b/test/mcppls/contract.test.ts new file mode 100644 index 0000000..a66cfff --- /dev/null +++ b/test/mcppls/contract.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "../../src/commands/ids"; +import { + CAPABILITIES, + MCPPLS_EXTENSION_ID, + VERIFIED_MCPPLS_RANGE, + capability, + capabilityProblems, + commandsOf, +} from "../../src/mcppls/contract"; + +test("the capability table satisfies its own rules", () => { + assert.deepEqual(capabilityProblems(), []); +}); + +test("the dependency id is the published one", () => { + assert.equal(MCPPLS_EXTENSION_ID, "sunrisepeak.mcpp-language-server"); + assert.equal(VERIFIED_MCPPLS_RANGE, ">=0.0.4"); +}); + +test("no capability is required: losing mcppls must not disable mcpp's own features", () => { + assert.ok(CAPABILITIES.every((entry) => entry.required === false)); +}); + +test("the refresh chain prefers the cheap reload and falls back to a restart", () => { + assert.deepEqual(commandsOf("refresh"), ["mcppls.reloadBuildDescription", "mcppls.restartServer"]); +}); + +test("every forwarded command is an mcppls command", () => { + for (const entry of CAPABILITIES) { + for (const command of entry.commands) { + assert.ok(command.startsWith("mcppls."), `${entry.key} forwards ${command}`); + } + } +}); + +test("destructive capabilities spell out what they do not touch", () => { + const reset = capability("resetCache"); + assert.ok(reset); + assert.equal(reset.danger, "destructive"); + assert.match(reset.confirmHint ?? "", /not touched/); +}); + +test("our own commands never use the mcppls prefix", () => { + for (const id of [...Object.values(CLI_COMMANDS), ...Object.values(DEPRECATED_COMMANDS)]) { + assert.ok(!id.startsWith("mcppls."), `${id} would collide with a server-advertised command`); + } +}); + +test("capability keys are unique and unknown keys resolve to undefined", () => { + const keys = CAPABILITIES.map((entry) => entry.key); + assert.equal(new Set(keys).size, keys.length); + assert.equal(capability("nope"), undefined); + assert.deepEqual(commandsOf("nope"), []); +}); diff --git a/test/mcppls/state.test.ts b/test/mcppls/state.test.ts new file mode 100644 index 0000000..b6e1eb0 --- /dev/null +++ b/test/mcppls/state.test.ts @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { describeState, readStateFromExports } from "../../src/mcppls/state"; + +const READY = { + state: "ready", + project: { root: "file:///w", source: "mcpp", level: 3, tier: 1 }, + profile: { kind: "build-toolchain", compiler: "clang 22.1.8", stdlib: "libc++ 22.1.8", target: "x86_64-linux-gnu", standard: "c++26" }, + engine: { name: "clangd", version: "23.1.0" }, + engines: [ + { name: "clangd", version: "23.1.0", role: "core", state: "ready" }, + { name: "mcppls", version: "0.0.9", role: "modules", state: "ready" }, + ], + progress: { done: 12, total: 12 }, + issues: [{ code: "unresolved-module", message: "sub not found" }], +}; + +const api = (status: unknown) => ({ lastStatus: () => status, statusBarText: () => "ready" }); + +test("reads the documented status shape", () => { + const view = readStateFromExports(api(READY), { version: "0.0.9", active: true, enabled: true }); + assert.equal(view.available, true); + assert.equal(view.state, "ready"); + assert.equal(view.version, "0.0.9"); + assert.equal(view.active, true); + assert.equal(view.enabled, true); + assert.deepEqual(view.project, { root: "file:///w", source: "mcpp", level: 3, tier: 1 }); + assert.equal(view.profile?.stdlib, "libc++ 22.1.8"); + assert.equal(view.engine?.name, "clangd"); + assert.equal(view.engines?.length, 2); + assert.deepEqual(view.progress, { done: 12, total: 12 }); + assert.equal(view.issues?.[0]?.code, "unresolved-module"); +}); + +test("carries S3's own remedy for an issue", () => { + const view = readStateFromExports( + api({ + state: "degraded", + issues: [ + { + code: "producer-needs-download", + message: "the build tool needs a download", + command: { command: "mcppls.describeOnline", arguments: [], title: "Allow" }, + }, + ], + }), + ); + assert.equal(view.available, true); + assert.deepEqual(view.issues?.[0]?.command, { + command: "mcppls.describeOnline", + arguments: [], + title: "Allow", + }); +}); + +test("an issue without a well-formed command keeps its code and message", () => { + const view = readStateFromExports( + api({ state: "degraded", issues: [{ code: "x", message: "y", command: { arguments: [1] } }] }), + ); + assert.equal(view.issues?.[0]?.code, "x"); + assert.equal(view.issues?.[0]?.command, undefined); +}); + +test("no API object means available: false with a reason", () => { + for (const exports of [undefined, null, 42, "text", [], {}]) { + const view = readStateFromExports(exports); + assert.equal(view.available, false, JSON.stringify(exports)); + assert.equal(typeof view.reason, "string"); + } +}); + +test("an API object without lastStatus() is reported, not thrown", () => { + const view = readStateFromExports({ statusBarText: () => "ready" }); + assert.equal(view.available, false); + assert.match(view.reason ?? "", /lastStatus/); +}); + +test("a throwing lastStatus() is contained", () => { + const view = readStateFromExports({ + lastStatus: () => { + throw new Error("boom"); + }, + }); + assert.equal(view.available, false); + assert.match(view.reason ?? "", /boom/); +}); + +test("a status that has not arrived yet is not an error", () => { + const view = readStateFromExports(api(undefined)); + assert.equal(view.available, false); + assert.match(view.reason ?? "", /no status yet/); +}); + +test("an unknown state value is refused rather than rendered", () => { + const view = readStateFromExports(api({ state: "melted" })); + assert.equal(view.available, false); + assert.match(view.reason ?? "", /unknown state/); +}); + +test("every documented state is accepted", () => { + for (const state of ["starting", "loading", "preparing", "ready", "degraded", "error"]) { + const view = readStateFromExports(api({ state })); + assert.equal(view.available, true, state); + assert.equal(view.state, state); + } +}); + +test("partially formed fields are dropped instead of producing half-objects", () => { + const view = readStateFromExports( + api({ + state: "ready", + project: { root: "file:///w" }, + profile: { kind: "semantic-kit" }, + engine: { name: "clangd" }, + progress: { done: "many", total: 3 }, + engines: [{ name: "clangd" }], + onlineRun: { outcome: "fetched" }, + }), + ); + assert.equal(view.available, true); + assert.equal(view.project, undefined); + assert.equal(view.profile, undefined); + assert.equal(view.engine, undefined); + assert.equal(view.progress, undefined); + assert.equal(view.engines, undefined); + assert.equal(view.onlineRun, undefined); +}); + +test("onlineRun is surfaced when complete", () => { + const view = readStateFromExports( + api({ state: "ready", onlineRun: { outcome: "fetched", message: "got it", at: "2026-10-02T12:40:11Z" } }), + ); + assert.deepEqual(view.onlineRun, { outcome: "fetched", message: "got it", at: "2026-10-02T12:40:11Z" }); +}); + +test("describeState is a single readable line either way", () => { + assert.match(describeState(readStateFromExports(api(READY))), /ready · project mcpp · engine clangd/); + assert.match(describeState(readStateFromExports(undefined)), /^unavailable \(/); +}); From 3160cd51ad3d7c836a404cf53b2dd83085b86263 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:38:26 +0800 Subject: [PATCH 04/56] =?UTF-8?q?feat(views):=20=E4=B8=89=E6=A3=B5?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E6=A0=91=E7=9A=84=E7=BA=AF=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E3=80=81=E6=B8=85=E7=90=86=E8=AE=A1=E5=88=92=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/views/models.ts:工程/缓存/C++ Modules 三棵树以「标签即 key」的纯数据描述, 不依赖 vscode 也不依赖当前语言;渲染时才经 i18n 解析,测试断言稳定的 key - src/cli/clean.ts:全部清理动作作为计划表(argv + 危险级 + 是否预演 + 是否二次确认 + 是否需要受信工作区)。cache clean --all 可达但唯一为 3 级;--bmi-cache 只能通过 withSharedCache 显式升级危险级 - src/projects/summary.ts:从 mcpp.toml 读浅摘要(身份/toolchain/targets/profile), 行式解析、读不到就省略字段,绝不猜 验证:新增 38 个用例通过(视图模型 18、清理策略 12、工程摘要 8) --- src/cli/clean.ts | 242 +++++++++++++++ src/projects/summary.ts | 111 +++++++ src/views/models.ts | 560 ++++++++++++++++++++++++++++++++++ test/cli/clean.test.ts | 94 ++++++ test/projects/summary.test.ts | 78 +++++ test/views/models.test.ts | 198 ++++++++++++ 6 files changed, 1283 insertions(+) create mode 100644 src/cli/clean.ts create mode 100644 src/projects/summary.ts create mode 100644 src/views/models.ts create mode 100644 test/cli/clean.test.ts create mode 100644 test/projects/summary.test.ts create mode 100644 test/views/models.test.ts 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/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/views/models.ts b/src/views/models.ts new file mode 100644 index 0000000..525b815 --- /dev/null +++ b/src/views/models.ts @@ -0,0 +1,560 @@ +/** + * The three 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. + * + * The same shape serves the project, the cache and the C++ Modules view, so the + * three providers differ only in the data they hand in. + */ + +import { formatBytes, formatCount } from "../util/format"; + +export interface Label { + key: string; + args?: readonly (string | number)[]; +} + +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; + /** Consumed by `when` clauses in package.json for inline actions. */ + contextValue?: string; + command?: TreeCommand; + children?: readonly TreeNode[]; +} + +const plain = (text: string): Label => ({ key: text }); + +export interface TargetSummary { + name: string; + kind: string; +} + +export interface ProjectSummary { + root: string; + name?: string; + version?: string; + standard?: string; + profile?: string; + toolchainSpec?: string; + target?: string; + targets?: readonly TargetSummary[]; + hasTests?: boolean; + /** Set when `mcpp.toml` could not be read; the tree then says so instead of lying. */ + error?: string; +} + +/** The project view: identity first, then what the buttons act on. */ +export function buildProjectTree(project: ProjectSummary | undefined): TreeNode[] { + if (project === undefined) { + 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."), + }, + ]; + } + 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 identity: TreeNode[] = [ + { + id: "project.identity", + label: project.version === undefined ? plain(project.name ?? "mcpp project") : { key: "{0} {1}", args: [project.name ?? "mcpp project", project.version] }, + icon: "package", + tooltip: { key: "{0}", args: [project.root] }, + contextValue: "mcppProject", + children: [ + { + id: "project.root", + label: plain("Location"), + description: { key: "{0}", args: [project.root] }, + icon: "folder", + }, + ...(project.standard === undefined + ? [] + : [{ id: "project.standard", label: plain("C++ standard"), description: { key: "{0}", args: [project.standard] }, icon: "symbol-namespace" }]), + ...(project.profile === undefined + ? [] + : [{ id: "project.profile", label: plain("Profile"), description: { key: "{0}", args: [project.profile] }, icon: "settings-gear" }]), + ], + }, + ]; + + const toolchain: TreeNode[] = [ + { + id: "project.toolchain", + label: plain("Toolchain"), + description: { key: "{0}", args: [project.toolchainSpec ?? "host default"] }, + icon: "chip", + children: [ + ...(project.target === undefined + ? [] + : [{ id: "project.target", label: plain("Target"), description: { key: "{0}", args: [project.target] }, icon: "target" }]), + ...(project.targets ?? []).map((entry): TreeNode => ({ + id: `project.target.${entry.name}`, + label: { key: "{0}", args: [entry.name] }, + description: { key: "{0}", args: [entry.kind] }, + icon: "symbol-method", + })), + ], + }, + ]; + + const actions: TreeNode[] = [ + { + id: "project.action.build", + label: plain("Build"), + icon: "tools", + command: { command: "mcpp.build", title: plain("Build") }, + }, + { + id: "project.action.run", + label: plain("Run"), + icon: "play", + command: { command: "mcpp.run", title: plain("Run") }, + }, + { + id: "project.action.test", + label: plain("Test"), + icon: "beaker", + command: { command: "mcpp.test", title: plain("Test") }, + }, + { + id: "project.action.clean", + label: plain("Clean project artifacts"), + icon: "trash", + command: { command: "mcpp.cleanProjectArtifacts", title: plain("Clean project artifacts") }, + }, + ]; + + return [...identity, ...toolchain, ...actions]; +} + +export interface CacheTreeInput { + projectRoot?: string; + artifacts?: ArtifactEstimateSummary; + inventory?: CacheInventorySummary; + legacyBytes?: number; + error?: string; +} + +export interface ArtifactEstimateSummary { + exists: boolean; + totalBytes: number; + files: number; + groups: number; + truncated?: string; +} + +export interface CacheInventorySummary { + root: string; + totalBytes: number; + totalEntries: number; + byKind: Array<{ kind: string; entries: number; bytes: number }>; + topLabels: Array<{ label: string; entries: number; bytes: number }>; + incomplete: number; + oldestAccessed?: number; + newestAccessed?: number; + ageBuckets: Array<{ fromDays: number; toDays?: number; entries: number; bytes: number }>; +} + +/** The cache view: what this project leaves behind, then what the machine shares. */ +export function buildCacheTree(input: CacheTreeInput): TreeNode[] { + const nodes: TreeNode[] = []; + + nodes.push({ + id: "cache.project", + label: plain("Project artifacts"), + description: { key: "{0}", args: [input.projectRoot ?? "target/"] }, + icon: "file-directory", + contextValue: "mcppCacheProject", + children: projectArtifactChildren(input), + }); + + if (input.inventory !== undefined) { + nodes.push({ + id: "cache.global", + label: plain("Global build cache"), + description: { key: "{0} · {1}", args: [formatBytes(input.inventory.totalBytes), formatCount(input.inventory.totalEntries)] }, + icon: "database", + contextValue: "mcppCacheGlobal", + tooltip: { key: "{0}", args: [input.inventory.root] }, + children: globalCacheChildren(input.inventory), + }); + } else { + nodes.push({ + id: "cache.global.unknown", + label: plain("Global build cache"), + description: input.error === undefined ? plain("not read yet") : { key: "{0}", args: [input.error] }, + icon: input.error === undefined ? "database" : "warning", + contextValue: "mcppCacheGlobalUnknown", + command: { command: "mcpp.refreshCacheStats", title: plain("Refresh cache statistics") }, + }); + } + + if (input.legacyBytes !== undefined && input.legacyBytes > 0) { + nodes.push({ + id: "cache.legacy", + label: plain("Pre-v1 cache"), + description: { key: "{0}", args: [formatBytes(input.legacyBytes)] }, + icon: "archive", + contextValue: "mcppCacheLegacy", + command: { command: "mcpp.cleanLegacyCache", title: plain("Remove the pre-v1 cache") }, + }); + } + + return nodes; +} + +function projectArtifactChildren(input: CacheTreeInput): TreeNode[] { + const estimate = input.artifacts; + if (estimate === undefined) { + return [ + { + id: "cache.project.unread", + label: plain("Not measured yet"), + icon: "info", + command: { command: "mcpp.refreshCacheStats", title: plain("Refresh cache statistics") }, + }, + ]; + } + if (!estimate.exists) { + return [{ id: "cache.project.absent", label: plain("No target/ directory"), icon: "info" }]; + } + const children: TreeNode[] = [ + { + id: "cache.project.size", + label: plain("Estimated size"), + description: { + key: "{0} · {1} file(s)", + args: [formatBytes(estimate.totalBytes), formatCount(estimate.files)], + }, + icon: "graph", + tooltip: plain("An estimate: mcpp does not publish the layout of target/, so this is measured from the file system."), + }, + { + id: "cache.project.groups", + label: plain("Build directories"), + description: { key: "{0}", args: [estimate.groups] }, + icon: "file-submodule", + }, + { + id: "cache.project.stale", + label: plain("Stale artifacts"), + description: plain("Removed by mcpp clean --stale"), + icon: "history", + contextValue: "mcppCacheStale", + command: { command: "mcpp.cleanStaleArtifacts", title: plain("Clean stale artifacts") }, + }, + { + id: "cache.project.clean", + label: plain("Clean project artifacts"), + icon: "trash", + command: { command: "mcpp.cleanProjectArtifacts", title: plain("Clean project artifacts") }, + }, + ]; + if (estimate.truncated !== undefined) { + children.push({ + id: "cache.project.truncated", + label: plain("The measurement stopped early; the figure is a lower bound"), + icon: "warning", + }); + } + return children; +} + +function globalCacheChildren(inventory: CacheInventorySummary): TreeNode[] { + const children: TreeNode[] = [ + ...inventory.byKind.map((entry): TreeNode => ({ + id: `cache.kind.${entry.kind}`, + label: { key: "{0}", args: [entry.kind] }, + description: { key: "{0} · {1}", args: [formatBytes(entry.bytes), formatCount(entry.entries)] }, + icon: entry.kind === "std" ? "library" : "package", + })), + { + id: "cache.age", + label: plain("By last use"), + icon: "clock", + children: inventory.ageBuckets.map((bucket, index): TreeNode => ({ + id: `cache.age.${index}`, + label: + bucket.toDays === undefined + ? { key: "more than {0} day(s) ago", args: [bucket.fromDays] } + : { key: "{0}–{1} day(s) ago", args: [bucket.fromDays, bucket.toDays] }, + description: { key: "{0} · {1}", args: [formatBytes(bucket.bytes), formatCount(bucket.entries)] }, + icon: index === inventory.ageBuckets.length - 1 ? "warning" : "history", + })), + }, + ...(inventory.topLabels.length === 0 + ? [] + : [ + { + id: "cache.top", + label: plain("Largest packages"), + icon: "list-ordered", + children: inventory.topLabels.map((entry): TreeNode => ({ + id: `cache.top.${entry.label}`, + label: { key: "{0}", args: [entry.label] }, + description: { key: "{0} · {1}", args: [formatBytes(entry.bytes), formatCount(entry.entries)] }, + icon: "package", + contextValue: "mcppCachePackage", + command: { + command: "mcpp.showCacheEntry", + title: plain("Show cache entry details"), + arguments: [entry.label], + }, + })), + }, + ]), + ]; + + if (inventory.incomplete > 0) { + children.push({ + id: "cache.incomplete", + label: plain("Incomplete entries"), + description: { key: "{0}", args: [formatCount(inventory.incomplete)] }, + icon: "warning", + contextValue: "mcppCacheIncomplete", + command: { command: "mcpp.verifyGlobalCache", title: plain("Verify the cache") }, + }); + } + + children.push( + { + id: "cache.action.refresh", + label: plain("Refresh statistics"), + icon: "refresh", + command: { command: "mcpp.refreshCacheStats", title: plain("Refresh cache statistics") }, + }, + { + id: "cache.action.panel", + label: plain("Open the cache panel"), + icon: "graph", + command: { command: "mcpp.showCachePanel", title: plain("Cache statistics") }, + }, + { + id: "cache.action.gc", + label: plain("Collect to a budget"), + icon: "history", + command: { command: "mcpp.gcGlobalCache", title: plain("Collect the global cache") }, + }, + { + id: "cache.action.prune", + label: plain("Drop entries unused for a while"), + icon: "clock", + command: { command: "mcpp.pruneGlobalCache", title: plain("Prune the global cache") }, + }, + { + id: "cache.action.verify", + label: plain("Verify the cache"), + icon: "check", + command: { command: "mcpp.verifyGlobalCache", title: plain("Verify the cache") }, + }, + ); + return children; +} + +export interface LanguageServerTreeInput { + installed: boolean; + enabled?: boolean; + version?: string; + state?: { + available: boolean; + reason?: string; + state?: string; + project?: { source: string; level?: number }; + profile?: { compiler?: string; stdlib: string; target: string; standard?: string }; + engine?: { name: string; version: string }; + engines?: Array<{ name: string; version: string; role: string; state: string }>; + issues?: Array<{ code: string; message: string; command?: { command: string; arguments?: unknown[]; title?: string } }>; + notices?: Array<{ code: string; message: string }>; + onlineRun?: { outcome: string; message: string; at: string }; + }; +} + +/** + * The C++ Modules view. Everything here is provided by mcppls: the tree says so + * in its description, and every action forwards to an mcppls command. + */ +export function buildLanguageServerTree(input: LanguageServerTreeInput): TreeNode[] { + if (!input.installed) { + return [ + { + id: "ls.absent", + label: plain("C++ Modules is not installed"), + icon: "warning", + command: { command: "mcpp.openMcpplsSettings", title: plain("Install C++ Modules") }, + }, + ]; + } + + const status = input.state; + const nodes: TreeNode[] = []; + + nodes.push({ + id: "ls.status", + label: plain("Status"), + description: + status?.available === true + ? { key: "{0}", args: [status.state ?? "unknown"] } + : { key: "{0}", args: [status?.reason ?? "not read yet"] }, + icon: status?.available === true ? stateIcon(status.state) : "question", + contextValue: "mcppLanguageServerStatus", + }); + + if (input.version !== undefined || input.enabled !== undefined) { + nodes.push({ + id: "ls.identity", + label: plain("C++ Modules"), + description: { + key: "{0}{1}", + args: [input.version ?? "?", input.enabled === false ? " · disabled here" : ""], + }, + icon: "beaker", + }); + } + + if (status?.available === true) { + if (status.profile !== undefined) { + nodes.push({ + id: "ls.profile", + label: plain("Semantic profile"), + description: { key: "{0} · {1}", args: [status.profile.compiler ?? status.profile.stdlib, status.profile.target] }, + icon: "symbol-class", + }); + } + if (status.project !== undefined) { + nodes.push({ + id: "ls.database", + label: plain("Build description"), + description: { key: "{0}", args: [status.project.source] }, + icon: "database", + }); + } + if (status.engines !== undefined || status.engine !== undefined) { + const engines = status.engines ?? []; + nodes.push({ + id: "ls.engines", + label: plain("Engines"), + icon: "server-process", + children: + engines.length > 0 + ? engines.map((engine): TreeNode => ({ + id: `ls.engine.${engine.name}`, + label: { key: "{0}", args: [engine.name] }, + description: { key: "{0} · {1} · {2}", args: [engine.version, engine.role, engine.state] }, + icon: engine.state === "ready" ? "check" : "sync~spin", + })) + : [{ id: "ls.engine.core", label: { key: "{0}", args: [status.engine?.name ?? "?"] }, description: { key: "{0}", args: [status.engine?.version ?? "?"] }, icon: "check" }], + }); + } + if (status.onlineRun !== undefined) { + nodes.push({ + id: "ls.onlineRun", + label: plain("Last online run"), + description: { key: "{0} · {1}", args: [status.onlineRun.outcome, status.onlineRun.at] }, + icon: "cloud", + tooltip: { key: "{0}", args: [status.onlineRun.message] }, + }); + } + } + + const issues = status?.issues ?? []; + if (issues.length > 0) { + nodes.push({ + id: "ls.issues", + label: plain("Issues"), + description: { key: "{0}", args: [issues.length] }, + icon: "warning", + children: issues.map((issue, index): TreeNode => ({ + id: `ls.issue.${index}.${issue.code}`, + label: { key: "{0}", args: [issue.code] }, + description: { key: "{0}", args: [issue.message] }, + icon: "warning", + // S3 hands us the remedy; use it rather than inventing one. + command: + issue.command === undefined + ? undefined + : { + command: issue.command.command, + title: issue.command.title === undefined ? plain("Fix") : { key: "{0}", args: [issue.command.title] }, + arguments: issue.command.arguments, + }, + })), + }); + } + + const notices = status?.notices ?? []; + if (notices.length > 0) { + nodes.push({ + id: "ls.notices", + label: plain("Notices"), + description: { key: "{0}", args: [notices.length] }, + icon: "info", + children: notices.map((notice, index): TreeNode => ({ + id: `ls.notice.${index}.${notice.code}`, + label: { key: "{0}", args: [notice.code] }, + description: { key: "{0}", args: [notice.message] }, + icon: "info", + })), + }); + } + + nodes.push({ + id: "ls.actions", + label: plain("Actions"), + icon: "tools", + children: [ + { id: "ls.action.refreshState", label: plain("Refresh this view"), icon: "refresh", command: { command: "mcpp.languageServer.refreshState", title: plain("Refresh") } }, + { id: "ls.action.restart", label: plain("Restart the language server"), icon: "debug-restart", command: { command: "mcpp.languageServer.restart", title: plain("Restart") } }, + { id: "ls.action.restartEngine", label: plain("Restart the semantic engine"), icon: "debug-restart", command: { command: "mcpp.languageServer.restartEngine", title: plain("Restart engine") } }, + { id: "ls.action.resetCache", label: plain("Reset this workspace's cache"), icon: "trash", command: { command: "mcpp.languageServer.resetWorkspaceCache", title: plain("Reset cache") } }, + { id: "ls.action.selectContext", label: plain("Select the analysis context"), icon: "symbol-interface", command: { command: "mcpp.languageServer.selectContext", title: plain("Select context") } }, + { id: "ls.action.graph", label: plain("Show the module graph"), icon: "type-hierarchy", command: { command: "mcpp.languageServer.showModuleGraph", title: plain("Module graph") } }, + { id: "ls.action.logs", label: plain("Open the C++ Modules log"), icon: "output", command: { command: "mcpp.languageServer.showLogs", title: plain("Logs") } }, + { id: "ls.action.report", label: plain("Collect a diagnostic report"), icon: "report", command: { command: "mcpp.languageServer.collectReport", title: plain("Report") } }, + { id: "ls.action.bundle", label: plain("Export a diagnostic bundle"), icon: "package", command: { command: "mcpp.languageServer.exportDiagnosticBundle", title: plain("Bundle") } }, + { id: "ls.action.runBuildTool", label: plain("Run the build tool in a terminal"), icon: "terminal", command: { command: "mcpp.languageServer.runBuildToolInTerminal", title: plain("Run build tool") } }, + { id: "ls.action.settings", label: plain("Open the C++ Modules settings"), icon: "settings-gear", command: { command: "mcpp.openMcpplsSettings", title: plain("Settings") } }, + ], + }); + + return nodes; +} + +function stateIcon(state: string | undefined): string { + switch (state) { + case "ready": + return "pass-filled"; + case "degraded": + return "warning"; + case "error": + return "error"; + default: + return "sync~spin"; + } +} diff --git a/test/cli/clean.test.ts b/test/cli/clean.test.ts new file mode 100644 index 0000000..dde0c6f --- /dev/null +++ b/test/cli/clean.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { planClean, planProblems, withSharedCache } from "../../src/cli/clean"; + +test("the cleanup policy has no holes", () => { + assert.deepEqual(planProblems(), []); +}); + +test("the two project levels are exactly what the plan promised", () => { + assert.deepEqual(planClean("project").argv, ["clean"]); + assert.equal(planClean("project").level, 1); + assert.deepEqual(planClean("stale", { staleDays: 7 }).argv, ["clean", "--stale", "--older-than", "7d"]); + assert.equal(planClean("stale").level, 2); + assert.equal(planClean("stale").preview, true); +}); + +test("the stale threshold defaults to three days and clamps nonsense", () => { + assert.deepEqual(planClean("stale").argv, ["clean", "--stale", "--older-than", "3d"]); + assert.deepEqual(planClean("stale", { staleDays: -4 }).argv, ["clean", "--stale", "--older-than", "0d"]); + assert.deepEqual(planClean("stale", { staleDays: Number.NaN }).argv, ["clean", "--stale", "--older-than", "3d"]); + // 0 is meaningful: keep none. + assert.deepEqual(planClean("stale", { staleDays: 0 }).argv, ["clean", "--stale", "--older-than", "0d"]); +}); + +test("the shared cache is never touched by a project-level clean", () => { + for (const action of ["project", "stale"] as const) { + assert.ok(!planClean(action).argv.includes("--bmi-cache"), action); + } +}); + +test("withSharedCache raises the danger and says so", () => { + const escalated = withSharedCache(planClean("project")); + assert.deepEqual(escalated.argv, ["clean", "--bmi-cache"]); + assert.equal(escalated.level, 3); + assert.equal(escalated.acknowledge, true); + assert.match(escalated.detailKey, /every mcpp project on this machine/); +}); + +test("withSharedCache is a no-op for anything but the project body", () => { + const stale = planClean("stale"); + assert.deepEqual(withSharedCache(stale), stale); +}); + +test("the gc budget is optional; without one mcpp is asked", () => { + assert.deepEqual(planClean("cacheGc").argv, ["cache", "gc"]); + assert.deepEqual(planClean("cacheGc", { budgetGiB: 5 }).argv, ["cache", "gc", "--max-size", "5GiB"]); + assert.deepEqual(planClean("cacheGc", { budgetGiB: 0 }).argv, ["cache", "gc"]); + assert.deepEqual(planClean("cacheGc", { budgetGiB: Number.NaN }).argv, ["cache", "gc"]); +}); + +test("prune's age defaults to thirty days and never drops below one", () => { + assert.deepEqual(planClean("cachePrune").argv, ["cache", "prune", "--older-than", "30d"]); + assert.deepEqual(planClean("cachePrune", { pruneAgeDays: 0 }).argv, ["cache", "prune", "--older-than", "1d"]); +}); + +test("cache clean --all is reachable but is the only level 3 action", () => { + const all = planClean("cacheAll"); + assert.deepEqual(all.argv, ["cache", "clean", "--all"]); + assert.equal(all.level, 3); + assert.equal(all.acknowledge, true); + assert.match(all.detailKey, /[Ee]very mcpp project on this machine/); + for (const action of ["cacheDeps", "cacheStd", "cachePrune", "cacheGc"] as const) { + assert.ok(planClean(action).level < 3, action); + } +}); + +test("verifying and listing are read-only", () => { + assert.equal(planClean("cacheVerify").level, 0); + assert.deepEqual(planClean("cacheVerify").argv, ["cache", "verify"]); + assert.equal(planClean("cacheList").level, 0); + assert.deepEqual(planClean("cacheList").argv, ["cache", "list", "--format", "json"]); +}); + +test("the legacy cleanup needs no preview because nothing rebuilds", () => { + const legacy = planClean("cacheLegacy"); + assert.deepEqual(legacy.argv, ["cache", "clean", "--legacy"]); + assert.equal(legacy.preview, false); + assert.equal(legacy.level, 2); +}); + +test("every plan runs through a trusted workspace", () => { + for (const action of ["project", "stale", "cacheGc", "cachePrune", "cacheAll", "cacheVerify"] as const) { + assert.equal(planClean(action).requiresTrust, true, action); + } +}); + +test("no argument is a shell word", () => { + for (const action of ["project", "stale", "cachePrune", "cacheGc"] as const) { + for (const part of planClean(action, { budgetGiB: 5, staleDays: 7, pruneAgeDays: 30 }).argv) { + assert.equal(part.trim(), part); + } + } +}); diff --git a/test/projects/summary.test.ts b/test/projects/summary.test.ts new file mode 100644 index 0000000..77d3599 --- /dev/null +++ b/test/projects/summary.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readProjectSummary } from "../../src/projects/summary"; + +const MANIFEST = [ + "[package]", + 'name = "greeter"', + 'version = "0.1.0"', + 'standard = "c++23"', + "# a comment with name = \"not-this\"", + "", + "[targets.greet]", + 'kind = "bin"', + 'main = "src/main.cpp"', + "", + "[targets.greet-lib]", + 'kind = "lib"', + "", + "[toolchain]", + 'spec = "llvm@22.1.8"', + "", + "[target.'cfg(os = \"linux\")']", + 'runner = "qemu"', + "", + "[test]", + 'discover = "tests/**/*.cpp"', +].join("\n") + .split("\n"); + +test("reads the identity, the toolchain and the artifact targets", () => { + const summary = readProjectSummary("/w", MANIFEST); + assert.equal(summary.name, "greeter"); + assert.equal(summary.version, "0.1.0"); + assert.equal(summary.standard, "c++23"); + assert.equal(summary.toolchainSpec, "llvm@22.1.8"); + assert.deepEqual(summary.targets, [ + { name: "greet", kind: "bin" }, + { name: "greet-lib", kind: "lib" }, + ]); + assert.equal(summary.hasTests, true); + assert.equal(summary.root, "/w"); +}); + +test("a commented-out assignment is not read", () => { + const summary = readProjectSummary("/w", ['[package]', 'name = "real"', '# name = "fake"']); + assert.equal(summary.name, "real"); +}); + +test("nested target sections do not leak into [targets]", () => { + const summary = readProjectSummary("/w", ["[targets.a]", 'kind = "bin"', "[target.'cfg(unix)']", 'runner = "x"']); + assert.deepEqual(summary.targets, [{ name: "a", kind: "bin" }]); +}); + +test("a target with no kind is still listed", () => { + const summary = readProjectSummary("/w", ["[targets.a]", 'main = "x.cpp"']); + assert.deepEqual(summary.targets, [{ name: "a", kind: "target" }]); +}); + +test("an empty or unreadable manifest yields a root only", () => { + const summary = readProjectSummary("/w", []); + assert.deepEqual(summary, { root: "/w" }); +}); + +test("single-quoted values are accepted", () => { + const summary = readProjectSummary("/w", ["[package]", "name = 'single'"]); + assert.equal(summary.name, "single"); +}); + +test("a trailing comment after a value is stripped", () => { + const summary = readProjectSummary("/w", ["[package]", 'name = "greeter" # the project']); + assert.equal(summary.name, "greeter"); +}); + +test("the default_profile and the build profile are both honoured", () => { + assert.equal(readProjectSummary("/w", ["[package]", 'default_profile = "release"']).profile, "release"); + assert.equal(readProjectSummary("/w", ["[build]", 'profile = "dev"']).profile, "dev"); +}); diff --git a/test/views/models.test.ts b/test/views/models.test.ts new file mode 100644 index 0000000..21c33ef --- /dev/null +++ b/test/views/models.test.ts @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCacheTree, + buildLanguageServerTree, + buildProjectTree, + type TreeNode, +} from "../../src/views/models"; + +function ids(nodes: readonly TreeNode[]): string[] { + return nodes.map((node) => node.id); +} + +function find(nodes: readonly TreeNode[], id: string): TreeNode | undefined { + for (const node of nodes) { + if (node.id === id) return node; + const hit = node.children === undefined ? undefined : find(node.children, id); + if (hit !== undefined) return hit; + } + return undefined; +} + +test("an empty workspace says so and suggests the fix", () => { + const tree = buildProjectTree(undefined); + assert.deepEqual(ids(tree), ["project.none"]); + assert.match(tree[0].tooltip?.key ?? "", /mcpp: New Project/); +}); + +test("an unreadable manifest is reported instead of half-rendered", () => { + const tree = buildProjectTree({ root: "/w", error: "bad TOML" }); + assert.equal(tree[0].id, "project.error"); + assert.equal(tree[0].icon, "error"); + assert.deepEqual(tree[0].description?.args, ["bad TOML"]); +}); + +test("the project tree carries identity, toolchain and the four actions", () => { + const tree = buildProjectTree({ + root: "/w", + name: "greeter", + version: "0.1.0", + standard: "c++23", + profile: "debug", + toolchainSpec: "llvm@22.1.8", + target: "x86_64-unknown-linux-gnu", + targets: [{ name: "greet", kind: "bin" }], + }); + assert.deepEqual(ids(tree), ["project.identity", "project.toolchain", "project.action.build", "project.action.run", "project.action.test", "project.action.clean"]); + assert.deepEqual(find(tree, "project.identity")?.label.args, ["greeter", "0.1.0"]); + assert.deepEqual(find(tree, "project.standard")?.description?.args, ["c++23"]); + assert.deepEqual(find(tree, "project.toolchain")?.description?.args, ["llvm@22.1.8"]); + assert.equal(find(tree, "project.target.greet")?.description?.args?.[0], "bin"); + assert.equal(find(tree, "project.action.build")?.command?.command, "mcpp.build"); + assert.equal(find(tree, "project.action.clean")?.command?.command, "mcpp.cleanProjectArtifacts"); +}); + +test("a project without a toolchain says host default rather than inventing one", () => { + const tree = buildProjectTree({ root: "/w" }); + assert.deepEqual(find(tree, "project.toolchain")?.description?.args, ["host default"]); + assert.equal(find(tree, "project.standard"), undefined); +}); + +test("the cache tree separates project artifacts from the shared cache", () => { + const tree = buildCacheTree({ projectRoot: "/w/target" }); + assert.deepEqual(ids(tree), ["cache.project", "cache.global.unknown"]); + assert.equal(find(tree, "cache.project")?.contextValue, "mcppCacheProject"); + assert.equal(find(tree, "cache.global.unknown")?.command?.command, "mcpp.refreshCacheStats"); +}); + +test("an unmeasured target/ offers to measure rather than showing a zero", () => { + const tree = buildCacheTree({}); + assert.equal(find(tree, "cache.project.unread")?.command?.command, "mcpp.refreshCacheStats"); +}); + +test("a missing target/ is stated, not shown as 0 bytes", () => { + const tree = buildCacheTree({ artifacts: { exists: false, totalBytes: 0, files: 0, groups: 0 } }); + assert.equal(find(tree, "cache.project.absent")?.label.key, "No target/ directory"); +}); + +test("a truncated estimate is labelled a lower bound", () => { + const tree = buildCacheTree({ + artifacts: { exists: true, totalBytes: 1024, files: 3, groups: 2, truncated: "entries" }, + }); + assert.ok(find(tree, "cache.project.truncated")); + assert.equal(find(tree, "cache.project.stale")?.command?.command, "mcpp.cleanStaleArtifacts"); +}); + +test("the global cache shows kinds, ages, the largest packages and the actions", () => { + const tree = buildCacheTree({ + inventory: { + root: "/home/u/.mcpp/build-cache/v1", + totalBytes: 7_736_306_884, + totalEntries: 657, + byKind: [ + { kind: "pkg", entries: 576, bytes: 7_000_000_000 }, + { kind: "std", entries: 81, bytes: 736_306_884 }, + ], + topLabels: [{ label: "ns/name@1.0.0", entries: 5, bytes: 1_200_000_000 }], + incomplete: 2, + ageBuckets: [ + { fromDays: 0, toDays: 1, entries: 1, bytes: 10 }, + { fromDays: 30, entries: 2, bytes: 20 }, + ], + }, + }); + assert.equal(find(tree, "cache.kind.pkg")?.description?.args?.[1], "576"); + assert.equal(find(tree, "cache.kind.std")?.icon, "library"); + assert.equal(find(tree, "cache.age.1")?.label.key, "more than {0} day(s) ago"); + assert.equal(find(tree, "cache.top.ns/name@1.0.0")?.command?.command, "mcpp.showCacheEntry"); + assert.deepEqual(find(tree, "cache.top.ns/name@1.0.0")?.command?.arguments, ["ns/name@1.0.0"]); + assert.ok(find(tree, "cache.incomplete")); + for (const id of ["cache.action.refresh", "cache.action.panel", "cache.action.gc", "cache.action.prune", "cache.action.verify"]) { + assert.ok(find(tree, id), id); + } +}); + +test("an empty cache has no largest-packages node", () => { + const tree = buildCacheTree({ + inventory: { root: "/c", totalBytes: 0, totalEntries: 0, byKind: [], topLabels: [], incomplete: 0, ageBuckets: [] }, + }); + assert.equal(find(tree, "cache.top"), undefined); + assert.equal(find(tree, "cache.incomplete"), undefined); +}); + +test("a pre-v1 cache is offered for removal only when it exists", () => { + assert.equal(find(buildCacheTree({ legacyBytes: 0 }), "cache.legacy"), undefined); + const tree = buildCacheTree({ legacyBytes: 175_000_000 }); + assert.equal(find(tree, "cache.legacy")?.command?.command, "mcpp.cleanLegacyCache"); +}); + +test("the C++ Modules view offers to install when the dependency is absent", () => { + const tree = buildLanguageServerTree({ installed: false }); + assert.equal(tree[0].id, "ls.absent"); + assert.equal(tree[0].command?.command, "mcpp.openMcpplsSettings"); +}); + +test("an unreadable state is stated without pretending to know the status", () => { + const tree = buildLanguageServerTree({ installed: true, state: { available: false, reason: "no status yet" } }); + assert.deepEqual(find(tree, "ls.status")?.description?.args, ["no status yet"]); + assert.equal(find(tree, "ls.status")?.icon, "question"); + assert.ok(find(tree, "ls.actions")); +}); + +test("a ready status renders profile, database and engines", () => { + const tree = buildLanguageServerTree({ + installed: true, + version: "0.0.9", + enabled: true, + state: { + available: true, + state: "ready", + project: { source: "mcpp", level: 3 }, + profile: { compiler: "clang 22.1.8", stdlib: "libc++", target: "x86_64-linux-gnu" }, + engine: { name: "clangd", version: "23.1.0" }, + engines: [ + { name: "clangd", version: "23.1.0", role: "core", state: "ready" }, + { name: "mcppls", version: "0.0.9", role: "modules", state: "ready" }, + ], + }, + }); + assert.equal(find(tree, "ls.status")?.icon, "pass-filled"); + assert.deepEqual(find(tree, "ls.database")?.description?.args, ["mcpp"]); + assert.equal(find(tree, "ls.engines")?.children?.length, 2); + assert.equal(find(tree, "ls.engine.clangd")?.icon, "check"); +}); + +test("an issue carrying S3's own remedy becomes a clickable node", () => { + const tree = buildLanguageServerTree({ + installed: true, + state: { + available: true, + state: "degraded", + issues: [ + { + code: "producer-needs-download", + message: "needs a download", + command: { command: "mcppls.describeOnline", arguments: [], title: "Allow" }, + }, + ], + }, + }); + const issue = find(tree, "ls.issue.0.producer-needs-download"); + assert.equal(issue?.command?.command, "mcppls.describeOnline"); + assert.deepEqual(issue?.command?.title, { key: "{0}", args: ["Allow"] }); +}); + +test("an issue without a remedy stays informative and non-clickable", () => { + const tree = buildLanguageServerTree({ + installed: true, + state: { available: true, state: "degraded", issues: [{ code: "x", message: "y" }] }, + }); + assert.equal(find(tree, "ls.issue.0.x")?.command, undefined); +}); + +test("a disabled workspace says so next to the version", () => { + const tree = buildLanguageServerTree({ installed: true, version: "0.0.9", enabled: false }); + assert.match(String(find(tree, "ls.identity")?.description?.args?.[1]), /disabled here/); +}); From 4c85f95624bf49a96a16e1a300e4645e153ea1f8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:43:33 +0800 Subject: [PATCH 05/56] feat(buildscript): mcpp build-script API snapshot and self-contained intelligence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mode A of §3.2 of the plugin-optimisation plan: `build.mcpp` keeps the `mcpp-build` language id and the C++ language service never sees it, so this is the intelligence layer that replaces it — pure data, scanning and analysis, no clangd and no "module not found". - tools/generate-buildscript-api.mjs reads the mcpp checkout (MCPP_REPO, default ../mcpp) and writes data/buildscript-api.json: the 31-row kTable, the five roles, kProtocolVersion/kCacheEpoch, the provision kinds, the SPEC-007 rule ids that mention each wire name, and a docs anchor per heading that spells the wire name. Any unreadable source, a parse that yields zero rows or a count that disagrees with the declared array size exits 1 without writing. - src/buildscript/api.ts (snapshot + invariants), modules.ts (import scanner and known modules), analysis.ts (the seven SPEC-007 static rules) never import vscode. - src/buildscript/providers.ts is the one-line integration: completion, hover and a diagnostic collection for { language: "mcpp-build" }. - test/buildscript/* matches the snapshot against the mcpp source (row count and every wire name), covers import scanning through comments/strings, and has a positive and negative case for each of the seven rules (28 tests). --- data/buildscript-api.json | 432 ++++++++++++++++++ src/buildscript/analysis.ts | 674 +++++++++++++++++++++++++++++ src/buildscript/api.ts | 111 +++++ src/buildscript/modules.ts | 294 +++++++++++++ src/buildscript/providers.ts | 175 ++++++++ test/buildscript/analysis.test.ts | 199 +++++++++ test/buildscript/api.test.ts | 113 +++++ test/buildscript/modules.test.ts | 118 +++++ tools/generate-buildscript-api.mjs | 370 ++++++++++++++++ 9 files changed, 2486 insertions(+) create mode 100644 data/buildscript-api.json create mode 100644 src/buildscript/analysis.ts create mode 100644 src/buildscript/api.ts create mode 100644 src/buildscript/modules.ts create mode 100644 src/buildscript/providers.ts create mode 100644 test/buildscript/analysis.test.ts create mode 100644 test/buildscript/api.test.ts create mode 100644 test/buildscript/modules.test.ts create mode 100644 tools/generate-buildscript-api.mjs diff --git a/data/buildscript-api.json b/data/buildscript-api.json new file mode 100644 index 0000000..be38ed7 --- /dev/null +++ b/data/buildscript-api.json @@ -0,0 +1,432 @@ +{ + "sourceVersion": "2026.10.1.3", + "sourceCommit": "4d81d062", + "protocolVersion": 15, + "cacheEpoch": 3, + "roles": [ + "source", + "check", + "object", + "artifact", + "prepare" + ], + "provisions": [ + { + "kind": "tool", + "wire": "tool" + }, + { + "kind": "host-module", + "wire": "host-module" + }, + { + "kind": "dep-dir", + "wire": "dep-dir" + } + ], + "directives": [ + { + "wire": "cxxflag", + "tag": "cxxflag", + "slot": "CxxFlags", + "scope": "PackagePrivate", + "transform": "Verbatim", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "cflag", + "tag": "cflag", + "slot": "CFlags", + "scope": "PackagePrivate", + "transform": "Verbatim", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "link-lib", + "tag": "ldflag", + "slot": "LdFlags", + "scope": "LinkGlobal", + "transform": "LibFlag", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "link-search", + "tag": "ldflag", + "slot": "LdFlags", + "scope": "LinkGlobal", + "transform": "LibSearchPath", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "cfg", + "tag": "define", + "slot": "Defines", + "scope": "PackagePrivate", + "transform": "DefinePrefix", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "generated", + "tag": "generated", + "slot": "Generated", + "scope": "SourceSet", + "transform": "Verbatim", + "mustExist": true, + "missingPrefix": "declared generated source", + "missingSuffix": "but it does not exist after the run", + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "source", + "tag": "source", + "slot": "Sources", + "scope": "SourceSet", + "transform": "Verbatim", + "mustExist": true, + "missingPrefix": "selected source", + "missingSuffix": "(mcpp:source=) but no such file exists", + "since": 1, + "rules": [ + "R2.1", + "R3.3", + "R3.6" + ], + "docsUrl": null + }, + { + "wire": "runner", + "tag": "runner", + "slot": "Runner", + "scope": "RunGlobal", + "transform": "Verbatim", + "mustExist": false, + "since": 4, + "rules": [], + "docsUrl": "https://github.com/mcpp-community/mcpp/blob/4d81d062/docs/30-build-mcpp.md#runner-how-the-artifact-is-executed-20268192" + }, + { + "wire": "runner-named", + "tag": "runner-named", + "slot": "NamedRunner", + "scope": "RunGlobal", + "transform": "Verbatim", + "mustExist": false, + "since": 6, + "rules": [], + "docsUrl": null + }, + { + "wire": "runner-longlived", + "tag": "runner-longlived", + "slot": "RunnerLongLived", + "scope": "RunGlobal", + "transform": "Verbatim", + "mustExist": false, + "since": 6, + "rules": [], + "docsUrl": null + }, + { + "wire": "run-exclusive", + "tag": "run-exclusive", + "slot": "RunExclusive", + "scope": "RunGlobal", + "transform": "Verbatim", + "mustExist": false, + "since": 6, + "rules": [], + "docsUrl": null + }, + { + "wire": "link-script", + "tag": "ldflag", + "slot": "LdFlags", + "scope": "LinkGlobal", + "transform": "LinkerScript", + "mustExist": false, + "since": 3, + "rules": [], + "docsUrl": null + }, + { + "wire": "link-flag", + "tag": "ldflag", + "slot": "LdFlags", + "scope": "LinkGlobal", + "transform": "Verbatim", + "mustExist": false, + "since": 8, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "include-dir", + "tag": "include-dir", + "slot": "IncludeDirs", + "scope": "PackagePrivate", + "transform": "AbsPath", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "include-dir-after", + "tag": "include-dir-after", + "slot": "IncludeDirsAfter", + "scope": "PackagePrivate", + "transform": "AbsPath", + "mustExist": false, + "since": 1, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "rerun-if-changed", + "tag": "", + "slot": "RerunFiles", + "scope": "RerunKey", + "transform": "Verbatim", + "mustExist": false, + "since": 1, + "rules": [], + "docsUrl": null + }, + { + "wire": "rerun-if-env-changed", + "tag": "", + "slot": "RerunEnv", + "scope": "RerunKey", + "transform": "Verbatim", + "mustExist": false, + "since": 1, + "rules": [], + "docsUrl": null + }, + { + "wire": "rerun-if-changed-glob", + "tag": "", + "slot": "RerunGlobs", + "scope": "RerunKey", + "transform": "Verbatim", + "mustExist": false, + "since": 2, + "rules": [], + "docsUrl": null + }, + { + "wire": "warning", + "tag": "warning", + "slot": "Warnings", + "scope": "Advisory", + "transform": "Verbatim", + "mustExist": false, + "since": 5, + "rules": [ + "R1.2", + "R2.1", + "R9.4" + ], + "docsUrl": "https://github.com/mcpp-community/mcpp/blob/4d81d062/docs/30-build-mcpp.md#warning-succeeding-and-still-being-heard-20268212" + }, + { + "wire": "diagnostic", + "tag": "diagnostic", + "slot": "Diagnostics", + "scope": "Advisory", + "transform": "Verbatim", + "mustExist": false, + "since": 14, + "rules": [], + "docsUrl": null + }, + { + "wire": "action", + "tag": "action", + "slot": "Actions", + "scope": "GraphNode", + "transform": "Verbatim", + "mustExist": false, + "since": 1, + "rules": [ + "R1.1", + "R1.2", + "R1.3", + "R2.1", + "R3.1", + "R3.2", + "R3.3", + "R3.5", + "R3.7", + "R3.8", + "R5.1", + "R5.3", + "R6.4", + "R9.2" + ], + "docsUrl": null + }, + { + "wire": "fact", + "tag": "fact", + "slot": "Facts", + "scope": "Claim", + "transform": "Verbatim", + "mustExist": false, + "since": 7, + "rules": [ + "R2.1" + ], + "docsUrl": "https://github.com/mcpp-community/mcpp/blob/4d81d062/docs/30-build-mcpp.md#the-probe-channel-fact-floor-2026952" + }, + { + "wire": "floor", + "tag": "floor", + "slot": "Floors", + "scope": "Claim", + "transform": "Verbatim", + "mustExist": false, + "since": 7, + "rules": [ + "R2.1" + ], + "docsUrl": "https://github.com/mcpp-community/mcpp/blob/4d81d062/docs/30-build-mcpp.md#the-probe-channel-fact-floor-2026952" + }, + { + "wire": "pack-format", + "tag": "pack-format", + "slot": "PackFormats", + "scope": "Claim", + "transform": "Verbatim", + "mustExist": false, + "since": 9, + "rules": [], + "docsUrl": null + }, + { + "wire": "windows-subsystem", + "tag": "windows-subsystem", + "slot": "WindowsSubsystem", + "scope": "TargetLink", + "transform": "Verbatim", + "mustExist": false, + "since": 10, + "rules": [], + "docsUrl": null + }, + { + "wire": "windows-entry", + "tag": "windows-entry", + "slot": "WindowsEntry", + "scope": "TargetLink", + "transform": "Verbatim", + "mustExist": false, + "since": 10, + "rules": [], + "docsUrl": null + }, + { + "wire": "deploy", + "tag": "deploy", + "slot": "Deploy", + "scope": "LinkGlobal", + "transform": "Deploy", + "mustExist": false, + "since": 11, + "rules": [ + "R2.1", + "R4.2", + "R9.2", + "R9.5" + ], + "docsUrl": "https://github.com/mcpp-community/mcpp/blob/4d81d062/docs/30-build-mcpp.md#deploying-what-the-program-generated-deploy-20269123-protocol-11" + }, + { + "wire": "runtime-search-dir", + "tag": "runtime-search-dir", + "slot": "RuntimeSearchDir", + "scope": "LinkGlobal", + "transform": "AbsPath", + "mustExist": false, + "since": 12, + "rules": [ + "R2.1" + ], + "docsUrl": null + }, + { + "wire": "decision", + "tag": "decision", + "slot": "ToolDecisions", + "scope": "Advisory", + "transform": "Verbatim", + "mustExist": false, + "since": 15, + "rules": [ + "R6.5", + "R9.2" + ], + "docsUrl": null + }, + { + "wire": "xpkg-request", + "tag": "xpkg-request", + "slot": "XpkgRequests", + "scope": "Claim", + "transform": "Verbatim", + "mustExist": false, + "since": 15, + "rules": [], + "docsUrl": null + }, + { + "wire": "toolchain", + "tag": "toolchain", + "slot": "ToolchainStatement", + "scope": "Claim", + "transform": "Verbatim", + "mustExist": false, + "since": 15, + "rules": [ + "R9.2", + "R9.9" + ], + "docsUrl": null + } + ] +} diff --git a/src/buildscript/analysis.ts b/src/buildscript/analysis.ts new file mode 100644 index 0000000..4f4af8e --- /dev/null +++ b/src/buildscript/analysis.ts @@ -0,0 +1,674 @@ +/** + * Static analysis for `build.mcpp`, built on the snapshot in `api.ts` and on + * nothing else. No compiler runs, nothing is executed, no workspace is read, so + * this is usable in an untrusted workspace and cannot disagree with the build + * because of a stale compilation database. + * + * ## What this deliberately cannot see + * + * It is **line-oriented, not a parser**. There is no preprocessor, no macro + * expansion, no `#include` graph, no type information, and no evaluation of a + * condition that guards a call. A string built by concatenation or format is + * invisible. Positions are exact for the spellings below and approximate (the + * whole string literal) when escapes make the offset ambiguous. + * + * The one cross-line heuristic is the action block: from a `mcpp::action` line + * forward to `.submit(`, up to 40 lines. A role or `output_dir` set outside that + * window is not seen. + * + * Every rule is kept conservative on purpose: a false positive erodes trust + * faster than a missed hint. When a spelling is uncertain — a role held in a + * variable, a flag built at run time, a payload that does not parse — the rule + * says nothing rather than guessing. + * + * ## Positions + * + * `line` is 1-based, and `startCharacter` / `endCharacter` are 1-based columns + * (the first character of a line is column 1), so a diagnostic prints directly + * as `file:line:column`. + * + * ## Severity + * + * The engine does not distinguish here; the caller's setting picks one severity + * for the whole file (`mcpp.buildScript.diagnostics.severity`). + */ +import { ACTION_ROLES, API } from "./api"; + +export type Severity = "error" | "warning" | "info" | "off"; + +export interface BuildScriptDiagnostic { + code: string; + message: string; + severity: Exclude; + /** 1-based line number. */ + line: number; + /** 1-based column of the first character. */ + startCharacter: number; + /** 1-based column one past the last character. */ + endCharacter: number; +} + +/** The seven rule ids, as language-server code strings. */ +export const DIAGNOSTIC_CODES = { + unknownSymbol: "mcpp.buildscript.unknownSymbol", + roleLiteral: "mcpp.buildscript.roleLiteral", + prepareNeedsOutputDir: "mcpp.buildscript.prepareNeedsOutputDir", + rpathInLinkFlag: "mcpp.buildscript.rpathInLinkFlag", + rawPathFlag: "mcpp.buildscript.rawPathFlag", + shellSyntaxInAction: "mcpp.buildscript.shellSyntaxInAction", + actionNeedsRole: "mcpp.buildscript.actionNeedsRole", +} as const; + +/** + * The typed-API names `docs/30-build-mcpp.md` lists (its `import mcpp;` table, + * the accessor tables, and the `mcpp::action` members). Used only to *not* + * flag a `mcpp::` name: when a name here is wrong the cost is a missed hint, + * never a false one, which is why the list errs towards inclusion. Directive + * wire names are added from the snapshot separately (`link-lib` -> `link_lib`), + * so a directive that reaches the typed surface is known without being listed. + */ +export const TYPED_API_NAMES: readonly string[] = [ + "abi_tool", + "accel", + "action", + "cflag", + "cxx_runtime", + "cxx_stdlib", + "cxxflag", + "decision", + "define", + "dep_bin", + "dep_dir", + "dep_linkage", + "deploy", + "device_sources", + "fact", + "floor", + "generated", + "graph_file", + "has_feature", + "host", + "include_dir", + "include_dir_after", + "link_flag", + "link_lib", + "link_script", + "link_search", + "manifest_dir", + "min_platform_version", + "msvc_crt_linkage", + "msvc_instance_dir", + "ninja_program", + "out_dir", + "pack_debug_symbols_dir", + "pack_format", + "pack_stage_dir", + "pack_strip", + "package_authors", + "package_description", + "package_license", + "package_repo", + "package_version", + "phase", + "pkg_config_libdir", + "plugins", + "profile", + "provision", + "provides_pack_format", + "report", + "rerun_if_changed", + "rerun_if_changed_glob", + "rerun_if_env_changed", + "roles", + "runner", + "runtime_search_dir", + "source", + "stage_dir", + "sysroot_dir", + "target", + "target_arch", + "target_env", + "target_os", + "tool", + "tool_env", + "toolchain", + "toolchain_binutils_dir", + "toolchain_dir", + "toolchain_sysroot", + "tools", + "toolset_identity", + "warning", + "windows_entry", + "windows_subsystem", + "xpkg_dir", + "xpkg_pending", + "xpkg_program", + "xpkg_request", + "xpkg_source", +]; + +/** The five action roles. Constant, so a malformed snapshot cannot disable a rule. */ +const ROLE_NAMES: ReadonlySet = new Set(ACTION_ROLES); + +const KNOWN_SYMBOLS: ReadonlySet = (() => { + const names = new Set(TYPED_API_NAMES); + for (const role of ACTION_ROLES) { + names.add(role); + } + for (const entry of API.directives) { + names.add(entry.wire); + names.add(entry.wire.replace(/-/g, "_")); + } + return names; +})(); + +/** How far past `mcpp::action` a role or `output_dir` may appear. */ +const ACTION_WINDOW = 40; + +const RAWPATH = /(?:^|\s)(-[IL]\S*)/; +const RPATH = "-Wl,-rpath"; +const SHELL_ENV = /(?:^|\s)([A-Za-z_][A-Za-z0-9_]*=\S*)/; +const SHELL_CD = /(?:^|\s)(cd\s+\S+\s*&&)/; +const IMPORT_ACTION = "mcpp:action="; + +interface StringLiteral { + /** The characters the literal denotes. */ + value: string; + /** 0-based column of the opening quote. */ + start: number; + /** 0-based column one past the closing quote. */ + end: number; + /** The source text including quotes, escape sequences intact. */ + raw: string; +} + +interface MaskedLine { + /** The source line with comment and string-literal characters blanked. */ + code: string; + strings: StringLiteral[]; +} + +interface ScanState { + blockComment: boolean; + rawDelim: string | null; +} + +function unescape(raw: string): string { + let out = ""; + for (let i = 0; i < raw.length; i += 1) { + if (raw[i] !== "\\") { + out += raw[i]; + continue; + } + const next = raw[i + 1]; + if (next === "n") out += "\n"; + else if (next === "t") out += "\t"; + else if (next === "r") out += "\r"; + else if (next === undefined) out += "\\"; + else out += next; + i += 1; + } + return out; +} + +/** + * Split one line into real code plus the string literals it contains: every + * character inside a comment or a literal becomes a space in `code`, so a match + * on `code` is a match on code. Indices are preserved. `state` carries the two + * constructs that outlive a line, a block comment and a raw string. + */ +function maskLine(line: string, state: ScanState): MaskedLine { + const out = line.split(""); + const strings: StringLiteral[] = []; + const blank = (from: number, to: number): void => { + for (let i = Math.max(0, from); i < Math.min(to, line.length); i += 1) { + out[i] = " "; + } + }; + + let i = 0; + if (state.rawDelim !== null) { + const terminator = `)${state.rawDelim}"`; + const end = line.indexOf(terminator); + if (end < 0) { + return { code: " ".repeat(line.length), strings }; + } + blank(0, end + terminator.length); + i = end + terminator.length; + state.rawDelim = null; + } + + while (i < line.length) { + if (state.blockComment) { + const end = line.indexOf("*/", i); + if (end < 0) { + blank(i, line.length); + break; + } + blank(i, end + 2); + i = end + 2; + state.blockComment = false; + continue; + } + const pair = line.slice(i, i + 2); + if (pair === "//") { + blank(i, line.length); + break; + } + if (pair === "/*") { + state.blockComment = true; + i += 2; + continue; + } + if (line[i] === "R" && line[i + 1] === '"') { + const open = /^R"([^(\s]{0,16})\(/.exec(line.slice(i)); + if (open !== null) { + const terminator = `)${open[1]}"`; + const end = line.indexOf(terminator, i + open[0].length); + if (end < 0) { + state.rawDelim = open[1]; + blank(i, line.length); + break; + } + blank(i, end + terminator.length); + i = end + terminator.length; + continue; + } + } + if (line[i] === "'" && /[A-Za-z0-9_]/.test(line[i - 1] ?? "")) { + // A `'` right after an identifier character is a digit separator (1'000), + // not the start of a character literal; scanning for a closing quote would + // swallow the rest of the line. + i += 1; + continue; + } + if (line[i] === '"' || line[i] === "'") { + const quote = line[i]; + let j = i + 1; + while (j < line.length) { + if (line[j] === "\\") { + j += 2; + continue; + } + if (line[j] === quote) { + j += 1; + break; + } + j += 1; + } + const stop = Math.min(j, line.length); + strings.push({ + value: unescape(line.slice(i + 1, Math.max(i + 1, stop - 1))), + start: i, + end: stop, + raw: line.slice(i, stop), + }); + blank(i, stop); + i = stop; + continue; + } + i += 1; + } + + return { code: out.join(""), strings }; +} + +/** The narrowest honest span for `needle` inside a literal, or the literal itself. */ +function literalSpan(literal: StringLiteral, needle: string): { start: number; end: number } { + if (needle.length > 0) { + const at = literal.raw.indexOf(needle); + if (at >= 0) { + return { start: literal.start + at, end: literal.start + at + needle.length }; + } + } + return { start: literal.start, end: literal.end }; +} + +interface RoleSite { + text: string; + literal: boolean; + line: number; + start: number; + end: number; +} + +interface ActionBlock { + line: number; + actionStart: number; + actionEnd: number; + role: RoleSite | null; + hasRoleField: boolean; + hasOutputDir: boolean; +} + +/** `mcpp::action` … `.submit()` windows, with the role and `output_dir` they declare. */ +function findActionBlocks(masked: readonly MaskedLine[]): ActionBlock[] { + const blocks: ActionBlock[] = []; + for (let i = 0; i < masked.length; i += 1) { + const token = /\bmcpp::action\b/.exec(masked[i].code); + if (token === null) { + continue; + } + const limit = Math.min(masked.length - 1, i + ACTION_WINDOW); + let end = limit; + for (let j = i; j <= limit; j += 1) { + if (/\.submit\s*\(/.test(masked[j].code)) { + end = j; + break; + } + } + const block: ActionBlock = { + line: i, + actionStart: token.index, + actionEnd: token.index + token[0].length, + role: null, + hasRoleField: false, + hasOutputDir: false, + }; + for (let j = i; j <= end; j += 1) { + const { code, strings } = masked[j]; + if (!block.hasOutputDir && /\.output_dir\s*\(/.test(code)) { + block.hasOutputDir = true; + } + const constant = /mcpp::roles::([A-Za-z_]\w*)/.exec(code); + if (constant !== null && block.role === null) { + block.hasRoleField = true; + block.role = { + text: constant[1], + literal: false, + line: j, + start: constant.index, + end: constant.index + constant[0].length, + }; + } + if (block.role === null) { + const assign = /\brole\s*=/.exec(code); + if (assign !== null) { + block.hasRoleField = true; + const after = assign.index + assign[0].length; + const literal = strings.find( + (entry) => entry.start >= after && code.slice(after, entry.start).trim() === "", + ); + if (literal !== undefined) { + block.role = { + text: literal.value, + literal: true, + line: j, + start: literal.start, + end: literal.end, + }; + } + } + } + } + blocks.push(block); + } + return blocks; +} + +/** + * Every diagnostic the text earns, all at `severity`, sorted by position. + * + * `lines` are the file's lines without their terminators (`text.split(/\r?\n/)`). + */ +export function analyseBuildScript( + lines: readonly string[], + severity: Exclude, +): BuildScriptDiagnostic[] { + const masked: MaskedLine[] = []; + const state: ScanState = { blockComment: false, rawDelim: null }; + for (const line of lines) { + masked.push(maskLine(line, state)); + } + + const found: BuildScriptDiagnostic[] = []; + const report = (code: string, message: string, line: number, start: number, end: number): void => { + found.push({ + code, + message, + severity, + line: line + 1, + startCharacter: start + 1, + endCharacter: end + 1, + }); + }; + + // 1. `mcpp::` that the engine does not have. + for (let i = 0; i < masked.length; i += 1) { + const symbols = /\bmcpp::([A-Za-z_][A-Za-z0-9_]*)/g; + for (let match = symbols.exec(masked[i].code); match !== null; match = symbols.exec(masked[i].code)) { + if (KNOWN_SYMBOLS.has(match[1])) { + continue; + } + report( + DIAGNOSTIC_CODES.unknownSymbol, + `unknown mcpp::${match[1]}: not a directive, action role or typed-API name in the ` + + `mcpp build-script snapshot (SPEC-007 §2; docs/30-build-mcpp.md)`, + i, + match.index, + match.index + match[0].length, + ); + } + } + + // 2. `role = ""` where the engine's constant exists (R3.6). + const blocks = findActionBlocks(masked); + for (let i = 0; i < masked.length; i += 1) { + const assign = /\brole\s*=/g; + for (let match = assign.exec(masked[i].code); match !== null; match = assign.exec(masked[i].code)) { + const after = match.index + match[0].length; + const literal = masked[i].strings.find( + (entry) => entry.start >= after && masked[i].code.slice(after, entry.start).trim() === "", + ); + if (literal === undefined) { + continue; + } + if (ROLE_NAMES.has(literal.value)) { + report( + DIAGNOSTIC_CODES.roleLiteral, + `role is spelled as the string "${literal.value}"; use mcpp::roles::${literal.value} ` + + `so an engine that does not know the role refuses to compile it (SPEC-007 R3.6)`, + i, + literal.start, + literal.end, + ); + } else { + report( + DIAGNOSTIC_CODES.actionNeedsRole, + `unknown action role "${literal.value}"; the five roles are ` + + `${ACTION_ROLES.join(", ")} (SPEC-007 R3.6)`, + i, + literal.start, + literal.end, + ); + } + } + } + + // 3 + 7 (typed form). A block with a role this pass already reported (an unknown + // literal) is not reported twice; a block whose role is a variable is not + // reported at all. + for (const block of blocks) { + if (!block.hasRoleField) { + report( + DIAGNOSTIC_CODES.actionNeedsRole, + "this mcpp::action declares no role; name one of " + + `${ACTION_ROLES.join(", ")} (mcpp::roles::…) — a missing role is read as "source" ` + + `(SPEC-007 R3.2, R3.6)`, + block.line, + block.actionStart, + block.actionEnd, + ); + continue; + } + if (block.role === null || block.role.text !== "prepare" || block.hasOutputDir) { + continue; + } + report( + DIAGNOSTIC_CODES.prepareNeedsOutputDir, + "a prepare action must declare the directory its command fills with output_dir(…): " + + "the engine refuses an action without one (SPEC-007 R3.3)", + block.role.line, + block.role.start, + block.role.end, + ); + } + + // 3 + 6 + 7 (raw `mcpp:action=` payload; the frozen printf surface). A payload + // that does not parse is skipped rather than guessed at, and a role spelled as + // a string is *not* rule 2 here: docs/30 explicitly allows the string on this + // surface. + for (let i = 0; i < masked.length; i += 1) { + for (const literal of masked[i].strings) { + const at = literal.value.indexOf(IMPORT_ACTION); + if (at < 0) { + continue; + } + let payload: unknown; + try { + payload = JSON.parse(literal.value.slice(at + IMPORT_ACTION.length).trim()); + } catch { + continue; + } + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + continue; + } + const record = payload as Record; + const role = record.role; + if (typeof role !== "string") { + report( + DIAGNOSTIC_CODES.actionNeedsRole, + `this mcpp:action payload declares no role; the five roles are ` + + `${ACTION_ROLES.join(", ")} (SPEC-007 R3.6)`, + i, + literal.start, + literal.end, + ); + } else if (!ROLE_NAMES.has(role)) { + report( + DIAGNOSTIC_CODES.actionNeedsRole, + `unknown action role "${role}"; the five roles are ${ACTION_ROLES.join(", ")} ` + + `(SPEC-007 R3.6)`, + i, + literal.start, + literal.end, + ); + } else if (role === "prepare" && typeof record.output_dir !== "string") { + report( + DIAGNOSTIC_CODES.prepareNeedsOutputDir, + "a prepare action must declare output_dir: the engine refuses an action without " + + "one (SPEC-007 R3.3)", + i, + literal.start, + literal.end, + ); + } + const command = record.command; + if (Array.isArray(command)) { + for (const entry of command) { + if (typeof entry !== "string") { + continue; + } + const needle = /(?:^|\s)([A-Za-z_][A-Za-z0-9_]*=\S*)/.exec(entry)?.[1] + ?? /(?:^|\s)(cd\s+\S+\s*&&)/.exec(entry)?.[1]; + if (needle !== undefined) { + report( + DIAGNOSTIC_CODES.shellSyntaxInAction, + `the command spells shell syntax ("${needle}"); declare it with env(…) and ` + + `cwd(…) instead (SPEC-007 R3.8)`, + i, + literal.start, + literal.end, + ); + break; + } + } + } + } + } + + // 4 + 5. Flags that R2.1/R4.4 send to directives. + for (let i = 0; i < masked.length; i += 1) { + const { code, strings } = masked[i]; + const isCxxFlagCall = /(?:^|[^\w])(?:cxxflag|cflag)\s*\(/.test(code); + const isLinkFlagCall = /(?:^|[^\w])link_flag\s*\(/.test(code); + for (const literal of strings) { + const value = literal.value; + const isLinkFlag = isLinkFlagCall || value.includes("mcpp:link-flag="); + const isCxxFlag = isCxxFlagCall || value.includes("mcpp:cxxflag=") || value.includes("mcpp:cflag="); + if (isLinkFlag && value.includes(RPATH)) { + report( + DIAGNOSTIC_CODES.rpathInLinkFlag, + "a run path must not be spelled in link_flag; declare the directory with " + + "mcpp::runtime_search_dir(…) (SPEC-007 R4.4)", + i, + literalSpan(literal, RPATH).start, + literalSpan(literal, RPATH).end, + ); + } + if (isLinkFlag || isCxxFlag) { + const token = RAWPATH.exec(value)?.[1]; + if (token !== undefined) { + const span = literalSpan(literal, token); + report( + DIAGNOSTIC_CODES.rawPathFlag, + `spelling "${token}" in a raw flag bypasses the engine's rendering; use ` + + `mcpp::include_dir / mcpp::include_dir_after / mcpp::link_search (SPEC-007 R2.1)`, + i, + span.start, + span.end, + ); + } + } + } + } + + // 6 (typed form). Only strings that are arguments of `.arg()`/`.command()` in + // an action block, so an `env()` value or an unrelated string cannot match. + const actionLines = new Set(); + for (const block of blocks) { + for (let i = block.line; i < masked.length; i += 1) { + actionLines.add(i); + if (/\.submit\s*\(/.test(masked[i].code)) { + break; + } + } + } + for (let i = 0; i < masked.length; i += 1) { + if (!actionLines.has(i) || !/\.(?:arg|command)\s*\(/.test(masked[i].code)) { + continue; + } + for (const literal of masked[i].strings) { + const needle = SHELL_ENV.exec(literal.value)?.[1] ?? SHELL_CD.exec(literal.value)?.[1]; + if (needle === undefined) { + continue; + } + const span = literalSpan(literal, needle); + report( + DIAGNOSTIC_CODES.shellSyntaxInAction, + `the command spells shell syntax ("${needle}"); declare the environment and working ` + + `directory with env(…) and cwd(…) instead (SPEC-007 R3.8)`, + i, + span.start, + span.end, + ); + } + } + + const seen = new Set(); + const unique = found.filter((entry) => { + const key = `${entry.code}:${entry.line}:${entry.startCharacter}:${entry.endCharacter}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + unique.sort( + (a, b) => + a.line - b.line || + a.startCharacter - b.startCharacter || + (a.code < b.code ? -1 : a.code > b.code ? 1 : 0), + ); + return unique; +} diff --git a/src/buildscript/api.ts b/src/buildscript/api.ts new file mode 100644 index 0000000..b89be14 --- /dev/null +++ b/src/buildscript/api.ts @@ -0,0 +1,111 @@ +/** + * The mcpp build-script API snapshot: directives, roles, protocol constants and + * provision kinds, generated from the mcpp checkout by + * `tools/generate-buildscript-api.mjs` into `data/buildscript-api.json`. + * + * Why a snapshot and not the compiler: §3.2.1 of the plugin-optimisation plan + * measured that handing `build.mcpp` to the C++ language service makes *both* + * `import std` and `import mcpp` fail as `module not found` — mcpp deliberately + * keeps the build program out of the compilation database. No language service + * can describe it, so this module is the editor's substitute. It is pure data + * and imports no `vscode`, so the analysis layer can be unit-tested and can + * never start depending on clangd. + * + * `apiProblems()` is the snapshot's own invariant check: `activate()` can log one + * line instead of failing feature by feature when the committed JSON is stale or + * malformed. + */ +import apiJson from "../../data/buildscript-api.json"; + +export interface Directive { + wire: string; + tag: string; + slot: string; + scope: string; + transform: string; + mustExist: boolean; + missingPrefix?: string; + missingSuffix?: string; + since: number; + rules: string[]; + docsUrl?: string | null; +} + +export interface BuildScriptApi { + sourceVersion: string; + sourceCommit: string; + protocolVersion: number; + cacheEpoch: number; + roles: string[]; + provisions: Array<{ kind: string; wire: string }>; + directives: Directive[]; +} + +/** + * The five action roles the engine knows (`mcpp::roles::*`), in the order + * `mcpp::manifest::BuildAction::Role` declares them. The generator reads the + * same list out of `directives.cppm`; keeping the constant here is what lets a + * snapshot whose `roles` drifted be rejected. + */ +export const ACTION_ROLES: readonly string[] = ["source", "check", "object", "artifact", "prepare"]; + +const api = apiJson as unknown as BuildScriptApi; + +export const API: BuildScriptApi = api; + +const DIRECTIVES: readonly Directive[] = api.directives; +const BY_WIRE = new Map(DIRECTIVES.map((entry) => [entry.wire, entry])); + +/** The directive with this wire name (`"link-search"`), or `undefined`. */ +export function directive(wire: string): Directive | undefined { + return BY_WIRE.get(wire); +} + +/** Every directive, in the order the mcpp table declares them. */ +export function directives(): readonly Directive[] { + return DIRECTIVES; +} + +/** The commit/version the snapshot was generated from, for provenance UIs. */ +export function apiSource(): { version: string; commit: string } { + return { version: api.sourceVersion, commit: api.sourceCommit }; +} + +/** + * The snapshot's own invariants. Empty when the table is usable; every string is + * a sentence a log line can carry. Deliberately narrow: it catches a snapshot + * that would make the editor lie (nothing to suggest, two answers for one wire, + * a role list the engine does not have, a protocol number that is not a + * protocol), not cosmetic drift. + */ +export function apiProblems(): string[] { + const problems: string[] = []; + + if (DIRECTIVES.length === 0) { + problems.push("the buildscript-api snapshot has no directives"); + } + + const seen = new Set(); + for (const entry of DIRECTIVES) { + if (seen.has(entry.wire)) { + problems.push(`duplicate directive wire name ${entry.wire}`); + } + seen.add(entry.wire); + } + + const roles = api.roles; + const isTheFiveKnownRoles = + roles.length === ACTION_ROLES.length && ACTION_ROLES.every((role) => roles.includes(role)); + if (!isTheFiveKnownRoles) { + problems.push( + `roles are not the five the engine knows: [${roles.join(", ")}] ` + + `(expected [${ACTION_ROLES.join(", ")}])`, + ); + } + + if (!(api.protocolVersion >= 1)) { + problems.push(`protocolVersion is not a protocol number: ${String(api.protocolVersion)}`); + } + + return problems; +} diff --git a/src/buildscript/modules.ts b/src/buildscript/modules.ts new file mode 100644 index 0000000..d4f255f --- /dev/null +++ b/src/buildscript/modules.ts @@ -0,0 +1,294 @@ +/** + * The modules a `build.mcpp` can legitimately `import`, and a scanner that finds + * the import sites in one. + * + * §3.2.2 of the plugin-optimisation plan: `build.mcpp` keeps its own + * `mcpp-build` language id and the C++ language service never sees it, so this + * extension is the only thing that can explain what an import means. The rule is + * absolute — **we never report "module not found"**. This module therefore only + * *recognises*; it never validates, because a build program's imports are + * resolved by mcpp's own module graph (`docs/specs/build-plugins.md` §9, + * `docs/30-build-mcpp.md` "The names of a plugin's modules"), not by clangd. + * + * The layer table of §9 is what the descriptions below name: + * L1 `mcpp.core` the engine interface; `mcpp` is its permanent equivalent spelling; + * L2 `mcpp.plugins.*` the official general-purpose library, provided by `plugins-core`; + * L3 `mcpp.deps.*` / `mcpp.rules.*` / `mcpp.dist.*` / `mcpp.tools.*` official plugins, + * and `mcpp..*` for a third party in namespace `mcpp`. + * + * The third-party form is deliberately *not* claimed by `isKnownModule`: it is + * indistinguishable from a file name (`mcpp.toml`), and the contract here is the + * explicit list (`std`, `std.compat`, `mcpp`, `mcpp.core`, `mcpp.plugins.*`). + * Not claiming a name costs a hover; claiming a wrong one would cost trust. + */ + +/** One `import` site. Positions are 0-based, matching `vscode.Position`. */ +export interface ImportSite { + /** The module name exactly as written, e.g. `mcpp.plugins.tool`. */ + module: string; + /** 0-based line index. */ + line: number; + /** 0-based column of the module name's first character. */ + startCharacter: number; + /** 0-based column one past the module name's last character. */ + endCharacter: number; +} + +export interface KnownModule { + name: string; + description: string; + docsUrl?: string; +} + +const MCPP_DOCS = "https://github.com/mcpp-community/mcpp/blob/main/docs/30-build-mcpp.md"; +const STD_DOCS = `${MCPP_DOCS}#import-std-mcpp-2026821`; +const CORE_DOCS = `${MCPP_DOCS}#the-interfaces-name-mcppcore-protocol-14`; +const PLUGIN_DOCS = `${MCPP_DOCS}#the-names-of-a-plugins-modules`; + +/** + * Exact module names plus `prefix.*` patterns. Order is the order a completion + * list should show: the standard library, the engine, then the libraries built + * on it. `mcpp.plugins.*` is the one the plan names; the other reserved second + * segments are SPEC-007 R9.6's official namespaces. + */ +export const KNOWN_MODULES: readonly KnownModule[] = [ + { + name: "std", + description: + "The C++ standard library module. mcpp stages the same `std` its own build uses, " + + "keyed on toolchain × standard × dialect (`docs/30-build-mcpp.md`, 2026.8.2.1+).", + docsUrl: STD_DOCS, + }, + { + name: "std.compat", + description: + "The C++ standard library plus the C library names in the global namespace; " + + "`import std;` and `import std.compat;` may be used together.", + docsUrl: STD_DOCS, + }, + { + name: "mcpp", + description: + "The mcpp engine interface, bundled in the mcpp binary so it always matches that " + + "engine's protocol. The permanent equivalent spelling of `mcpp.core` (SPEC-007 R9.1).", + docsUrl: CORE_DOCS, + }, + { + name: "mcpp.core", + description: + "The mcpp engine interface (L1). Only grows; a symbol leaves only after six months " + + "of deprecation, and a meaning change is a new symbol (SPEC-007 R9.2).", + docsUrl: CORE_DOCS, + }, + { + name: "mcpp.plugins.*", + description: + "The official general-purpose library (L2), provided by the `plugins-core` feature " + + "and built only on the L1 engine interface, e.g. `mcpp.plugins.tool` (SPEC-007 §9).", + docsUrl: PLUGIN_DOCS, + }, + { + name: "mcpp.deps.*", + description: + "Official dependency-adapter plugins (L3), e.g. a package manager or external build " + + "system brought into the graph (SPEC-007 §0, §9).", + docsUrl: PLUGIN_DOCS, + }, + { + name: "mcpp.rules.*", + description: + "Official rule packages (L3): code generation and device-language toolchains " + + "(SPEC-007 §0, §9).", + docsUrl: PLUGIN_DOCS, + }, + { + name: "mcpp.dist.*", + description: + "Official distribution members (L3): they turn a link artifact into something " + + "installable, e.g. a WiX or APK payload (SPEC-007 §0, §9).", + docsUrl: PLUGIN_DOCS, + }, + { + name: "mcpp.tools.*", + description: + "Official tool namespaces (L3), e.g. `mcpp.tools.island` helpers described in " + + "docs/31 (SPEC-007 §9).", + docsUrl: PLUGIN_DOCS, + }, +]; + +const EXACT_MODULES = new Map( + KNOWN_MODULES.filter((entry) => !entry.name.endsWith(".*")).map((entry) => [entry.name, entry]), +); +const PATTERN_MODULES = KNOWN_MODULES.filter((entry) => entry.name.endsWith(".*")); + +/** A dotted C++ module name (no partitions, no header units). */ +const MODULE_NAME = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/; + +/** `std`, `std.compat`, `mcpp`, `mcpp.core`, `mcpp.plugins.*` (and the other reserved L3 namespaces). */ +export function isKnownModule(name: string): boolean { + return knownModule(name) !== undefined; +} + +/** + * The description of a known module, or `undefined`. A concrete name that matches + * a `prefix.*` pattern is returned with the concrete name, so a hover can show + * the module the user actually wrote. + */ +export function knownModule(name: string): KnownModule | undefined { + const exact = EXACT_MODULES.get(name); + if (exact !== undefined) { + return exact; + } + for (const pattern of PATTERN_MODULES) { + const prefix = pattern.name.slice(0, -1); // keep the trailing dot + if (name.startsWith(prefix)) { + const rest = name.slice(prefix.length); + if (MODULE_NAME.test(rest)) { + return { name, description: pattern.description, docsUrl: pattern.docsUrl }; + } + } + } + return undefined; +} + +/** Every recognised module (patterns included), for completion lists. */ +export function knownModules(): readonly KnownModule[] { + return KNOWN_MODULES; +} + +/** + * Whitespace/comment/string masking state that has to survive a line boundary. + * `blockComment` and `rawDelim` are the two C++ constructs that do. + */ +interface ScanState { + blockComment: boolean; + rawDelim: string | null; +} + +/** + * Replace every character that is part of a comment or a string literal with a + * space, so a match on the result is a match on real code. Indices are preserved, + * which is what lets positions be reported straight from a match. + */ +function maskLine(line: string, state: ScanState): string { + let out = ""; + let i = 0; + + if (state.rawDelim !== null) { + const terminator = `)${state.rawDelim}"`; + const end = line.indexOf(terminator, i); + if (end < 0) { + return " ".repeat(line.length); + } + out += " ".repeat(end + terminator.length); + i = end + terminator.length; + state.rawDelim = null; + } + + while (i < line.length) { + if (state.blockComment) { + const end = line.indexOf("*/", i); + if (end < 0) { + return out + " ".repeat(line.length - i); + } + out += " ".repeat(end + 2 - i); + i = end + 2; + state.blockComment = false; + continue; + } + const pair = line.slice(i, i + 2); + if (pair === "//") { + return out + " ".repeat(line.length - i); + } + if (pair === "/*") { + state.blockComment = true; + out += " "; + i += 2; + continue; + } + if (line[i] === "R" && line[i + 1] === '"') { + // R"delim( … )delim" — may cross lines. + const open = /^R"([^(\s]{0,16})\(/.exec(line.slice(i)); + if (open !== null) { + const terminator = `)${open[1]}"`; + const end = line.indexOf(terminator, i + open[0].length); + if (end < 0) { + state.rawDelim = open[1]; + return out + " ".repeat(line.length - i); + } + const stop = end + terminator.length; + out += " ".repeat(stop - i); + i = stop; + continue; + } + } + if (line[i] === "'" && /[A-Za-z0-9_]/.test(line[i - 1] ?? "")) { + // A `'` right after an identifier character is a digit separator (1'000), + // not the start of a character literal; scanning for a closing quote would + // swallow the rest of the line. + i += 1; + continue; + } + if (line[i] === '"' || line[i] === "'") { + const quote = line[i]; + let j = i + 1; + while (j < line.length) { + if (line[j] === "\\") { + j += 2; + continue; + } + if (line[j] === quote) { + j += 1; + break; + } + j += 1; + } + out += " ".repeat(Math.min(j, line.length) - i); + i = Math.min(j, line.length); + continue; + } + out += line[i]; + i += 1; + } + return out; +} + +/** + * `import X;` / `export import X;` / `import X.Y.Z;`, with trailing whitespace or + * a comment. The leading class keeps a member call such as `obj.import(...)` or + * an identifier ending in `import` from matching; the trailing lookahead keeps + * `import X` from matching when it is not the whole declaration. + */ +const IMPORT = /(?:^|[\s;{}])import\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*(?=;)/g; + +/** + * Every recognised import in `lines`, in source order. Header units (`import + * ;`), header imports (`import "x.h";`) and partitions (`import :part;`) + * are ignored: they are not module names this extension has anything to say + * about. + * + * Positions are 0-based and cover the module name itself, not the statement — + * that is the range a hover or a semantic highlight wants. + */ +export function scanImports(lines: readonly string[]): ImportSite[] { + const sites: ImportSite[] = []; + const state: ScanState = { blockComment: false, rawDelim: null }; + + for (let line = 0; line < lines.length; line += 1) { + const code = maskLine(lines[line], state); + IMPORT.lastIndex = 0; + for (let match = IMPORT.exec(code); match !== null; match = IMPORT.exec(code)) { + const name = match[1]; + const start = match.index + match[0].lastIndexOf(name); + sites.push({ + module: name, + line, + startCharacter: start, + endCharacter: start + name.length, + }); + } + } + + return sites; +} diff --git a/src/buildscript/providers.ts b/src/buildscript/providers.ts new file mode 100644 index 0000000..c65ff31 --- /dev/null +++ b/src/buildscript/providers.ts @@ -0,0 +1,175 @@ +/** + * The one-line integration point for `build.mcpp` intelligence. + * + * `src/extension.ts` stays the only assembly point: it calls this function with + * its real context and a severity reader, and gets completion, hover and + * diagnostics for the `mcpp-build` language id in return. Nothing here reads a + * setting or knows a configuration key — severity arrives as a function, so the + * wiring step decides where it comes from. + * + * Everything shown comes from `api.ts` (the generated snapshot) or `modules.ts`; + * nothing invokes a compiler, and no provider ever says "module not found". The + * signature is deliberately narrow so the pure modules stay testable and this + * file stays the only one that touches the `vscode` API. + */ +import type * as vscode from "vscode"; + +import { analyseBuildScript } from "./analysis"; +import { API, directive } from "./api"; +import { knownModule, knownModules, scanImports } from "./modules"; + +// The deliverable for this file is typed against `import type * as vscode`; the +// extension host is the only environment it runs in, so the value is required +// here rather than injected. +const vscodeApi = require("vscode") as typeof vscode; + +const LANGUAGE = "mcpp-build"; + +const VS_SEVERITY: Record<"error" | "warning" | "info", vscode.DiagnosticSeverity> = { + error: vscodeApi.DiagnosticSeverity.Error, + warning: vscodeApi.DiagnosticSeverity.Warning, + info: vscodeApi.DiagnosticSeverity.Information, +}; + +function toVscodeDiagnostics( + document: vscode.TextDocument, + severity: "warning" | "info", +): vscode.Diagnostic[] { + return analyseBuildScript(document.getText().split(/\r?\n/), severity).map((entry) => { + const diagnostic = new vscodeApi.Diagnostic( + // The analyser reports 1-based line/column; `vscode.Position` is 0-based. + new vscodeApi.Range( + entry.line - 1, + entry.startCharacter - 1, + entry.line - 1, + entry.endCharacter - 1, + ), + entry.message, + VS_SEVERITY[entry.severity], + ); + diagnostic.code = entry.code; + diagnostic.source = "mcpp"; + return diagnostic; + }); +} + +/** The `mcpp::name` on this line that covers `character`, if any. */ +function symbolAt(line: string, character: number): string | undefined { + const pattern = /mcpp::([A-Za-z_][A-Za-z0-9_]*)/g; + for (let match = pattern.exec(line); match !== null; match = pattern.exec(line)) { + if (character >= match.index && character <= match.index + match[0].length) { + return match[1]; + } + } + return undefined; +} + +function markdown(lines: readonly string[]): vscode.MarkdownString { + return new vscodeApi.MarkdownString(lines.join("\n\n")); +} + +export function registerBuildScriptProviders( + context: { subscriptions: { push(...items: unknown[]): unknown } }, + severity: () => "warning" | "info" | "off", +): void { + const selector: vscode.DocumentSelector = { language: LANGUAGE }; + const collection = vscodeApi.languages.createDiagnosticCollection(LANGUAGE); + + const refresh = (document: vscode.TextDocument): void => { + if (document.languageId !== LANGUAGE) { + return; + } + const level = severity(); + if (level === "off") { + collection.delete(document.uri); + return; + } + collection.set(document.uri, toVscodeDiagnostics(document, level)); + }; + + const completion = vscodeApi.languages.registerCompletionItemProvider(selector, { + provideCompletionItems(document, position) { + const before = document.lineAt(position.line).text.slice(0, position.character); + + const afterImport = /\b(?:export\s+)?import\s+([A-Za-z0-9_.]*)$/.exec(before); + if (afterImport !== null) { + return knownModules() + .filter((entry) => !entry.name.endsWith(".*") && entry.name.startsWith(afterImport[1])) + .map((entry) => { + const item = new vscodeApi.CompletionItem( + entry.name, + vscodeApi.CompletionItemKind.Module, + ); + item.detail = "mcpp build-script module"; + item.documentation = markdown([entry.description]); + return item; + }); + } + + const afterScope = /mcpp::([A-Za-z_][A-Za-z0-9_]*)?$/.exec(before); + if (afterScope !== null) { + const typed = afterScope[1] ?? ""; + const names = API.directives.map((entry) => entry.wire.replace(/-/g, "_")); + return names + .filter((name, index) => names.indexOf(name) === index && name.startsWith(typed)) + .map((name) => { + const item = new vscodeApi.CompletionItem(name, vscodeApi.CompletionItemKind.Function); + item.detail = "mcpp build-script API"; + return item; + }); + } + + return undefined; + }, + }); + + const hover = vscodeApi.languages.registerHoverProvider(selector, { + provideHover(document, position) { + const lines = document.getText().split(/\r?\n/); + + for (const site of scanImports(lines)) { + if ( + site.line === position.line && + position.character >= site.startCharacter && + position.character <= site.endCharacter + ) { + const info = knownModule(site.module); + if (info !== undefined) { + return new vscodeApi.Hover(markdown([`**${info.name}**`, info.description]), undefined); + } + } + } + + const name = symbolAt(document.lineAt(position.line).text, position.character); + const entry = name === undefined ? undefined : directive(name.replace(/_/g, "-")); + if (entry === undefined) { + return undefined; + } + const details = [ + `**mcpp::${name}**`, + `wire \`mcpp:${entry.wire}=\` · slot \`${entry.slot}\` · scope \`${entry.scope}\` · ` + + `since protocol ${entry.since}`, + ]; + if (entry.rules.length > 0) { + details.push(`SPEC-007: ${entry.rules.join(", ")}`); + } + if (typeof entry.docsUrl === "string") { + details.push(`[docs/30 — build.mcpp](${entry.docsUrl})`); + } + return new vscodeApi.Hover(markdown(details), undefined); + }, + }); + + const subscriptions = [ + collection, + completion, + hover, + vscodeApi.workspace.onDidOpenTextDocument(refresh), + vscodeApi.workspace.onDidChangeTextDocument((event) => refresh(event.document)), + vscodeApi.workspace.onDidCloseTextDocument((document) => collection.delete(document.uri)), + ]; + for (const document of vscodeApi.workspace.textDocuments) { + refresh(document); + } + context.subscriptions.push(...subscriptions); +} 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/tools/generate-buildscript-api.mjs b/tools/generate-buildscript-api.mjs new file mode 100644 index 0000000..b3b8b71 --- /dev/null +++ b/tools/generate-buildscript-api.mjs @@ -0,0 +1,370 @@ +#!/usr/bin/env node +/** + * `data/buildscript-api.json` <- the mcpp checkout. + * + * Spec: `.agents/docs/2026-10-02-plugin-optimisation-plan.md` §3.2.3. `build.mcpp` + * is deliberately kept away from clangd (§3.2.1 measured that handing it to the C++ + * language service makes both `import std` and `import mcpp` fail), so the editor + * intelligence for `mcpp::…` names comes from this snapshot instead: the directive + * table, the five action roles, the protocol/cache constants, the provision kinds, + * the SPEC-007 rule ids that mention each wire name, and the docs anchor when a + * `docs/30-build-mcpp.md` heading spells the wire name in backticks. + * + * Everything here is derived from the mcpp sources; no row is hardcoded. The + * declared `std::array` size is checked against the number of rows actually + * parsed, and every source is mandatory — a generator that quietly writes a partial + * or empty table is worse than one that fails, because an empty table turns every + * `mcpp::` name into "unknown" in the editor. + * + * Run: node tools/generate-buildscript-api.mjs + * Env: MCPP_REPO path to the mcpp checkout (default: ../mcpp beside this repo) + * Exit: 0 on success; 1 on any unreadable source, parse failure or count mismatch. + * Nothing is written on failure. + * + * Output is deterministic: fixed key order, source order for arrays (the directive + * table's own order, the declared role order, the provision table's order), no + * timestamps. `sourceCommit` is part of the data on purpose. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const mcppRepo = path.resolve(process.env.MCPP_REPO ?? path.join(root, "..", "mcpp")); +const outFile = path.join(root, "data", "buildscript-api.json"); + +/** The upstream repository, for `docsUrl` links into the pinned revision. */ +const MCPP_URL = "https://github.com/mcpp-community/mcpp"; +/** The docs file directive anchors are drawn from. */ +const DOCS_FILE = "docs/30-build-mcpp.md"; +/** The spec that owns the rule ids. */ +const SPEC_FILE = "docs/specs/build-plugins.md"; + +function fail(message) { + console.error(`generate-buildscript-api: error: ${message}`); + process.exit(1); +} + +function readSource(relative) { + const file = path.join(mcppRepo, relative); + if (!fs.existsSync(file)) { + fail(`${relative}: not found under ${mcppRepo} (set MCPP_REPO to the mcpp checkout)`); + } + const text = fs.readFileSync(file, "utf8"); + if (text.length === 0) { + fail(`${relative}: empty`); + } + return text; +} + +/** C++ string-literal body -> the characters it denotes (enough for these tables). */ +function unescapeCpp(value) { + let out = ""; + for (let i = 0; i < value.length; i += 1) { + if (value[i] !== "\\") { + out += value[i]; + continue; + } + const next = value[i + 1]; + if (next === "n") out += "\n"; + else if (next === "t") out += "\t"; + else if (next === "r") out += "\r"; + else if (next === "0") out += "\0"; + else if (next === undefined) out += "\\"; + else out += next; + i += 1; + } + return out; +} + +function requireInt(text, label, pattern, where) { + const match = pattern.exec(text); + if (match === null) { + fail(`${where}: could not find ${label}`); + } + return Number.parseInt(match[1], 10); +} + +// ── `[package] version` from mcpp.toml ────────────────────────────────────── + +function parseSourceVersion(text) { + const lines = text.split(/\r?\n/); + let inPackage = false; + for (const line of lines) { + const header = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/.exec(line); + if (header !== null) { + inPackage = header[1].trim() === "package"; + continue; + } + if (!inPackage) continue; + const version = /^\s*version\s*=\s*"([^"]+)"/.exec(line); + if (version !== null) return version[1]; + } + fail("mcpp.toml: no [package] version"); + return ""; +} + +function sourceCommit() { + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { + cwd: mcppRepo, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + +// ── `modules/buildmcpp/src/directives.cppm` ───────────────────────────────── + +/** + * One row of `kTable`. The comments between rows are ignored; a line that looks + * like a row but does not parse makes the count fall short, which is a failure. + */ +const DIRECTIVE_ROW = + /^\s*\{\s*"((?:[^"\\]|\\.)*)"\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*Slot::([A-Za-z_]\w*)\s*,\s*Scope::([A-Za-z_]\w*)\s*,\s*Transform::([A-Za-z_]\w*)\s*,\s*(true|false)\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*"((?:[^"\\]|\\.)*)"\s*,\s*(\d+)\s*\}\s*,?\s*$/; + +function parseDirectives(text) { + const declared = requireInt( + text, + "the kTable array size", + /inline constexpr\s+std::array\s+kTable\{\{/, + "directives.cppm", + ); + const start = text.indexOf("inline constexpr std::array role.trim()) + .filter((role) => role.length > 0); + if (roles.length !== 5) { + fail(`directives.cppm: expected 5 roles, found ${roles.length}`); + } + return roles; +} + +// ── `modules/buildmcpp/src/program_protocol.cppm` ─────────────────────────── + +function parseProtocol(text) { + return { + protocolVersion: requireInt( + text, + "kProtocolVersion", + /kProtocolVersion\s*=\s*(\d+)/, + "program_protocol.cppm", + ), + cacheEpoch: requireInt(text, "kCacheEpoch", /kCacheEpoch\s*=\s*(\d+)/, "program_protocol.cppm"), + }; +} + +// ── `modules/buildmcpp/src/provisions.cppm` ───────────────────────────────── + +const PROVISION_ROW = /^\s*\{\s*Kind::([A-Za-z_]\w*)\s*,\s*"([^"]*)"\s*,/; + +/** `HostModule` -> `host-module`; the wire string is part of the same table. */ +function kebab(name) { + return name + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2") + .toLowerCase(); +} + +function parseProvisions(text) { + const start = text.indexOf("inline constexpr Def kTable[]"); + const end = text.indexOf("};", start); + if (start < 0 || end < 0) { + fail("provisions.cppm: could not delimit the kTable block"); + } + const rows = []; + for (const line of text.slice(start, end).split(/\r?\n/)) { + const match = PROVISION_ROW.exec(line); + if (match === null) continue; + rows.push({ kind: kebab(match[1]), wire: match[2] }); + } + if (rows.length === 0) { + fail("provisions.cppm: kTable parsed to zero rows"); + } + return rows; +} + +// ── `docs/specs/build-plugins.md` rule ids ────────────────────────────────── + +/** + * A rule is `- **R2.1** …` up to the next rule or the next heading; R2.1's own + * block contains the directive/`C++ 接口`/wire table, so a wire name mentioned + * only in that table still credits R2.1. + */ +function parseRuleBlocks(text) { + const blocks = []; + let current; + for (const line of text.split(/\r?\n/)) { + const rule = /^-\s+\*\*(R\d+(?:\.\d+)?)\*\*/.exec(line); + if (rule !== null) { + current = { id: rule[1], text: line }; + blocks.push(current); + continue; + } + if (/^#{1,6}\s/.test(line)) { + current = undefined; + continue; + } + if (current !== undefined) current.text += `\n${line}`; + } + return blocks; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function rulesForWire(blocks, wire) { + const pattern = new RegExp(`(? `${MCPP_URL}/blob/${revision}/${DOCS_FILE}#${anchor}`; + +const table = directives.map((row) => { + const entry = { + wire: row.wire, + tag: row.tag, + slot: row.slot, + scope: row.scope, + transform: row.transform, + mustExist: row.mustExist, + }; + // Empty strings are omitted: an optional field that is absent reads the same as + // one that is empty, and keeping them out makes the snapshot diffable. + if (row.missingPrefix.length > 0) entry.missingPrefix = row.missingPrefix; + if (row.missingSuffix.length > 0) entry.missingSuffix = row.missingSuffix; + entry.since = row.since; + entry.rules = rulesForWire(ruleBlocks, row.wire); + entry.docsUrl = anchors.has(row.wire) ? docsUrl(anchors.get(row.wire)) : null; + return entry; +}); + +const api = { + sourceVersion: version, + sourceCommit: commit, + protocolVersion, + cacheEpoch, + roles, + provisions, + directives: table, +}; + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, `${JSON.stringify(api, null, 2)}\n`); + +const withRules = table.filter((entry) => entry.rules.length > 0).length; +const withDocs = table.filter((entry) => entry.docsUrl !== null).length; +console.log( + `generate-buildscript-api: mcpp ${version} @ ${commit} (protocol ${protocolVersion}, ` + + `cache epoch ${cacheEpoch})`, +); +console.log( + ` ${table.length} directives (${withRules} with rule ids, ${withDocs} with docs anchors), ` + + `${roles.length} roles, ${provisions.length} provisions`, +); +console.log(` wrote ${path.relative(root, outFile)}`); From e342eee10d1affc6637b822d0441e58ea11719ae Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:44:18 +0800 Subject: [PATCH 06/56] =?UTF-8?q?feat(toml):=20mcpp.toml=20=E6=AE=B5/?= =?UTF-8?q?=E9=94=AE/=E6=9E=9A=E4=B8=BE=E5=BF=AB=E7=85=A7=E4=B8=8E?= =?UTF-8?q?=E4=B8=83=E6=9D=A1=E6=B8=85=E5=8D=95=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/generate-toml-schema.mjs 从 mcpp 仓库生成 data/toml-schema.json: 段列表只来自 modules/manifest/src/toml.cppm,平面来自 SPEC-004 §2 的平面表, doc 锚点来自 docs/04 的小节标题;枚举词表是脚本里唯一手工维护的部分。 src/toml/schema.ts 是快照的纯数据读取层,src/toml/diagnostics.ts 是保守的 mcpp.toml 文本诊断(1 基行列、七条规则、可逐条关闭)。 --- data/toml-schema.json | 822 +++++++++++++++++++++++++++++++++ src/toml/diagnostics.ts | 628 +++++++++++++++++++++++++ src/toml/schema.ts | 125 +++++ test/toml/diagnostics.test.ts | 235 ++++++++++ test/toml/schema.test.ts | 98 ++++ tools/generate-toml-schema.mjs | 549 ++++++++++++++++++++++ 6 files changed, 2457 insertions(+) create mode 100644 data/toml-schema.json create mode 100644 src/toml/diagnostics.ts create mode 100644 src/toml/schema.ts create mode 100644 test/toml/diagnostics.test.ts create mode 100644 test/toml/schema.test.ts create mode 100644 tools/generate-toml-schema.mjs diff --git a/data/toml-schema.json b/data/toml-schema.json new file mode 100644 index 0000000..da92ac0 --- /dev/null +++ b/data/toml-schema.json @@ -0,0 +1,822 @@ +{ + "sourceVersion": "2026.10.1.3", + "sourceCommit": "4d81d062", + "sections": [ + { + "header": "[build]", + "name": "build", + "plane": "compile", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#23-build--build-configuration", + "deprecatedBy": null, + "keys": [ + { + "key": "accel", + "type": "string", + "note": "which device backends/architectures this build targets" + }, + { + "key": "allow_host_libs", + "type": "boolean" + }, + { + "key": "bmi_schedule", + "type": "enum", + "values": [ + "auto", + "on", + "off" + ], + "default": "auto", + "note": "module-edge scheduling; \"auto\" currently means off" + }, + { + "key": "build_program_timeout", + "type": "number", + "default": 600, + "note": "seconds a build.mcpp may run; 0 = no limit" + }, + { + "key": "c_standard", + "type": "string", + "default": "c11" + }, + { + "key": "cache", + "type": "enum", + "values": [ + "global", + "local", + "off" + ], + "default": "global" + }, + { + "key": "cflags", + "type": "array" + }, + { + "key": "cxx_runtime", + "type": "enum", + "values": [ + "self-contained", + "toolchain-coupled", + "host-coupled" + ], + "default": "self-contained", + "note": "docs/20; static_stdlib is the old spelling" + }, + { + "key": "cxxflags", + "type": "array" + }, + { + "key": "default-profile", + "type": "string", + "note": "profile name, e.g. \"release\"" + }, + { + "key": "defines", + "type": "array" + }, + { + "key": "dependency_linkage", + "type": "enum", + "values": [ + "static", + "shared" + ], + "default": "static" + }, + { + "key": "dialect_cxxflags", + "type": "array", + "since": "2026.9.28.1" + }, + { + "key": "flags", + "type": "array", + "note": "array of { glob, cflags, cxxflags, asmflags, defines } tables", + "unmodelled": true + }, + { + "key": "include_dirs", + "type": "array" + }, + { + "key": "include_dirs_after", + "type": "array" + }, + { + "key": "ios_deployment_target", + "type": "string" + }, + { + "key": "jobs", + "type": "enum", + "values": [ + "auto" + ], + "note": "a positive integer is accepted as well; the type is either", + "unmodelled": true + }, + { + "key": "ldflags", + "type": "array" + }, + { + "key": "macos_deployment_target", + "type": "string" + }, + { + "key": "module_extensions", + "type": "array" + }, + { + "key": "platform-dependencies", + "type": "string", + "note": "names a platform" + }, + { + "key": "private_include_dirs", + "type": "array" + }, + { + "key": "profile", + "type": "string", + "note": "accepted alias of default-profile" + }, + { + "key": "sources", + "type": "array" + }, + { + "key": "static_stdlib", + "type": "boolean", + "legacy": true, + "note": "replaced by [build] cxx_runtime" + }, + { + "key": "std-compat-module", + "type": "string" + }, + { + "key": "std-module", + "type": "string" + }, + { + "key": "std-module-flags", + "type": "array" + }, + { + "key": "target", + "type": "string", + "note": "default target triple when no --target is passed" + } + ] + }, + { + "header": "[build-dependencies]", + "name": "build-dependencies", + "plane": "dependency", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#25-dependencies-dev-dependencies-build-dependencies", + "deprecatedBy": null + }, + { + "header": "[c-abi]", + "name": "c-abi", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[c-abi-absent]", + "name": "c-abi-absent", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[capabilities]", + "name": "capabilities", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[dependencies]", + "name": "dependencies", + "plane": "dependency", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#25-dependencies-dev-dependencies-build-dependencies", + "deprecatedBy": null + }, + { + "header": "[dev-dependencies]", + "name": "dev-dependencies", + "plane": "dependency", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#25-dependencies-dev-dependencies-build-dependencies", + "deprecatedBy": null + }, + { + "header": "[feature-deps]", + "name": "feature-deps", + "plane": "gate", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[feature-xlings]", + "name": "feature-xlings", + "plane": "gate", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[features]", + "name": "features", + "plane": "gate", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#28-features--features", + "deprecatedBy": null + }, + { + "header": "[generated_files]", + "name": "generated_files", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[hooks]", + "name": "hooks", + "plane": "lifecycle", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#216-hooks--project-build-lifecycle-commands", + "deprecatedBy": null + }, + { + "header": "[indices]", + "name": "indices", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[kernel-abi]", + "name": "kernel-abi", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[language]", + "name": "language", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#41-legacy-language-compatibility-layer", + "deprecatedBy": "[package].standard", + "keys": [ + { + "key": "import_std", + "type": "boolean", + "legacy": true, + "note": "replaced by [package].standard and the module scan" + }, + { + "key": "modules", + "type": "boolean", + "legacy": true, + "note": "replaced by the module scan" + }, + { + "key": "standard", + "type": "enum", + "values": [ + "c++20", + "c++23", + "c++26", + "c++2a", + "c++2c", + "gnu++20", + "gnu++23", + "gnu++26", + "c++latest", + "c++fly" + ], + "default": "c++23", + "legacy": true, + "note": "replaced by [package].standard" + } + ] + }, + { + "header": "[lib]", + "name": "lib", + "plane": "artifact", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#24-lib--library-root-module-convention", + "deprecatedBy": null, + "keys": [ + { + "key": "path", + "type": "string" + } + ] + }, + { + "header": "[modules]", + "name": "modules", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null, + "keys": [ + { + "key": "exports", + "type": "array" + }, + { + "key": "sources", + "type": "array" + }, + { + "key": "strict", + "type": "boolean" + } + ] + }, + { + "header": "[pack]", + "name": "pack", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null, + "keys": [ + { + "key": "bundle-project", + "type": "string", + "note": "table of fine-grained overrides", + "unmodelled": true + }, + { + "key": "debug_symbols", + "type": "string" + }, + { + "key": "default_mode", + "type": "enum", + "values": [ + "static", + "bundle-project", + "bundle-all" + ] + }, + { + "key": "exclude", + "type": "array" + }, + { + "key": "include", + "type": "array" + }, + { + "key": "strip", + "type": "boolean" + } + ] + }, + { + "header": "[package]", + "name": "package", + "plane": "identity", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#21-package--package-metadata", + "deprecatedBy": null, + "keys": [ + { + "key": "accelerators", + "type": "array", + "note": "accelerator backends the package supports (docs/04 §2.12b)" + }, + { + "key": "authors", + "type": "array" + }, + { + "key": "c-environment", + "type": "string" + }, + { + "key": "description", + "type": "string" + }, + { + "key": "exclusive", + "type": "array" + }, + { + "key": "license", + "type": "string" + }, + { + "key": "mcpp", + "type": "string", + "since": "2026.9.28.3", + "note": "release floor, only the \">=\" form is accepted (docs/04 §2.1)" + }, + { + "key": "metadata", + "type": "string", + "note": "table keyed by tool name; mcpp keeps it and does not interpret it", + "unmodelled": true + }, + { + "key": "name", + "type": "string" + }, + { + "key": "namespace", + "type": "string" + }, + { + "key": "platforms", + "type": "enum", + "values": [ + "linux", + "macos", + "windows", + "ios", + "android", + "emscripten" + ], + "note": "array of platform names (docs/04 §2.12)" + }, + { + "key": "provides", + "type": "array" + }, + { + "key": "repo", + "type": "string" + }, + { + "key": "requires", + "type": "array" + }, + { + "key": "requires_abi", + "type": "array" + }, + { + "key": "standard", + "type": "enum", + "values": [ + "c++20", + "c++23", + "c++26", + "c++2a", + "c++2c", + "gnu++20", + "gnu++23", + "gnu++26", + "c++latest", + "c++fly" + ], + "default": "c++23", + "note": "c++2a/c++2c are aliases; gnu++NN, c++latest and c++fly are documented too" + }, + { + "key": "std-compat-module", + "type": "string" + }, + { + "key": "std-module", + "type": "string" + }, + { + "key": "std-module-flags", + "type": "array" + }, + { + "key": "version", + "type": "string" + } + ] + }, + { + "header": "[profile]", + "name": "profile", + "plane": "compile", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#29-profilename--build-profiles", + "deprecatedBy": null, + "keys": [ + { + "key": "cflags", + "type": "array" + }, + { + "key": "cxxflags", + "type": "array" + }, + { + "key": "debug", + "type": "boolean", + "note": "-g" + }, + { + "key": "dependency_linkage", + "type": "enum", + "values": [ + "static", + "shared" + ] + }, + { + "key": "ldflags", + "type": "array" + }, + { + "key": "lto", + "type": "boolean", + "note": "-flto" + }, + { + "key": "opt", + "type": "enum", + "values": [ + "s", + "z" + ], + "note": "an -O level: a number, or \"s\"/\"z\"", + "unmodelled": true + }, + { + "key": "strip", + "type": "boolean", + "note": "-s at link time" + } + ] + }, + { + "header": "[resources]", + "name": "resources", + "plane": "metadata", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#215-resources--metadata-and-assets-embedded-in-the-artifact-2026871", + "deprecatedBy": null, + "keys": [ + { + "key": "extra-inputs", + "type": "array" + }, + { + "key": "files", + "type": "array" + }, + { + "key": "icon", + "type": "string" + }, + { + "key": "version-info", + "type": "boolean" + } + ] + }, + { + "header": "[runtime]", + "name": "runtime", + "plane": "metadata", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#211-runtime--provider-neutral-runtime-contract", + "deprecatedBy": null + }, + { + "header": "[scan_overrides]", + "name": "scan_overrides", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#283-scan_overridesglob--author-asserted-scan-results", + "deprecatedBy": null + }, + { + "header": "[target]", + "name": "target", + "plane": "condition", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#271-target--platform-conditional-dependencies--flags", + "deprecatedBy": null, + "keys": [ + { + "key": "cxx_runtime", + "type": "enum", + "values": [ + "self-contained", + "toolchain-coupled", + "host-coupled" + ] + }, + { + "key": "linkage", + "type": "enum", + "values": [ + "static", + "shared" + ] + }, + { + "key": "min_api_level", + "type": "number" + }, + { + "key": "runner", + "type": "array", + "note": "argv template for mcpp run/test on this target" + }, + { + "key": "sysroot", + "type": "string" + }, + { + "key": "toolchain", + "type": "string" + } + ] + }, + { + "header": "[targets]", + "name": "targets", + "plane": "artifact", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#22-targetsname--build-targets", + "deprecatedBy": null, + "keys": [ + { + "key": "cflags", + "type": "array" + }, + { + "key": "cxxflags", + "type": "array" + }, + { + "key": "defines", + "type": "array" + }, + { + "key": "exports", + "type": "string", + "note": "a path to a symbol-pattern file, or an inline array of patterns (docs/04 §2.2)", + "unmodelled": true + }, + { + "key": "kind", + "type": "enum", + "values": [ + "bin", + "lib", + "shared", + "app" + ], + "since": "2026.9.12.3", + "note": "\"app\" requires mcpp 2026.9.12.3+; library/binary/dylib/so/shlib are accepted aliases" + }, + { + "key": "linkage", + "type": "enum", + "values": [ + "static", + "shared" + ], + "since": "2026.9.15.2" + }, + { + "key": "main", + "type": "string" + }, + { + "key": "required_features", + "type": "array" + }, + { + "key": "soname", + "type": "string" + }, + { + "key": "windows_code_page", + "type": "enum", + "values": [ + "utf-8", + "legacy" + ], + "since": "2026.9.26.1" + }, + { + "key": "windows_entry", + "type": "enum", + "values": [ + "main", + "wmain", + "WinMain", + "wWinMain" + ], + "since": "2026.9.12.2" + }, + { + "key": "windows_subsystem", + "type": "enum", + "values": [ + "console", + "windows" + ], + "since": "2026.9.12.2" + } + ] + }, + { + "header": "[test]", + "name": "test", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#217-test--where-test-programs-are", + "deprecatedBy": null, + "keys": [ + { + "key": "discover", + "type": "array", + "note": "globs whose every match is one test program" + } + ] + }, + { + "header": "[toolchain]", + "name": "toolchain", + "plane": "compile", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#27-toolchain--toolchain-configuration", + "deprecatedBy": null, + "openKeys": true, + "keys": [ + { + "key": "bootstrap", + "type": "string", + "note": "managed spec for the toolchain that builds build programs" + }, + { + "key": "default", + "type": "string", + "note": "a platform key: a managed spec or `{ path = ... }`" + }, + { + "key": "family", + "type": "enum", + "values": [ + "gcc", + "llvm", + "msvc", + "emsdk", + "android-ndk" + ], + "note": "on a `[toolchain.]` entry table; a table named by `path` accepts only \"gcc\" or \"llvm\"" + } + ] + }, + { + "header": "[tools]", + "name": "tools", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[workspace]", + "name": "workspace", + "plane": "unclassified", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md", + "deprecatedBy": null + }, + { + "header": "[xlings]", + "name": "xlings", + "plane": "tool", + "doc": "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#213-xlings--the-projects-environment", + "deprecatedBy": null + } + ], + "rules": [ + { + "id": "syntax", + "severity": "error" + }, + { + "id": "unknown-section", + "severity": "warning" + }, + { + "id": "unknown-key", + "severity": "warning" + }, + { + "id": "plane-separation", + "severity": "warning" + }, + { + "id": "mcpp-floor", + "severity": "error" + }, + { + "id": "legacy-key", + "severity": "info" + }, + { + "id": "array-table", + "severity": "error" + } + ] +} diff --git a/src/toml/diagnostics.ts b/src/toml/diagnostics.ts new file mode 100644 index 0000000..b89319e --- /dev/null +++ b/src/toml/diagnostics.ts @@ -0,0 +1,628 @@ +// 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/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/test/toml/diagnostics.test.ts b/test/toml/diagnostics.test.ts new file mode 100644 index 0000000..4705aeb --- /dev/null +++ b/test/toml/diagnostics.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DIAGNOSTIC_CODES, + analyseManifest, + type DiagnosticSettings, +} from "../../src/toml/diagnostics"; + +function settings(overrides: Partial = {}): DiagnosticSettings { + return { + syntax: "error", + unknownSection: "warning", + unknownKey: "warning", + planeSeparation: "warning", + legacyKeys: "info", + ...overrides, + }; +} + +function codes(lines: readonly string[], overrides: Partial = {}): string[] { + return analyseManifest(lines, settings(overrides)).map((diagnostic) => diagnostic.code); +} + +// ── rule 1: syntax ────────────────────────────────────────────────────────── + +test("rule 1: a line that is neither a header nor key = value is a syntax error", () => { + const found = analyseManifest(["[package]", 'name = "x"', "this is broken"], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.syntax); + assert.equal(found[0].severity, "error"); + assert.equal(found[0].line, 3); + assert.deepEqual([found[0].startCharacter, found[0].endCharacter], [1, 15]); +}); + +test("rule 1: a key without a value and a malformed header are syntax errors", () => { + assert.deepEqual(codes(["[package]", "name ="]), [DIAGNOSTIC_CODES.syntax]); + assert.deepEqual(codes(["[package", 'name = "x"']), [DIAGNOSTIC_CODES.syntax]); + assert.deepEqual(codes(["[package]", "= 1"]), [DIAGNOSTIC_CODES.syntax]); +}); + +test("rule 1: headers, comments, continuations and quoted # are not syntax errors", () => { + const lines = [ + "# a comment", + "", + "[package]", + 'description = """', + "a # inside a string", + "and a = sign", + '"""', + "", + "[build]", + "sources = [", + ' "src/**/*.cppm", # a real comment', + ' "src/**",', + "]", + 'cxxflags = ["-DA#B"]', + ]; + assert.deepEqual(codes(lines), []); +}); + +// ── rule 2: unknown section ───────────────────────────────────────────────── + +test("rule 2: a section the schema does not know is reported", () => { + const found = analyseManifest(["[packag]", 'name = "x"'], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.unknownSection); + assert.equal(found[0].line, 1); + assert.equal(found[0].relatedKey, "packag"); +}); + +test("rule 2: a dotted conditional name matches its prefix section", () => { + assert.deepEqual( + codes(["[package]", 'name = "x"', "[target.'cfg(os = \"linux\")'.dependencies]", 'foo = "1"']), + [], + ); + assert.deepEqual(codes(["[targets.app]", 'kind = "bin"']), []); + assert.deepEqual(codes(["[package]", 'name = "x"', "[package.metadata.demo]", 'anything = 1']), []); +}); + +// ── rule 3: unknown key ───────────────────────────────────────────────────── + +test("rule 3: a key absent from a section's key table is reported", () => { + const found = analyseManifest(["[package]", 'nmae = "x"'], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.unknownKey); + assert.equal(found[0].relatedKey, "nmae"); + assert.deepEqual(codes(["[package]", 'name = "x"', 'standard = "c++23"']), []); +}); + +test("rule 3: row tables inherit their base section's key table", () => { + assert.deepEqual(codes(["[targets.app]", 'kind = "bin"', "bogus = 1"]), [DIAGNOSTIC_CODES.unknownKey]); + assert.deepEqual(codes(["[profile.dist]", "opt = 3"]), []); +}); + +test("rule 3: deeper sub-tables have their own vocabulary and are not checked", () => { + assert.deepEqual(codes(["[runtime.\"display.present\"]", 'provider = "acme@2.0"']), []); + assert.deepEqual(codes(["[toolchain.linux]", 'path = "/opt"', 'family = "gcc"']), []); + assert.deepEqual(codes(["[target.windows.build]", 'dialect_cxxflags = ["-DX"]']), []); +}); + +// ── rule 4: plane separation ──────────────────────────────────────────────── + +test("rule 4: a tool payload inside a dependency table is a plane mistake", () => { + const found = analyseManifest(["[dependencies]", '"xim:ninja" = "1.0"'], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.planeSeparation); + assert.match(found[0].message, /xim:ninja/); + assert.match(found[0].message, /\[xlings\]/); +}); + +test("rule 4: an mcpp package inside [xlings] is a plane mistake", () => { + assert.deepEqual(codes(["[xlings.workspace]", 'mcpplibs.cmdline = "1.0"']), [ + DIAGNOSTIC_CODES.planeSeparation, + ]); +}); + +test("rule 4: the correct plane in each table is not reported", () => { + assert.deepEqual(codes(["[dependencies]", 'lua = "5.4.7"']), []); + assert.deepEqual(codes(["[dependencies]", 'foo = { path = "../foo" }']), []); + assert.deepEqual(codes(["[xlings.workspace]", '"xim:ninja" = "1.0"']), []); + assert.deepEqual(codes(["[xlings.workspace]", 'mcpplibs.cmdline = { path = "../cmdline" }']), []); +}); + +// ── rule 5: [package].mcpp floor ──────────────────────────────────────────── + +test("rule 5: [package].mcpp must be a >= floor", () => { + const found = analyseManifest(["[package]", 'mcpp = "2026.9.28.3"'], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.mcppFloor); + assert.equal(found[0].relatedKey, "mcpp"); +}); + +test("rule 5: the >= form and a package without the key are fine", () => { + assert.deepEqual(codes(["[package]", 'mcpp = ">=2026.9.28.3"']), []); + assert.deepEqual(codes(["[package]", 'name = "x"']), []); + // A key of the same name in another section is not this rule. + assert.deepEqual(codes(["[build]", "accel = 1"]), []); +}); + +// ── rule 6: legacy section or key ─────────────────────────────────────────── + +test("rule 6: a deprecated section names its replacement", () => { + const found = analyseManifest(["[language]", 'standard = "c++20"'], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.legacyKey); + assert.match(found[0].message, /\[package\]\.standard/); +}); + +test("rule 6: a legacy key names its replacement", () => { + const found = analyseManifest(["[build]", "static_stdlib = true"], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.legacyKey); + assert.match(found[0].message, /cxx_runtime/); + assert.deepEqual(codes(["[package]", 'standard = "c++23"']), []); +}); + +// ── rule 7: array tables ──────────────────────────────────────────────────── + +test("rule 7: an array table mcpp does not use is reported", () => { + const found = analyseManifest(["[[targets.app]]", 'kind = "bin"'], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].code, DIAGNOSTIC_CODES.arrayTable); + assert.equal(found[0].line, 1); +}); + +test("rule 7: the array tables mcpp accepts are not reported", () => { + assert.deepEqual(codes(["[[build.flags]]", 'glob = "src/**"']), []); + assert.deepEqual(codes(["[[features.simd.flags]]", 'glob = "src/**"']), []); + assert.deepEqual(codes(["[targets.app]", 'kind = "bin"']), []); +}); + +// ── severity and quoting ──────────────────────────────────────────────────── + +test("severity off silences its rule and leaves the others alone", () => { + const lines = ["[packag]", 'name = "x"', "this is broken"]; + assert.deepEqual(codes(lines), [DIAGNOSTIC_CODES.unknownSection, DIAGNOSTIC_CODES.syntax]); + assert.deepEqual(codes(lines, { syntax: "off" }), [DIAGNOSTIC_CODES.unknownSection]); + assert.deepEqual(codes(lines, { unknownSection: "off" }), [DIAGNOSTIC_CODES.syntax]); + assert.deepEqual(codes(["[language]", 'standard = "c++20"'], { legacyKeys: "off" }), []); + assert.deepEqual(codes(["[dependencies]", '"xim:ninja" = "1.0"'], { planeSeparation: "off" }), []); + assert.deepEqual(codes(["[package]", 'nmae = "x"'], { unknownKey: "off" }), []); +}); + +test("a # inside a string is not a comment, and later lines are still checked", () => { + assert.deepEqual( + codes(["[package]", 'description = "a # not a comment"', "license = 'MIT' # trailing"]), + [], + ); + const found = analyseManifest(["[package]", 'description = "#"', "still broken"], settings()); + assert.equal(found.length, 1); + assert.equal(found[0].line, 3); +}); + +test("a multi-line string hides its contents from every rule", () => { + const lines = ["[package]", 'description = """', "not a key = value", "[[not a table]]", '"""']; + assert.deepEqual(codes(lines), []); +}); + +test("positions are 1-based and cover the offending token", () => { + const header = analyseManifest([" [packag]"], settings()); + assert.deepEqual( + header.map((diagnostic) => [diagnostic.line, diagnostic.startCharacter, diagnostic.endCharacter]), + [[1, 3, 11]], + ); + const key = analyseManifest(["[package]", ' nmae = "x"'], settings()); + assert.deepEqual( + key.map((diagnostic) => [diagnostic.line, diagnostic.startCharacter, diagnostic.endCharacter]), + [[2, 3, 7]], + ); +}); + +test("a clean manifest produces no diagnostics", () => { + const lines = [ + "[package]", + 'name = "greeter"', + 'version = "0.1.0"', + 'standard = "c++23"', + 'mcpp = ">=2026.9.28.3"', + "", + "[build]", + 'sources = ["src/**/*.cppm", "src/**/*.cpp"]', + 'bmi_schedule = "on"', + "", + "[targets.greeter]", + 'kind = "bin"', + "", + "[dependencies]", + 'fmt = "11.0.2"', + "", + "[xlings.workspace]", + '"xim:ninja" = "1.12.1"', + ]; + assert.deepEqual(codes(lines), []); +}); diff --git a/test/toml/schema.test.ts b/test/toml/schema.test.ts new file mode 100644 index 0000000..7736bd4 --- /dev/null +++ b/test/toml/schema.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + SCHEMA, + keyOf, + schemaSource, + sectionByHeader, + sectionByName, + sectionProblems, +} from "../../src/toml/schema"; + +test("the generated schema loads and every section header matches its name", () => { + assert.ok(SCHEMA.sections.length > 0, "the snapshot is empty"); + for (const section of SCHEMA.sections) { + assert.equal(section.header, `[${section.name}]`, section.name); + } +}); + +test("the snapshot is internally consistent", () => { + assert.deepEqual(sectionProblems(), []); +}); + +test("the snapshot records which mcpp produced it", () => { + const source = schemaSource(); + assert.match(source.version, /^\d+(\.\d+)+$/, source.version); + assert.notEqual(source.commit, ""); + assert.equal(source.version, SCHEMA.sourceVersion); + assert.equal(source.commit, SCHEMA.sourceCommit); +}); + +test("section lookup accepts both the header and the bare name", () => { + const byHeader = sectionByHeader("[package]"); + assert.ok(byHeader); + assert.equal(byHeader.name, "package"); + assert.equal(sectionByHeader("package"), byHeader); + assert.equal(sectionByName("build")?.header, "[build]"); + // An array table is not a section. + assert.equal(sectionByHeader("[[package]]"), undefined); + assert.equal(sectionByName("does-not-exist"), undefined); +}); + +test("planes come from SPEC-004 §2 and legacy sections name their replacement", () => { + assert.equal(sectionByName("package")?.plane, "identity"); + assert.equal(sectionByName("lib")?.plane, "artifact"); + assert.equal(sectionByName("targets")?.plane, "artifact"); + assert.equal(sectionByName("build")?.plane, "compile"); + assert.equal(sectionByName("profile")?.plane, "compile"); + assert.equal(sectionByName("dependencies")?.plane, "dependency"); + assert.equal(sectionByName("xlings")?.plane, "tool"); + assert.equal(sectionByName("features")?.plane, "gate"); + assert.equal(sectionByName("target")?.plane, "condition"); + assert.equal(sectionByName("runtime")?.plane, "metadata"); + assert.equal(sectionByName("hooks")?.plane, "lifecycle"); + assert.equal(sectionByName("language")?.deprecatedBy, "[package].standard"); +}); + +test("the snapshot carries a doc anchor for sections docs/04 documents", () => { + assert.equal( + sectionByName("package")?.doc, + "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md#21-package--package-metadata", + ); + assert.match(sectionByName("build")?.doc ?? "", /#23-build--build-configuration$/); +}); + +test("enums carry the values the docs list", () => { + const standard = keyOf("package", "standard"); + assert.equal(standard?.type, "enum"); + assert.equal(standard?.default, "c++23"); + for (const value of ["c++20", "c++23", "c++26"]) { + assert.ok(standard?.values?.includes(value), value); + } + assert.deepEqual(keyOf("targets", "kind")?.values, ["bin", "lib", "shared", "app"]); + assert.deepEqual(keyOf("build", "bmi_schedule")?.values, ["auto", "on", "off"]); + assert.deepEqual(keyOf("build", "cache")?.values, ["global", "local", "off"]); + assert.deepEqual(keyOf("profile", "opt")?.values, ["s", "z"]); + assert.ok(keyOf("toolchain", "family")?.values?.includes("gcc")); + assert.equal(keyOf("language", "standard")?.legacy, true); + assert.equal(keyOf("build", "static_stdlib")?.legacy, true); + assert.equal(keyOf("package", "does-not-exist"), undefined); +}); + +test("a section that carries a key table carries a non-empty, typed one", () => { + const types = new Set(["string", "boolean", "number", "array", "enum"]); + for (const section of SCHEMA.sections) { + if (section.keys === undefined) { + continue; + } + assert.ok(section.keys.length > 0, section.header); + for (const key of section.keys) { + assert.notEqual(key.key, "", section.header); + assert.ok(types.has(key.type), `${section.header} ${key.key} has type ${key.type}`); + if (key.type === "enum") { + assert.ok((key.values?.length ?? 0) > 0, `${section.header} ${key.key} has no values`); + } + } + } +}); diff --git a/tools/generate-toml-schema.mjs b/tools/generate-toml-schema.mjs new file mode 100644 index 0000000..b390f4f --- /dev/null +++ b/tools/generate-toml-schema.mjs @@ -0,0 +1,549 @@ +#!/usr/bin/env node +// Generate data/toml-schema.json from an mcpp checkout. +// +// Sources (all read-only): +// docs/specs/manifest-semantics.md — SPEC-004 §2 plane table: which plane each +// section belongs to. +// docs/04-mcpp-toml.md — field reference; its section headings give +// the `doc` anchor for each section. +// modules/manifest/src/toml.cppm — the authoritative list of accepted section +// names (the string literals the parser +// compares against) plus the key lists it +// checks (`kKnownPackageKeys`, ...). A +// section mcpp stops accepting therefore +// disappears from the schema. +// mcpp.toml — [package] version -> sourceVersion. +// git rev-parse --short HEAD — sourceCommit (or "unknown"). +// +// The checkout is `$MCPP_REPO`, defaulting to `../mcpp` next to this repository. +// +// The script fails loudly (exit 1) whenever a source cannot be read or yields +// nothing: it must never write an empty or partial schema. The output is +// deterministic — the same checkout produces byte-identical bytes (arrays are +// sorted, no timestamps, no environment data). +// +// ───────────────────────────────────────────────────────────────────────────── +// HAND-MAINTAINED, AND THE ONLY HAND-MAINTAINED PARTS +// +// Everything below the "HAND-MAINTAINED" banner is written by hand rather than +// parsed, because the mcpp checkout does not spell these out as data: +// +// * PLANE_LABELS — the Chinese plane names of SPEC-004 §2 mapped to the +// English vocabulary. An unknown label is a hard error, so a rewording of +// the table is noticed instead of silently producing "unclassified". +// * LEGACY_SECTIONS — sections docs/04 explicitly marks as a compatibility +// layer, and what replaces them. +// * OPEN_KEYS_SECTIONS — sections whose keys are user-chosen names rather +// than a closed vocabulary (the diagnostics layer must not report those as +// unknown keys). +// * KEY_INFO — the enum vocabulary the docs describe but the source does not +// spell out as a table (standard, kind, bmi_schedule, cache, cxx_runtime, +// linkage, the Windows keys, the platform vocabulary, profile knobs, +// toolchain family) plus a type for every key of a section that carries a +// key table. Keys whose type cannot be expressed by the five-value +// type vocabulary carry `unmodelled: true`. +// +// The *section list* is never hand-maintained: it comes from toml.cppm alone. +// ───────────────────────────────────────────────────────────────────────────── + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, ".."); +const MCPP_REPO = resolve(process.env.MCPP_REPO ?? join(REPO_ROOT, "..", "mcpp")); +const OUT_FILE = join(REPO_ROOT, "data", "toml-schema.json"); + +const DOC_BASE = "https://github.com/mcpp-community/mcpp/blob/main/docs/04-mcpp-toml.md"; +const SPEC_FILE = "docs/specs/manifest-semantics.md"; +const FIELD_DOC_FILE = "docs/04-mcpp-toml.md"; +const PARSER_FILE = "modules/manifest/src/toml.cppm"; + +function fail(message) { + process.stderr.write(`generate-toml-schema: ${message}\n`); + process.exit(1); +} + +function readSource(relPath) { + const abs = join(MCPP_REPO, relPath); + try { + return readFileSync(abs, "utf8"); + } catch (error) { + fail(`cannot read ${abs}: ${error?.message ?? error}`); + } +} + +// ─── read the mcpp [package] version ──────────────────────────────────────── + +function readPackageVersion(tomlText) { + let section = ""; + for (const rawLine of tomlText.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === "" || line.startsWith("#")) continue; + const header = /^\[([^\]]+)\]$/.exec(line); + if (header) { + section = header[1].trim(); + continue; + } + if (section !== "package") continue; + const assignment = /^version\s*=\s*"([^"]*)"\s*(?:#.*)?$/.exec(line); + if (assignment) return assignment[1]; + } + return undefined; +} + +function readSourceCommit() { + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { + cwd: MCPP_REPO, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + +// ─── HAND-MAINTAINED: plane vocabulary ────────────────────────────────────── +// SPEC-004 §2 is written in Chinese; the schema uses an English vocabulary. +// Every row of the table must resolve here, otherwise the script exits 1. +const PLANE_LABELS = new Map([ + ["身份", "identity"], + ["产物", "artifact"], + ["编译", "compile"], + ["库依赖", "dependency"], + ["工具与环境", "tool"], + ["门", "gate"], + ["条件", "condition"], + ["产物元数据", "metadata"], + ["生命周期", "lifecycle"], +]); + +// Sections the plane table does not name. The table is the spec's own closure +// claim ("a section MUST fall in one of these planes") but the parser reads +// more sections than the table lists, so the gap is surfaced as an explicit +// plane rather than invented away. A later revision of SPEC-004 can move a +// section out of this plane simply by adding it to §2. +const UNCLASSIFIED_PLANE = "unclassified"; + +// ─── HAND-MAINTAINED: legacy sections ─────────────────────────────────────── +// docs/04 §4.1: `[language]` is the old configuration, `[package].standard` is +// authoritative when both are present. +const LEGACY_SECTIONS = new Map([["language", "[package].standard"]]); + +// ─── HAND-MAINTAINED: sections whose keys are user-chosen names ───────────── +// `[toolchain]` is a platform -> spec map (`[toolchain] linux = "gcc@16"`) plus +// `bootstrap`, so its keys cannot be a closed list; the diagnostics layer skips +// unknown-key checking for these sections. +const OPEN_KEYS_SECTIONS = new Set(["toolchain"]); + +// ─── HAND-MAINTAINED: enum vocabulary and key types ───────────────────────── +// C++ standards, docs/04 §2.1 (`c++2a`/`c++2c` are normalized aliases; +// `gnu++NN`, `c++latest` and the experimental `c++fly` are documented too). +const CXX_STANDARDS = [ + "c++20", + "c++23", + "c++26", + "c++2a", + "c++2c", + "gnu++20", + "gnu++23", + "gnu++26", + "c++latest", + "c++fly", +]; + +// docs/04 §2.12: the platform vocabulary is fixed by mcpp. +const PLATFORMS = ["linux", "macos", "windows", "ios", "android", "emscripten"]; + +// docs/20 §"the toolchain": families of a managed spec. A `[toolchain]` entry +// *named by path* accepts only "gcc" or "llvm" (toml.cppm read_local_toolchain). +const TOOLCHAIN_FAMILIES = ["gcc", "llvm", "msvc", "emsdk", "android-ndk"]; + +// Keys are grouped by the section that owns them. For the four sections whose +// key list is generated from toml.cppm (package, targets, build, target), this +// table supplies the type/enum metadata for keys the parser names; a key the +// parser grows that is not listed here is emitted with `unmodelled: true`. +const KEY_INFO = { + package: { + accelerators: { type: "array", note: "accelerator backends the package supports (docs/04 §2.12b)" }, + authors: { type: "array" }, + "c-environment": { type: "string" }, + description: { type: "string" }, + exclusive: { type: "array" }, + license: { type: "string" }, + mcpp: { + type: "string", + since: "2026.9.28.3", + note: 'release floor, only the ">=" form is accepted (docs/04 §2.1)', + }, + metadata: { + type: "string", + unmodelled: true, + note: "table keyed by tool name; mcpp keeps it and does not interpret it", + }, + name: { type: "string" }, + namespace: { type: "string" }, + platforms: { type: "enum", values: PLATFORMS, note: "array of platform names (docs/04 §2.12)" }, + provides: { type: "array" }, + repo: { type: "string" }, + requires: { type: "array" }, + requires_abi: { type: "array" }, + standard: { + type: "enum", + values: CXX_STANDARDS, + default: "c++23", + note: "c++2a/c++2c are aliases; gnu++NN, c++latest and c++fly are documented too", + }, + "std-compat-module": { type: "string" }, + "std-module": { type: "string" }, + "std-module-flags": { type: "array" }, + version: { type: "string" }, + }, + targets: { + cflags: { type: "array" }, + cxxflags: { type: "array" }, + defines: { type: "array" }, + exports: { + type: "string", + unmodelled: true, + note: "a path to a symbol-pattern file, or an inline array of patterns (docs/04 §2.2)", + }, + kind: { + type: "enum", + values: ["bin", "lib", "shared", "app"], + since: "2026.9.12.3", + note: '"app" requires mcpp 2026.9.12.3+; library/binary/dylib/so/shlib are accepted aliases', + }, + linkage: { type: "enum", values: ["static", "shared"], since: "2026.9.15.2" }, + main: { type: "string" }, + required_features: { type: "array" }, + soname: { type: "string" }, + windows_code_page: { type: "enum", values: ["utf-8", "legacy"], since: "2026.9.26.1" }, + windows_entry: { type: "enum", values: ["main", "wmain", "WinMain", "wWinMain"], since: "2026.9.12.2" }, + windows_subsystem: { type: "enum", values: ["console", "windows"], since: "2026.9.12.2" }, + }, + build: { + accel: { type: "string", note: "which device backends/architectures this build targets" }, + allow_host_libs: { type: "boolean" }, + bmi_schedule: { + type: "enum", + values: ["auto", "on", "off"], + default: "auto", + note: 'module-edge scheduling; "auto" currently means off', + }, + build_program_timeout: { type: "number", default: 600, note: "seconds a build.mcpp may run; 0 = no limit" }, + c_standard: { type: "string", default: "c11" }, + cache: { type: "enum", values: ["global", "local", "off"], default: "global" }, + cflags: { type: "array" }, + cxxflags: { type: "array" }, + cxx_runtime: { + type: "enum", + values: ["self-contained", "toolchain-coupled", "host-coupled"], + default: "self-contained", + note: "docs/20; static_stdlib is the old spelling", + }, + "default-profile": { type: "string", note: 'profile name, e.g. "release"' }, + defines: { type: "array" }, + dependency_linkage: { type: "enum", values: ["static", "shared"], default: "static" }, + dialect_cxxflags: { type: "array", since: "2026.9.28.1" }, + flags: { type: "array", unmodelled: true, note: "array of { glob, cflags, cxxflags, asmflags, defines } tables" }, + include_dirs: { type: "array" }, + include_dirs_after: { type: "array" }, + private_include_dirs: { type: "array" }, + ios_deployment_target: { type: "string" }, + jobs: { + type: "enum", + values: ["auto"], + unmodelled: true, + note: "a positive integer is accepted as well; the type is either", + }, + ldflags: { type: "array" }, + macos_deployment_target: { type: "string" }, + module_extensions: { type: "array" }, + "platform-dependencies": { type: "string", note: "names a platform" }, + profile: { type: "string", note: "accepted alias of default-profile" }, + sources: { type: "array" }, + static_stdlib: { type: "boolean", legacy: true, note: "replaced by [build] cxx_runtime" }, + target: { type: "string", note: "default target triple when no --target is passed" }, + "std-compat-module": { type: "string" }, + "std-module": { type: "string" }, + "std-module-flags": { type: "array" }, + }, + target: { + cxx_runtime: { type: "enum", values: ["self-contained", "toolchain-coupled", "host-coupled"] }, + linkage: { type: "enum", values: ["static", "shared"] }, + min_api_level: { type: "number" }, + runner: { type: "array", note: "argv template for mcpp run/test on this target" }, + sysroot: { type: "string" }, + toolchain: { type: "string" }, + }, + // Sections whose key list is not generated from a toml.cppm array, so the + // keys themselves are hand-maintained here as well. + lib: { + path: { type: "string" }, + }, + test: { + discover: { type: "array", note: "globs whose every match is one test program" }, + }, + language: { + import_std: { type: "boolean", legacy: true, note: "replaced by [package].standard and the module scan" }, + modules: { type: "boolean", legacy: true, note: "replaced by the module scan" }, + standard: { type: "enum", values: CXX_STANDARDS, default: "c++23", legacy: true, note: "replaced by [package].standard" }, + }, + profile: { + cflags: { type: "array" }, + cxxflags: { type: "array" }, + debug: { type: "boolean", note: "-g" }, + dependency_linkage: { type: "enum", values: ["static", "shared"] }, + ldflags: { type: "array" }, + lto: { type: "boolean", note: "-flto" }, + // docs/04 §2.9 spells the key `opt` (a number, or the string "s"/"z"). + opt: { + type: "enum", + values: ["s", "z"], + unmodelled: true, + note: 'an -O level: a number, or "s"/"z"', + }, + strip: { type: "boolean", note: "-s at link time" }, + }, + resources: { + "extra-inputs": { type: "array" }, + files: { type: "array" }, + icon: { type: "string" }, + "version-info": { type: "boolean" }, + }, + modules: { + exports: { type: "array" }, + sources: { type: "array" }, + strict: { type: "boolean" }, + }, + pack: { + "bundle-project": { type: "string", unmodelled: true, note: "table of fine-grained overrides" }, + debug_symbols: { type: "string" }, + default_mode: { type: "enum", values: ["static", "bundle-project", "bundle-all"] }, + exclude: { type: "array" }, + include: { type: "array" }, + strip: { type: "boolean" }, + }, + toolchain: { + bootstrap: { type: "string", note: "managed spec for the toolchain that builds build programs" }, + default: { type: "string", note: "a platform key: a managed spec or `{ path = ... }`" }, + family: { + type: "enum", + values: TOOLCHAIN_FAMILIES, + note: 'on a `[toolchain.]` entry table; a table named by `path` accepts only "gcc" or "llvm"', + }, + }, +}; + +// toml.cppm arrays that are the authoritative key list of a section; the array +// declarations are read by name so a renamed list is a hard error rather than a +// silently empty key table. +const GENERATED_KEY_LISTS = { + package: ["kKnownPackageKeys"], + targets: ["kKnownTargetKeys"], + build: ["kKnownBuildKeys"], + target: ["kKnownTargetScalars", "kKnownTargetArrays"], +}; + +// The manifest rules and their default severities. Ids match the diagnostics +// layer's rule names (src/toml/diagnostics.ts). +const RULES = [ + { id: "syntax", severity: "error" }, + { id: "unknown-section", severity: "warning" }, + { id: "unknown-key", severity: "warning" }, + { id: "plane-separation", severity: "warning" }, + { id: "mcpp-floor", severity: "error" }, + { id: "legacy-key", severity: "info" }, + { id: "array-table", severity: "error" }, +]; + +// ─── parse SPEC-004 §2 (planes) ───────────────────────────────────────────── + +function parsePlaneTable(specText) { + const lines = specText.split(/\r?\n/); + const start = lines.findIndex((line) => /^##\s+2\.\s/.test(line)); + if (start < 0) fail(`${SPEC_FILE}: no "## 2." plane section`); + + const planes = new Map(); + let rows = 0; + for (let i = start + 1; i < lines.length; i += 1) { + const line = lines[i]; + if (/^##\s/.test(line)) break; + if (!line.trimStart().startsWith("|")) continue; + const cells = line.split("|").map((cell) => cell.trim()); + // ["", plane, sections, meaning, ""] + if (cells.length < 4) continue; + const label = cells[1]; + if (label === "" || label === "平面" || /^-+$/.test(label)) continue; + const plane = PLANE_LABELS.get(label); + if (!plane) fail(`${SPEC_FILE}: plane table row "${label}" has no English mapping`); + const tokenPattern = /`\[([^\]]+)\]`/g; + let match; + let named = 0; + while ((match = tokenPattern.exec(cells[2])) !== null) { + const base = match[1].split(".")[0].trim(); + if (base === "") continue; + planes.set(base, plane); + named += 1; + } + if (named === 0) fail(`${SPEC_FILE}: plane table row "${label}" names no section`); + rows += 1; + } + if (rows === 0) fail(`${SPEC_FILE}: plane table yielded no rows`); + return planes; +} + +// ─── parse docs/04 (doc anchors) ──────────────────────────────────────────── + +// GitHub's heading slug: lowercase, drop everything that is not a letter, +// number, space, underscore or hyphen, then each space becomes a hyphen. +// (Verified against an in-tree link: `dependency_linkage` — static … → +// #dependency_linkage--static-or-shared-is-the-consumers-decision.) +function headingSlug(text) { + return text + .toLowerCase() + .replace(/[^\p{L}\p{N} _-]/gu, "") + .replace(/ /g, "-"); +} + +function parseDocAnchors(docText) { + const anchors = new Map(); + for (const line of docText.split(/\r?\n/)) { + const heading = /^#{2,6}\s+(.*)$/.exec(line); + if (!heading) continue; + const title = heading[1].trim(); + const slug = headingSlug(title); + const tokenPattern = /`\[([^\]]+)\]`/g; + let match; + while ((match = tokenPattern.exec(title)) !== null) { + const raw = match[1].trim(); + const base = raw.split(".")[0].trim(); + if (base === "") continue; + // A heading whose bracket is exactly the section name is preferred over + // one that only mentions it (e.g. `[build]` over `[build] cache`). + const exact = raw === base; + const existing = anchors.get(base); + if (existing === undefined || (exact && !existing.exact)) { + anchors.set(base, { slug, exact }); + } + } + } + if (anchors.size === 0) fail(`${FIELD_DOC_FILE}: no section headings yielded a doc anchor`); + return anchors; +} + +// ─── parse toml.cppm (sections and key lists) ─────────────────────────────── + +function extractTopLevelSections(parserText) { + const names = new Set(); + const pattern = + /(?:doc->(?:get|get_table|get_string|get_bool|get_int|get_string_array|get_int_array|contains)\("([^"]+)"\)|(?:load_deps|read_deps|assign_dep|load_nested_dep_table|load_selector_dep_table)\("([^"]+)")/g; + let match; + while ((match = pattern.exec(parserText)) !== null) { + const path = match[1] ?? match[2]; + const base = path.split(".")[0].trim(); + if (base !== "") names.add(base); + } + if (names.size === 0) fail(`${PARSER_FILE}: no accepted section names found`); + return names; +} + +function extractStringArray(parserText, arrayName) { + const block = new RegExp( + `(?:static\\s+)?constexpr\\s+std::string_view\\s+${arrayName}\\s*\\[\\]\\s*=\\s*\\{([\\s\\S]*?)\\};`, + ).exec(parserText); + if (!block) fail(`${PARSER_FILE}: key list ${arrayName}[] not found`); + const values = []; + const literal = /"([^"]*)"/g; + let match; + while ((match = literal.exec(block[1])) !== null) { + if (match[1] !== "") values.push(match[1]); + } + if (values.length === 0) fail(`${PARSER_FILE}: key list ${arrayName}[] is empty`); + return values; +} + +// ─── assemble ─────────────────────────────────────────────────────────────── + +function buildKey(section, key) { + const info = KEY_INFO[section]?.[key]; + if (info === undefined) { + // The parser grew a key the hand table has not caught up with. Emit it so + // it is not reported as unknown, and say the type is a guess. + return { key, type: "string", unmodelled: true }; + } + const out = { key, type: info.type }; + if (info.values !== undefined) out.values = [...info.values]; + if (info.default !== undefined) out.default = info.default; + if (info.since !== undefined) out.since = info.since; + if (info.legacy === true) out.legacy = true; + if (info.note !== undefined) out.note = info.note; + if (info.unmodelled === true) out.unmodelled = true; + return out; +} + +function sectionKeys(section, parserText) { + const listNames = GENERATED_KEY_LISTS[section]; + let keyNames; + if (listNames !== undefined) { + keyNames = []; + for (const name of listNames) keyNames.push(...extractStringArray(parserText, name)); + } else { + keyNames = Object.keys(KEY_INFO[section] ?? {}); + } + keyNames = [...new Set(keyNames)].sort(); + if (keyNames.length === 0) return undefined; + return keyNames.map((key) => buildKey(section, key)); +} + +function main() { + const version = readPackageVersion(readSource("mcpp.toml")); + if (!version) fail(`mcpp.toml: [package] version not found`); + + const planes = parsePlaneTable(readSource(SPEC_FILE)); + const anchors = parseDocAnchors(readSource(FIELD_DOC_FILE)); + const parserText = readSource(PARSER_FILE); + const sectionNames = extractTopLevelSections(parserText); + + const sections = [...sectionNames].sort().map((name) => { + const section = { + header: `[${name}]`, + name, + plane: planes.get(name) ?? UNCLASSIFIED_PLANE, + doc: anchors.has(name) ? `${DOC_BASE}#${anchors.get(name).slug}` : DOC_BASE, + deprecatedBy: LEGACY_SECTIONS.get(name) ?? null, + }; + if (OPEN_KEYS_SECTIONS.has(name)) section.openKeys = true; + const keys = sectionKeys(name, parserText); + if (keys !== undefined) section.keys = keys; + return section; + }); + + // Refuse to write a schema that cannot drive anything: every section must be + // named by the parser, and a plane table that matched nothing is a hard + // error (parsePlaneTable already guarantees the latter). + if (sections.length === 0) fail("no sections assembled"); + + const schema = { + sourceVersion: version, + sourceCommit: readSourceCommit(), + sections, + rules: RULES, + }; + + mkdirSync(dirname(OUT_FILE), { recursive: true }); + writeFileSync(OUT_FILE, `${JSON.stringify(schema, null, 2)}\n`, "utf8"); + + const keys = sections.reduce((total, section) => total + (section.keys?.length ?? 0), 0); + process.stdout.write( + `generate-toml-schema: wrote ${OUT_FILE}\n` + + ` mcpp ${version} (${schema.sourceCommit}), ${sections.length} sections, ${keys} keys, ` + + `${sections.filter((section) => section.plane === UNCLASSIFIED_PLANE).length} unclassified\n`, + ); +} + +main(); From 6feaa4dda168ae31086c351659219a6cd3a22e2a Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:47:28 +0800 Subject: [PATCH 07/56] =?UTF-8?q?chore(ci):=20=E7=94=9F=E6=88=90=E7=89=A9?= =?UTF-8?q?=E6=BC=82=E7=A7=BB=E9=97=A8=E7=A6=81=E4=B8=8E=20npm=20=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools/check-generators.mjs:用 mcpp checkout 重新生成 buildscript-api.json 与 toml-schema.json,任何差异即失败;没有 checkout 时跳过并说明(不静默通过) - npm run check 现在包含 check:config + check:l10n + check:generated - 修正方案文档里两处 mcpp 实际不存在的键名(profile.opt_level -> opt、 bidi_schedule -> bmi_schedule),由生成脚本核对源码时发现 验证:npm test 390 通过;三个门禁脚本全绿 --- package.json | 5 +++- tools/check-generators.mjs | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tools/check-generators.mjs diff --git a/package.json b/package.json index a5b1b8c..5bb576d 100644 --- a/package.json +++ b/package.json @@ -853,7 +853,10 @@ "gen:docs": "node tools/generate-settings-docs.mjs", "check:config": "node tools/check-config.mjs", "check:l10n": "node tools/l10n-check.mjs", - "check": "npm run check:config && npm run check:l10n" + "check": "npm run check:config && npm run check:l10n && npm run check:generated", + "gen:buildscript": "node tools/generate-buildscript-api.mjs", + "gen:toml": "node tools/generate-toml-schema.mjs", + "check:generated": "node tools/check-generators.mjs" }, "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/tools/check-generators.mjs b/tools/check-generators.mjs new file mode 100644 index 0000000..78dc3be --- /dev/null +++ b/tools/check-generators.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Drift gate for the generated snapshots. + * + * `data/buildscript-api.json` and `data/toml-schema.json` are generated from the + * mcpp checkout and committed, because the extension must work without that + * checkout present. That only stays honest if something notices when the source + * moves, so CI regenerates them here and fails on any difference. + * + * Without a checkout (`MCPP_REPO`, else `../mcpp`) this **skips with a notice** + * rather than passing silently: a green run that checked nothing is worse than a + * yellow one. + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const checkout = process.env.MCPP_REPO ?? path.resolve(root, "..", "mcpp"); + +if (!fs.existsSync(path.join(checkout, "mcpp.toml"))) { + console.log(`check-generators: skipped — no mcpp checkout at ${checkout} (set MCPP_REPO to enable)`); + process.exit(0); +} + +const targets = [ + { name: "buildscript API", script: "tools/generate-buildscript-api.mjs", file: "data/buildscript-api.json" }, + { name: "mcpp.toml schema", script: "tools/generate-toml-schema.mjs", file: "data/toml-schema.json" }, +]; + +let drifted = 0; +for (const target of targets) { + const before = fs.readFileSync(path.join(root, target.file), "utf8"); + try { + execFileSync(process.execPath, [target.script], { cwd: root, env: { ...process.env, MCPP_REPO: checkout }, stdio: "pipe" }); + } catch (error) { + console.error(`error: ${target.script} failed; the snapshot cannot be refreshed`); + console.error(String(error.stderr ?? error.message)); + drifted += 1; + continue; + } + const after = fs.readFileSync(path.join(root, target.file), "utf8"); + if (before !== after) { + console.error(`error: ${target.file} is out of date — run \`npm run gen:…\` and commit the result`); + drifted += 1; + } else { + console.log(`check-generators: ${target.name} is current`); + } +} + +if (drifted > 0) { + process.exit(1); +} +console.log("check-generators: ok"); From 2569a7aee8c928929f0541e360a41626f57fd1c6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:47:34 +0800 Subject: [PATCH 08/56] =?UTF-8?q?docs:=20=E7=BB=9F=E4=B8=80=20mcpp.toml=20?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E9=94=AE=E5=90=8D=E4=B8=BA=20opt=EF=BC=88mcp?= =?UTF-8?q?p=20=E5=AE=9E=E9=99=85=E6=8B=BC=E5=86=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/docs/2026-10-02-plugin-optimisation-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/docs/2026-10-02-plugin-optimisation-plan.md b/.agents/docs/2026-10-02-plugin-optimisation-plan.md index db53720..df0e0bf 100644 --- a/.agents/docs/2026-10-02-plugin-optimisation-plan.md +++ b/.agents/docs/2026-10-02-plugin-optimisation-plan.md @@ -337,7 +337,7 @@ CI 漂移 job:用 `mcpp@main` 重新生成 + `git diff --exit-code`。 |---|---|---| | 段头补全 | 已有,改为读 schema(平面分组 + legacy 标记) | `mcpp.toml.completion` | | **键补全** | 已知段的键位置 → 该段全部键(类型、默认值、legacy),已存在的键剔除 | 同上 | -| **枚举值补全** | `standard`、`kind`、`linkage`、`opt_level`、`[profile.*]`、`[target.]` selector 词表 | 同上 | +| **枚举值补全** | `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` | From f6042cc3891ce8ffb0c8b43ba4cfcace2476cac6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 2 Oct 2026 23:47:41 +0800 Subject: [PATCH 09/56] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20round=201=20?= =?UTF-8?q?=E8=BF=9B=E5=B1=95=E4=B8=8E=E4=B8=8B=E4=B8=80=E8=BD=AE=E8=B5=B7?= =?UTF-8?q?=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/2026-10-02-implementation-plan.md | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.agents/docs/2026-10-02-implementation-plan.md b/.agents/docs/2026-10-02-implementation-plan.md index 30a71d6..b1681e9 100644 --- a/.agents/docs/2026-10-02-implementation-plan.md +++ b/.agents/docs/2026-10-02-implementation-plan.md @@ -191,3 +191,31 @@ T01 ─┬─ T02 ─┬─ T11 ─┬─ T12 ─┐ - [ ] `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`),已修正。 From 058788bc3c262737d11f66c89ef3ead54c5fda3e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 3 Oct 2026 00:00:17 +0800 Subject: [PATCH 10/56] =?UTF-8?q?feat(views,commands):=20=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E5=AE=B9=E5=99=A8=E3=80=81=E4=B8=89=E6=A3=B5=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E3=80=81=E6=B8=85=E7=90=86=E5=91=BD=E4=BB=A4=E3=80=81?= =?UTF-8?q?C++=20Modules=20=E7=8A=B6=E6=80=81=E8=A7=86=E5=9B=BE=E3=80=81?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=9D=A2=E6=9D=BF=E4=B8=8E=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清单(package.json) - 命令从 16 增至 42:缓存 9、C++ Modules 转发 14、工具 3(含设置面板与环境自检) - 新增 Activity Bar 容器 mcpp 与三个视图(工程/缓存/C++ Modules),视图描述里写明 C++ Modules 的内容由 sunrisepeak.mcpp-language-server 提供,本视图只展示与转发 - 新增两个可覆盖颜色(mcpp.cacheOkForeground / mcpp.cacheStaleForeground) - 激活面收敛:删除全部 onCommand:*(VS Code 1.74+ 由 contributes.commands 自动生成), 只留 workspaceContains:mcpp.toml 与 onLanguage:mcpp-toml / mcpp-build - 全部命令标题与视图名走 %nls% 键,中英双语 代码 - src/views/treeProvider.ts:唯一的 TreeDataProvider,标签在渲染时翻译 - src/views/{projectView,cacheView,languageServerView}.ts:三棵视图与其命令 - src/cli/cacheView 的清理命令全部经由 src/cli/clean.ts 的计划表(预演 + 分级确认 + 受信检查),缓存面板给出本地 LRU 预估 - src/mcppls/stateSource.ts:唯一读取 mcppls extension.exports 的地方,全部防御 - src/config/{panel,panelHtml}.ts + media/settings.css:配置面板(严格 CSP、无外链、 主题变量、来源与生效时机、预设、搜索、仅显示已修改、边界提示) - src/toml/providers.ts:mcpp.toml 补全 + 七条诊断;src/buildscript/providers.ts 接入 - src/cli/selfCheck.ts + mcpp.selfCheck:一份可复制的环境快照 - 0.4.x 的 5 个语言服务命令 ID 全部保留为转发别名 验证:npm test 420 通过;check-config / l10n-check / check-generators 全绿 --- data/i18n/zh-cn.json | 74 +++- l10n/bundle.l10n.zh-cn.json | 74 +++- media/settings.css | 318 +++++++++++++++++ package.json | 243 +++++++++++-- package.nls.json | 67 +++- package.nls.zh-cn.json | 43 ++- src/cli/controller.ts | 14 +- src/cli/selfCheck.ts | 98 +++++ src/commands/ids.ts | 82 ++++- src/commands/menu.ts | 60 +++- src/config/panel.ts | 403 +++++++++++++++++++++ src/config/panelHtml.ts | 612 ++++++++++++++++++++++++++++++++ src/extension.ts | 188 ++++++---- src/mcppls/stateSource.ts | 52 +++ src/toml/providers.ts | 124 +++++++ src/views/cacheView.ts | 441 +++++++++++++++++++++++ src/views/languageServerView.ts | 149 ++++++++ src/views/projectView.ts | 70 ++++ src/views/treeProvider.ts | 83 +++++ test/artifacts.test.ts | 55 +-- test/cli/selfCheck.test.ts | 89 +++++ test/commands/ids.test.ts | 118 ++++-- test/config/panelHtml.test.ts | 243 +++++++++++++ 23 files changed, 3488 insertions(+), 212 deletions(-) create mode 100644 media/settings.css create mode 100644 src/cli/selfCheck.ts create mode 100644 src/config/panel.ts create mode 100644 src/config/panelHtml.ts create mode 100644 src/mcppls/stateSource.ts create mode 100644 src/toml/providers.ts create mode 100644 src/views/cacheView.ts create mode 100644 src/views/languageServerView.ts create mode 100644 src/views/projectView.ts create mode 100644 src/views/treeProvider.ts create mode 100644 test/cli/selfCheck.test.ts create mode 100644 test/config/panelHtml.test.ts diff --git a/data/i18n/zh-cn.json b/data/i18n/zh-cn.json index b4390d9..eca9670 100644 --- a/data/i18n/zh-cn.json +++ b/data/i18n/zh-cn.json @@ -1,10 +1,72 @@ { - "{0}: done.": "{0}:完成。", + "(size unknown)": "(大小未知)", + "About {0} would be freed ({1} entries).": "预计释放约 {0}({1} 个条目)。", + "Already within {0} GiB.": "当前已在 {0} GiB 以内。", + "Also empty the shared build cache": "同时清空共享构建缓存", + "Cache entry {0}": "缓存条目 {0}", + "Cache statistics": "缓存统计", + "Cache verification": "缓存校验", + "Changed from the default": "已偏离默认值", + "Choose a project, toolchain, cache or C++ Modules action": "选择工程、工具链、缓存或 C++ Modules 操作", + "Command": "命令", + "Confirm once more": "请再次确认", + "Default": "默认", + "Deprecated": "已弃用", + "Direction: disable.": "方向:停用。", + "Direction: enable.": "方向:启用。", + "Enter a whole number of GiB": "请输入整数 GiB", + "Estimated size": "估算大小", + "Every entry matches its manifest.": "每个条目都与它的清单一致。", + "Global build cache": "全局构建缓存", + "I understand this affects every mcpp project on this machine": "我明白这会影响本机所有 mcpp 工程", + "Incomplete entries": "不完整条目", + "Install extension": "安装扩展", + "Invalid": "无效", + "Keep the shared build cache under how many GiB?": "共享构建缓存保留在多少 GiB 以内?", + "Largest packages": "占用最多的包", + "No settings match the search": "没有匹配的设置", + "No target/ directory": "没有 target/ 目录", + "No workspace folder is open": "未打开工作区文件夹", + "Not measured yet": "尚未测量", + "One value per line": "每行一个值", + "Only modified": "仅显示已修改", + "Open in the Settings editor": "在设置编辑器中打开", + "Open the C++ Modules settings": "打开 C++ Modules 设置", + "Presets": "预设", + "Project artifacts": "工程产物", + "Reload the window to see this": "重载窗口后生效", + "Reset": "重置", + "Reset the invalid value to the default": "将无效值重置为默认值", + "Run": "运行", + "Save settings to": "保存设置到", + "Search settings": "搜索设置", + "Show advanced settings": "显示高级设置", + "Takes effect on the next build": "下次构建时生效", + "Takes effect on the next clean": "下次清理时生效", "The C++ Modules extension ({0}) is not installed or is disabled.": "C++ Modules 扩展({0})未安装或已禁用。", - "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。", - "{0} failed: {1}": "{0} 失败:{1}", + "This panel changes mcpp-vscode settings only; the C++ Modules extension ({0}) keeps its own mcppls.* settings and this panel never writes them.": "本面板只修改 mcpp-vscode 的设置;C++ Modules 扩展({0})自行管理其 mcppls.* 设置,本面板不会写入它们。", + "This workspace has no mcpp.toml.": "当前工作区没有找到 mcpp.toml。", + "This workspace is not trusted. mcpp commands that write are disabled until you trust it.": "当前工作区未受信任。会写盘的 mcpp 命令在信任工作区之前不会执行。", + "Toggle this section": "折叠或展开本节", + "User": "用户", + "User settings": "用户设置", + "Values shown for {0}": "以下值适用于 {0}", + "Workspace": "工作区", + "Workspace folder": "工作区文件夹", + "Workspace settings": "工作区设置", + "mcpp cache list did not return the documented document": "mcpp cache list 未返回约定的文档", + "mcpp cache list failed (exit {0})": "mcpp cache list 失败(退出码 {0})", + "mcpp cache verify failed with exit code {0}": "mcpp cache verify 失败,退出码 {0}", + "mcpp clean --dry-run": "mcpp clean --dry-run", + "mcpp settings": "mcpp 设置", + "mcpp {0} failed with exit code {1}": "mcpp {0} 失败,退出码 {1}", + "mcpp: quick menu": "mcpp: 快捷菜单", + "mcpp: {0}": "mcpp:{0}", + "not read yet": "尚未读取", + "the workspace is not trusted": "工作区未受信任", "unknown error": "未知错误", - "Direction: enable.": "方向:启用。", - "Direction: disable.": "方向:停用。", - "Install extension": "安装扩展" + "{0} failed: {1}": "{0} 失败:{1}", + "{0} in the shared cache": "共享缓存中 {0}", + "{0}: done.": "{0}:完成。", + "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index b4390d9..eca9670 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -1,10 +1,72 @@ { - "{0}: done.": "{0}:完成。", + "(size unknown)": "(大小未知)", + "About {0} would be freed ({1} entries).": "预计释放约 {0}({1} 个条目)。", + "Already within {0} GiB.": "当前已在 {0} GiB 以内。", + "Also empty the shared build cache": "同时清空共享构建缓存", + "Cache entry {0}": "缓存条目 {0}", + "Cache statistics": "缓存统计", + "Cache verification": "缓存校验", + "Changed from the default": "已偏离默认值", + "Choose a project, toolchain, cache or C++ Modules action": "选择工程、工具链、缓存或 C++ Modules 操作", + "Command": "命令", + "Confirm once more": "请再次确认", + "Default": "默认", + "Deprecated": "已弃用", + "Direction: disable.": "方向:停用。", + "Direction: enable.": "方向:启用。", + "Enter a whole number of GiB": "请输入整数 GiB", + "Estimated size": "估算大小", + "Every entry matches its manifest.": "每个条目都与它的清单一致。", + "Global build cache": "全局构建缓存", + "I understand this affects every mcpp project on this machine": "我明白这会影响本机所有 mcpp 工程", + "Incomplete entries": "不完整条目", + "Install extension": "安装扩展", + "Invalid": "无效", + "Keep the shared build cache under how many GiB?": "共享构建缓存保留在多少 GiB 以内?", + "Largest packages": "占用最多的包", + "No settings match the search": "没有匹配的设置", + "No target/ directory": "没有 target/ 目录", + "No workspace folder is open": "未打开工作区文件夹", + "Not measured yet": "尚未测量", + "One value per line": "每行一个值", + "Only modified": "仅显示已修改", + "Open in the Settings editor": "在设置编辑器中打开", + "Open the C++ Modules settings": "打开 C++ Modules 设置", + "Presets": "预设", + "Project artifacts": "工程产物", + "Reload the window to see this": "重载窗口后生效", + "Reset": "重置", + "Reset the invalid value to the default": "将无效值重置为默认值", + "Run": "运行", + "Save settings to": "保存设置到", + "Search settings": "搜索设置", + "Show advanced settings": "显示高级设置", + "Takes effect on the next build": "下次构建时生效", + "Takes effect on the next clean": "下次清理时生效", "The C++ Modules extension ({0}) is not installed or is disabled.": "C++ Modules 扩展({0})未安装或已禁用。", - "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。", - "{0} failed: {1}": "{0} 失败:{1}", + "This panel changes mcpp-vscode settings only; the C++ Modules extension ({0}) keeps its own mcppls.* settings and this panel never writes them.": "本面板只修改 mcpp-vscode 的设置;C++ Modules 扩展({0})自行管理其 mcppls.* 设置,本面板不会写入它们。", + "This workspace has no mcpp.toml.": "当前工作区没有找到 mcpp.toml。", + "This workspace is not trusted. mcpp commands that write are disabled until you trust it.": "当前工作区未受信任。会写盘的 mcpp 命令在信任工作区之前不会执行。", + "Toggle this section": "折叠或展开本节", + "User": "用户", + "User settings": "用户设置", + "Values shown for {0}": "以下值适用于 {0}", + "Workspace": "工作区", + "Workspace folder": "工作区文件夹", + "Workspace settings": "工作区设置", + "mcpp cache list did not return the documented document": "mcpp cache list 未返回约定的文档", + "mcpp cache list failed (exit {0})": "mcpp cache list 失败(退出码 {0})", + "mcpp cache verify failed with exit code {0}": "mcpp cache verify 失败,退出码 {0}", + "mcpp clean --dry-run": "mcpp clean --dry-run", + "mcpp settings": "mcpp 设置", + "mcpp {0} failed with exit code {1}": "mcpp {0} 失败,退出码 {1}", + "mcpp: quick menu": "mcpp: 快捷菜单", + "mcpp: {0}": "mcpp:{0}", + "not read yet": "尚未读取", + "the workspace is not trusted": "工作区未受信任", "unknown error": "未知错误", - "Direction: enable.": "方向:启用。", - "Direction: disable.": "方向:停用。", - "Install extension": "安装扩展" + "{0} failed: {1}": "{0} 失败:{1}", + "{0} in the shared cache": "共享缓存中 {0}", + "{0}: done.": "{0}:完成。", + "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。" } diff --git a/media/settings.css b/media/settings.css new file mode 100644 index 0000000..967cbab --- /dev/null +++ b/media/settings.css @@ -0,0 +1,318 @@ +/* + * The configuration panel's stylesheet. + * + * Two rules hold this together, and both come from the plan (§3.5, §4.3): + * + * 1. **Theme tokens only.** Every colour is a `var(--vscode-*)` reference, so + * the panel follows light, dark and high-contrast themes without a second + * stylesheet. `currentColor` is used for borders that should inherit the + * text colour; there is not a single literal colour value in this file. + * 2. **Never colour alone.** State (default / user / workspace / folder / + * invalid, deprecated, modified) is carried by the badges' *text* and by the + * shape of the row's left border — solid, dashed, or double. Colour is + * decoration on top, which is what keeps the panel readable when a theme + * collapses every hue into one. + * + * The client only toggles `hidden` and `data-*` attributes, so all behaviour is + * expressed here as attribute selectors rather than inline styles. + */ + +body { + margin: 0; + padding: 0; + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + line-height: 1.5; +} + +[hidden] { + display: none !important; +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: inherit; + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + border: 1px solid; + border-color: var(--vscode-input-border, currentColor); + border-radius: 2px; + padding: 2px 6px; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +input[type="checkbox"] { + padding: 0; + background: none; + border: 0; + accent-color: var(--vscode-checkbox-background, var(--vscode-focusBorder)); +} + +/* ------------------------------------------------------------------ header */ + +.panel-header { + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.panel-title { + margin: 0 0 6px; + font-size: 1.3em; + font-weight: 600; +} + +.boundary { + margin: 0; + padding: 8px 10px; + border: 1px solid; + border-color: var(--vscode-inputValidation-infoBorder, currentColor); + background-color: var(--vscode-inputValidation-infoBackground); +} + +.resource-label { + margin: 6px 0 0; + color: var(--vscode-descriptionForeground); +} + +/* ----------------------------------------------------------------- toolbar */ + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.search { + flex: 1 1 220px; + min-width: 160px; +} + +.toggle, +.target { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +/* ----------------------------------------------------------------- presets */ + +.presets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 16px; + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.presets-title { + font-weight: 600; +} + +.preset-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.preset { + color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground)); + background-color: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); + border: 1px solid; + border-color: var(--vscode-contrastBorder, currentColor); + padding: 3px 10px; +} + +.preset:hover { + background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground)); +} + +.link-button { + color: var(--vscode-textLink-foreground); + background: none; + border: 1px solid; + border-color: currentColor; + padding: 2px 8px; +} + +.link-button:hover { + color: var(--vscode-textLink-activeForeground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* ---------------------------------------------------------------- sections */ + +.section { + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.section-head { + margin: 0; + font-size: 1em; +} + +.section-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 16px; + text-align: left; + font-weight: 600; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); + background-color: var(--vscode-sideBarSectionHeader-background); + border: 0; + border-radius: 0; +} + +/* The caret is a text glyph so the collapsed state is visible without colour. */ +.section-toggle::before { + content: "\25BE"; +} + +.section[data-collapsed="true"] .section-toggle::before { + content: "\25B8"; +} + +.section[data-collapsed="true"] .rows { + display: none; +} + +/* -------------------------------------------------------------------- rows */ + +.row { + display: grid; + gap: 6px; + padding: 10px 16px; + border-left: 3px solid; + border-left-color: var(--vscode-panel-border, currentColor); +} + +.row[data-deprecated="true"] { + border-left-style: dashed; + border-left-color: var(--vscode-editorWarning-foreground); +} + +.row[data-source="invalid"] { + border-left-style: double; + border-left-width: 5px; + border-left-color: var(--vscode-errorForeground); +} + +.row-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; +} + +.row-title { + font-weight: 600; +} + +.row-description { + margin: 0; + color: var(--vscode-descriptionForeground); +} + +.row-deprecation { + margin: 0; + color: var(--vscode-editorWarning-foreground); +} + +.row-applies { + margin: 0; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.row-control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.row-control input[type="text"], +.row-control input[type="number"], +.row-control select { + min-width: 180px; +} + +.row-control textarea { + width: 100%; + min-height: 3em; + resize: vertical; +} + +.control-hint { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 0.9em; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.empty { + margin: 0; + padding: 16px; + color: var(--vscode-descriptionForeground); +} + +/* ------------------------------------------------------------------ badges */ + +.badge { + border: 1px solid; + border-color: currentColor; + border-radius: 999px; + padding: 0 6px; + font-size: 0.85em; + white-space: nowrap; +} + +.badge-modified { + color: var(--vscode-charts-blue, var(--vscode-foreground)); +} + +.badge-deprecated { + color: var(--vscode-editorWarning-foreground, var(--vscode-foreground)); +} + +.badge-invalid { + color: var(--vscode-errorForeground, var(--vscode-foreground)); +} + +.badge-source { + color: var(--vscode-descriptionForeground); +} + +/* A hand-edited value the registry rejects: the control is visible but closed, + and the dashed border says "not editable here" without relying on colour. */ +[data-control][disabled] { + border-style: dashed; + opacity: 0.85; +} diff --git a/package.json b/package.json index 5bb576d..3b3a806 100644 --- a/package.json +++ b/package.json @@ -22,25 +22,8 @@ ], "activationEvents": [ "workspaceContains:mcpp.toml", - "onLanguage:cpp", - "onLanguage:mcpp-build", "onLanguage:mcpp-toml", - "onCommand:mcpp.configureLanguageServer", - "onCommand:mcpp.configureClangd", - "onCommand:mcpp.refreshCompilationDatabase", - "onCommand:mcpp.checkModuleSupport", - "onCommand:mcpp.showMenu", - "onCommand:mcpp.newProject", - "onCommand:mcpp.build", - "onCommand:mcpp.run", - "onCommand:mcpp.test", - "onCommand:mcpp.clean", - "onCommand:mcpp.showToolchains", - "onCommand:mcpp.installToolchain", - "onCommand:mcpp.selectDefaultToolchain", - "onCommand:mcpp.autoConfigureModules", - "onCommand:mcpp.showModuleGraph", - "onCommand:mcpp.showLanguageServerLogs" + "onLanguage:mcpp-build" ], "categories": [ "Programming Languages", @@ -66,9 +49,52 @@ "group": "navigation@2", "when": "mcpp.inProject" } + ], + "view/title": [ + { + "command": "mcpp.refreshCacheStats", + "when": "view == mcpp.cache", + "group": "navigation@2" + }, + { + "command": "mcpp.showCachePanel", + "when": "view == mcpp.cache", + "group": "navigation@3" + }, + { + "command": "mcpp.languageServer.refreshState", + "when": "view == mcpp.languageServer", + "group": "navigation@2" + }, + { + "command": "mcpp.languageServer.restart", + "when": "view == mcpp.languageServer", + "group": "navigation@3" + } + ], + "commandPalette": [ + { + "command": "mcpp.openMcpplsSettings", + "when": "false" + } ] }, "commands": [ + { + "command": "mcpp.openSettings", + "title": "%command.mcpp.openSettings.title%", + "category": "%category%" + }, + { + "command": "mcpp.openMcpplsSettings", + "title": "%command.mcpp.openMcpplsSettings.title%", + "category": "%category%" + }, + { + "command": "mcpp.selfCheck", + "title": "%command.mcpp.selfCheck.title%", + "category": "%category%" + }, { "command": "mcpp.showMenu", "title": "%command.mcpp.showMenu.title%", @@ -87,14 +113,14 @@ { "command": "mcpp.run", "title": "%command.mcpp.run.title%", - "icon": "$(play)", - "category": "%category%" + "category": "%category%", + "icon": "$(play)" }, { "command": "mcpp.test", "title": "%command.mcpp.test.title%", - "icon": "$(beaker)", - "category": "%category%" + "category": "%category%", + "icon": "$(beaker)" }, { "command": "mcpp.clean", @@ -116,6 +142,126 @@ "title": "%command.mcpp.selectDefaultToolchain.title%", "category": "%category%" }, + { + "command": "mcpp.autoConfigureModules", + "title": "%command.mcpp.autoConfigureModules.title%", + "category": "%category%" + }, + { + "command": "mcpp.showCachePanel", + "title": "%command.mcpp.showCachePanel.title%", + "category": "%category%" + }, + { + "command": "mcpp.refreshCacheStats", + "title": "%command.mcpp.refreshCacheStats.title%", + "category": "%category%" + }, + { + "command": "mcpp.showCacheEntry", + "title": "%command.mcpp.showCacheEntry.title%", + "category": "%category%" + }, + { + "command": "mcpp.cleanStaleArtifacts", + "title": "%command.mcpp.cleanStaleArtifacts.title%", + "category": "%category%" + }, + { + "command": "mcpp.cleanProjectArtifacts", + "title": "%command.mcpp.cleanProjectArtifacts.title%", + "category": "%category%" + }, + { + "command": "mcpp.gcGlobalCache", + "title": "%command.mcpp.gcGlobalCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.pruneGlobalCache", + "title": "%command.mcpp.pruneGlobalCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.verifyGlobalCache", + "title": "%command.mcpp.verifyGlobalCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.cleanLegacyCache", + "title": "%command.mcpp.cleanLegacyCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.refreshState", + "title": "%command.mcpp.languageServer.refreshState.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.restart", + "title": "%command.mcpp.languageServer.restart.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.restartEngine", + "title": "%command.mcpp.languageServer.restartEngine.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.resetWorkspaceCache", + "title": "%command.mcpp.languageServer.resetWorkspaceCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.selectContext", + "title": "%command.mcpp.languageServer.selectContext.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.showModuleGraph", + "title": "%command.mcpp.languageServer.showModuleGraph.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.showLogs", + "title": "%command.mcpp.languageServer.showLogs.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.collectReport", + "title": "%command.mcpp.languageServer.collectReport.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.exportDiagnosticBundle", + "title": "%command.mcpp.languageServer.exportDiagnosticBundle.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.runBuildToolInTerminal", + "title": "%command.mcpp.languageServer.runBuildToolInTerminal.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.manageConflicts", + "title": "%command.mcpp.languageServer.manageConflicts.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.toggleInWorkspace", + "title": "%command.mcpp.languageServer.toggleInWorkspace.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.installTools", + "title": "%command.mcpp.languageServer.installTools.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.reviewChanges", + "title": "%command.mcpp.languageServer.reviewChanges.title%", + "category": "%category%" + }, { "command": "mcpp.configureLanguageServer", "title": "%command.mcpp.configureLanguageServer.title%", @@ -136,11 +282,6 @@ "title": "%command.mcpp.checkModuleSupport.title%", "category": "%category%" }, - { - "command": "mcpp.autoConfigureModules", - "title": "%command.mcpp.autoConfigureModules.title%", - "category": "%category%" - }, { "command": "mcpp.showModuleGraph", "title": "%command.mcpp.showModuleGraph.title%", @@ -840,6 +981,54 @@ ], "path": "./syntaxes/mcpp-modules.tmLanguage.json" } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "mcpp", + "title": "%viewsContainer.mcpp.title%", + "icon": "images/logo.png" + } + ] + }, + "views": { + "mcpp": [ + { + "id": "mcpp.project", + "name": "%view.mcpp.project.name%", + "contextualTitle": "%view.mcpp.project.name%" + }, + { + "id": "mcpp.cache", + "name": "%view.mcpp.cache.name%", + "contextualTitle": "%view.mcpp.cache.name%" + }, + { + "id": "mcpp.languageServer", + "name": "%view.mcpp.languageServer.name%", + "description": "%view.mcpp.languageServer.description%" + } + ] + }, + "colors": [ + { + "id": "mcpp.cacheOkForeground", + "description": "%colors.mcpp.cacheOkForeground%", + "defaults": { + "dark": "#89d185", + "light": "#388a34", + "highContrast": "#00ff00" + } + }, + { + "id": "mcpp.cacheStaleForeground", + "description": "%colors.mcpp.cacheStaleForeground%", + "defaults": { + "dark": "#e2c08d", + "light": "#bf8803", + "highContrast": "#ffff00" + } + } ] }, "scripts": { diff --git a/package.nls.json b/package.nls.json index fca0805..37cd2ee 100644 --- a/package.nls.json +++ b/package.nls.json @@ -1,21 +1,49 @@ { "category": "mcpp", - "command.mcpp.autoConfigureModules.title": "一键构建并刷新模块语言服务", - "command.mcpp.build.title": "构建", - "command.mcpp.checkModuleSupport.title": "重启 C++ Modules 语言服务", - "command.mcpp.clean.title": "清理 target", - "command.mcpp.configureClangd.title": "[已弃用] 配置 C++ 模块语言服务", - "command.mcpp.configureLanguageServer.title": "选择 C++ 模块分析上下文", - "command.mcpp.installToolchain.title": "安装工具链", - "command.mcpp.newProject.title": "新建工程", - "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", - "command.mcpp.run.title": "运行", - "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", - "command.mcpp.showLanguageServerLogs.title": "打开 C++ Modules 日志", - "command.mcpp.showMenu.title": "打开快捷菜单", - "command.mcpp.showModuleGraph.title": "查看模块图", - "command.mcpp.showToolchains.title": "查看工具链", - "command.mcpp.test.title": "测试", + "colors.mcpp.cacheOkForeground": "Colour of a cache figure that needs no attention.", + "colors.mcpp.cacheStaleForeground": "Colour of artifacts that can be cleaned.", + "command.mcpp.autoConfigureModules.title": "Build and Refresh the Language Service", + "command.mcpp.build.title": "Build", + "command.mcpp.checkModuleSupport.title": "[Deprecated] C++ Modules: Restart the Language Server", + "command.mcpp.clean.title": "Clean target", + "command.mcpp.cleanLegacyCache.title": "Remove the Pre-v1 Cache", + "command.mcpp.cleanProjectArtifacts.title": "Clean Project Artifacts", + "command.mcpp.cleanStaleArtifacts.title": "Clean Stale Artifacts", + "command.mcpp.configureClangd.title": "[Deprecated] Configure the C++ Module Language Service", + "command.mcpp.configureLanguageServer.title": "[Deprecated] C++ Modules: Select the Analysis Context", + "command.mcpp.gcGlobalCache.title": "Collect the Shared Cache to a Budget", + "command.mcpp.installToolchain.title": "Install Toolchain", + "command.mcpp.languageServer.collectReport.title": "C++ Modules: Collect a Diagnostic Report", + "command.mcpp.languageServer.exportDiagnosticBundle.title": "C++ Modules: Export a Diagnostic Bundle", + "command.mcpp.languageServer.installTools.title": "C++ Modules: Install Command Line Tools", + "command.mcpp.languageServer.manageConflicts.title": "C++ Modules: Manage Other C++ Language Features", + "command.mcpp.languageServer.refreshState.title": "C++ Modules: Refresh This View", + "command.mcpp.languageServer.resetWorkspaceCache.title": "C++ Modules: Reset This Workspace's Cache", + "command.mcpp.languageServer.restart.title": "C++ Modules: Restart the Language Server", + "command.mcpp.languageServer.restartEngine.title": "C++ Modules: Restart the Semantic Engine", + "command.mcpp.languageServer.reviewChanges.title": "C++ Modules: Review Workspace Changes", + "command.mcpp.languageServer.runBuildToolInTerminal.title": "C++ Modules: Run the Build Tool in a Terminal", + "command.mcpp.languageServer.selectContext.title": "C++ Modules: Select the Analysis Context", + "command.mcpp.languageServer.showLogs.title": "C++ Modules: Open the Log", + "command.mcpp.languageServer.showModuleGraph.title": "C++ Modules: Show the Module Graph", + "command.mcpp.languageServer.toggleInWorkspace.title": "C++ Modules: Enable or Disable in This Workspace", + "command.mcpp.newProject.title": "New Project", + "command.mcpp.openMcpplsSettings.title": "Open the C++ Modules Settings", + "command.mcpp.openSettings.title": "Open Settings Panel", + "command.mcpp.pruneGlobalCache.title": "Prune the Shared Cache by Age", + "command.mcpp.refreshCacheStats.title": "Refresh Cache Statistics", + "command.mcpp.refreshCompilationDatabase.title": "Refresh the Module Build Description", + "command.mcpp.run.title": "Run", + "command.mcpp.selectDefaultToolchain.title": "Select the Global Default Toolchain", + "command.mcpp.selfCheck.title": "Environment Self-check", + "command.mcpp.showCacheEntry.title": "Show Cache Entry Details", + "command.mcpp.showCachePanel.title": "Cache Statistics", + "command.mcpp.showLanguageServerLogs.title": "[Deprecated] C++ Modules: Open the Log", + "command.mcpp.showMenu.title": "Open the Quick Menu", + "command.mcpp.showModuleGraph.title": "[Deprecated] C++ Modules: Show the Module Graph", + "command.mcpp.showToolchains.title": "Show Toolchains", + "command.mcpp.test.title": "Test", + "command.mcpp.verifyGlobalCache.title": "Verify the Shared Cache", "description": "Build, run, test and manage mcpp C++23 module projects, with mcpp.toml editing and build-script support.", "displayName": "mcpp", "mcpp.buildScript.diagnostics.description": "Validate build.mcpp against the build API mcpp actually ships.", @@ -151,5 +179,10 @@ "mcpp.views.languageServer.show.title": "Show the language service view", "mcpp.views.project.show.description": "Show the mcpp project view in the activity bar. Takes effect after the view container reloads.", "mcpp.views.project.show.title": "Show the project view", - "untrustedWorkspaces.description": "In an untrusted workspace this extension only provides module syntax highlighting and text-only mcpp.toml completion; it runs no mcpp command and changes no language-service configuration." + "untrustedWorkspaces.description": "In an untrusted workspace this extension only provides module syntax highlighting and text-only mcpp.toml completion; it runs no mcpp command and changes no language-service configuration.", + "view.mcpp.cache.name": "Cache", + "view.mcpp.languageServer.description": "Provided by sunrisepeak.mcpp-language-server; this view only displays and forwards.", + "view.mcpp.languageServer.name": "C++ Modules", + "view.mcpp.project.name": "Project", + "viewsContainer.mcpp.title": "mcpp" } diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index d568e5c..aa5511e 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -1,21 +1,49 @@ { "category": "mcpp", + "colors.mcpp.cacheOkForeground": "无需关注的缓存数值的颜色。", + "colors.mcpp.cacheStaleForeground": "可清理产物的颜色。", "command.mcpp.autoConfigureModules.title": "一键构建并刷新模块语言服务", "command.mcpp.build.title": "构建", - "command.mcpp.checkModuleSupport.title": "重启 C++ Modules 语言服务", + "command.mcpp.checkModuleSupport.title": "[已弃用] C++ Modules: 重启语言服务", "command.mcpp.clean.title": "清理 target", + "command.mcpp.cleanLegacyCache.title": "清理遗留缓存", + "command.mcpp.cleanProjectArtifacts.title": "清理项目产物", + "command.mcpp.cleanStaleArtifacts.title": "清理过期产物", "command.mcpp.configureClangd.title": "[已弃用] 配置 C++ 模块语言服务", - "command.mcpp.configureLanguageServer.title": "选择 C++ 模块分析上下文", + "command.mcpp.configureLanguageServer.title": "[已弃用] C++ Modules: 选择分析上下文", + "command.mcpp.gcGlobalCache.title": "收敛全局缓存到预算", "command.mcpp.installToolchain.title": "安装工具链", + "command.mcpp.languageServer.collectReport.title": "C++ Modules: 收集诊断报告", + "command.mcpp.languageServer.exportDiagnosticBundle.title": "C++ Modules: 导出诊断包", + "command.mcpp.languageServer.installTools.title": "C++ Modules: 安装命令行工具", + "command.mcpp.languageServer.manageConflicts.title": "C++ Modules: 处理其它 C++ 语言特性", + "command.mcpp.languageServer.refreshState.title": "C++ Modules: 刷新此视图", + "command.mcpp.languageServer.resetWorkspaceCache.title": "C++ Modules: 重置本工作区缓存", + "command.mcpp.languageServer.restart.title": "C++ Modules: 重启语言服务", + "command.mcpp.languageServer.restartEngine.title": "C++ Modules: 重启语义引擎", + "command.mcpp.languageServer.reviewChanges.title": "C++ Modules: 审查工作区改动", + "command.mcpp.languageServer.runBuildToolInTerminal.title": "C++ Modules: 在终端运行构建工具", + "command.mcpp.languageServer.selectContext.title": "C++ Modules: 选择分析上下文", + "command.mcpp.languageServer.showLogs.title": "C++ Modules: 打开日志", + "command.mcpp.languageServer.showModuleGraph.title": "C++ Modules: 查看模块图", + "command.mcpp.languageServer.toggleInWorkspace.title": "C++ Modules: 在本工作区启用或停用", "command.mcpp.newProject.title": "新建工程", + "command.mcpp.openMcpplsSettings.title": "打开 C++ Modules 设置", + "command.mcpp.openSettings.title": "打开设置面板", + "command.mcpp.pruneGlobalCache.title": "按时间清理全局缓存", + "command.mcpp.refreshCacheStats.title": "刷新缓存统计", "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", "command.mcpp.run.title": "运行", "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", - "command.mcpp.showLanguageServerLogs.title": "打开 C++ Modules 日志", + "command.mcpp.selfCheck.title": "环境自检", + "command.mcpp.showCacheEntry.title": "查看缓存条目详情", + "command.mcpp.showCachePanel.title": "缓存统计", + "command.mcpp.showLanguageServerLogs.title": "[已弃用] C++ Modules: 打开日志", "command.mcpp.showMenu.title": "打开快捷菜单", - "command.mcpp.showModuleGraph.title": "查看模块图", + "command.mcpp.showModuleGraph.title": "[已弃用] C++ Modules: 查看模块图", "command.mcpp.showToolchains.title": "查看工具链", "command.mcpp.test.title": "测试", + "command.mcpp.verifyGlobalCache.title": "校验全局缓存", "description": "构建、运行、测试并管理 mcpp C++23 模块工程,提供 mcpp.toml 编辑与构建脚本支持。", "displayName": "mcpp", "mcpp.buildScript.diagnostics.description": "按 mcpp 实际提供的构建 API 校验 build.mcpp。", @@ -151,5 +179,10 @@ "mcpp.views.languageServer.show.title": "显示语言服务视图", "mcpp.views.project.show.description": "在活动栏显示 mcpp 工程视图。视图容器重载后生效。", "mcpp.views.project.show.title": "显示工程视图", - "untrustedWorkspaces.description": "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。" + "untrustedWorkspaces.description": "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。", + "view.mcpp.cache.name": "缓存", + "view.mcpp.languageServer.description": "由 sunrisepeak.mcpp-language-server 提供;此视图只做展示与转发。", + "view.mcpp.languageServer.name": "C++ Modules", + "view.mcpp.project.name": "工程", + "viewsContainer.mcpp.title": "mcpp" } diff --git a/src/cli/controller.ts b/src/cli/controller.ts index 29a97d5..7416d82 100644 --- a/src/cli/controller.ts +++ b/src/cli/controller.ts @@ -25,7 +25,8 @@ import { type TaskCompletion, } from "./tasks"; import { CLI_COMMANDS } from "../commands/ids"; -import { quickMenuItems, quickMenuStatusText } from "../commands/menu"; +import { QUICK_MENU_GROUPS, quickMenuItems, quickMenuStatusText } from "../commands/menu"; +import { t } from "../i18n/t"; import { runNewProjectFlow, validateNewProjectName } from "./newProject"; export interface McppCliControllerOptions { @@ -492,16 +493,15 @@ export class McppCliController { } private async showMenu(): Promise { + const groupLabel = new Map(QUICK_MENU_GROUPS.map((group) => [group.id, t(group.labelKey)])); const items = quickMenuItems.map((item) => ({ - label: item.label, - description: item.group === "project" - ? "当前 mcpp 工程" - : item.group === "toolchain" ? "mcpp 工具链管理" : "C++ Modules 语言服务", + label: t(item.labelKey), + description: groupLabel.get(item.group) ?? item.group, command: item.command, })); const picked = await vscode.window.showQuickPick(items, { - title: "mcpp 快捷菜单", - placeHolder: "选择项目、工具链或 IDE 操作", + title: t("mcpp: quick menu"), + placeHolder: t("Choose a project, toolchain, cache or C++ Modules action"), matchOnDescription: true, }); if (picked !== undefined) { 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/commands/ids.ts b/src/commands/ids.ts index 134c8ae..e4e8a25 100644 --- a/src/commands/ids.ts +++ b/src/commands/ids.ts @@ -1,3 +1,18 @@ +/** + * 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", @@ -8,16 +23,79 @@ export const CLI_COMMANDS = { 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", - autoConfigureModules: "mcpp.autoConfigureModules", 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", + 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 = { + showPanel: "mcpp.showCachePanel", + 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; + +/** 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 CliCommandId = (typeof CLI_COMMANDS)[keyof typeof CLI_COMMANDS]; +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 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(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 index 1b47cc1..3626c82 100644 --- a/src/commands/menu.ts +++ b/src/commands/menu.ts @@ -1,25 +1,49 @@ -import { CLI_COMMANDS } from "./ids"; - -export const quickMenuStatusText = "$(tools) mcpp: 快捷菜单"; +import { CACHE_COMMANDS, CLI_COMMANDS, LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "./ids"; +/** + * The status-bar menu. Entries are keyed by command id; the titles are resolved + * through `src/i18n/t.ts` when the menu is built, so this table stays data. + */ export interface QuickMenuItem { - label: string; + labelKey: string; command: string; - group: "project" | "toolchain" | "ide"; + group: "project" | "toolchain" | "cache" | "languageServer" | "settings"; } 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" }, + { labelKey: "Build", command: CLI_COMMANDS.build, group: "project" }, + { labelKey: "Run", command: CLI_COMMANDS.run, group: "project" }, + { labelKey: "Test", command: CLI_COMMANDS.test, group: "project" }, + { labelKey: "Clean project artifacts", command: CACHE_COMMANDS.cleanProject, group: "project" }, + + { labelKey: "Show toolchains", command: CLI_COMMANDS.showToolchains, group: "toolchain" }, + { labelKey: "Install toolchain", command: CLI_COMMANDS.installToolchain, group: "toolchain" }, + { labelKey: "Select the global default toolchain", command: CLI_COMMANDS.selectDefaultToolchain, group: "toolchain" }, + + { labelKey: "Clean stale artifacts", command: CACHE_COMMANDS.cleanStale, group: "cache" }, + { labelKey: "Cache statistics", command: CACHE_COMMANDS.showPanel, group: "cache" }, + { labelKey: "Refresh cache statistics", command: CACHE_COMMANDS.refreshStats, group: "cache" }, + { labelKey: "Collect the global cache to a budget", command: CACHE_COMMANDS.collect, group: "cache" }, + { labelKey: "Verify the shared cache", command: CACHE_COMMANDS.verify, group: "cache" }, + + { labelKey: "C++ Modules: restart the language server", command: LANGUAGE_SERVER_COMMANDS.restart, group: "languageServer" }, + { labelKey: "C++ Modules: select the analysis context", command: LANGUAGE_SERVER_COMMANDS.selectContext, group: "languageServer" }, + { labelKey: "C++ Modules: show the module graph", command: LANGUAGE_SERVER_COMMANDS.showModuleGraph, group: "languageServer" }, + { labelKey: "C++ Modules: open the log", command: LANGUAGE_SERVER_COMMANDS.showLogs, group: "languageServer" }, + { labelKey: "C++ Modules: reset this workspace's cache", command: LANGUAGE_SERVER_COMMANDS.resetWorkspaceCache, group: "languageServer" }, + + { labelKey: "Build and refresh the language service", command: CLI_COMMANDS.autoConfigureModules, group: "settings" }, + { labelKey: "Environment self-check", command: TOOL_COMMANDS.selfCheck, group: "settings" }, + { labelKey: "mcpp settings", command: TOOL_COMMANDS.openSettings, group: "settings" }, +]; + +export const quickMenuStatusText = "$(tools) mcpp"; + +/** The group a menu entry belongs to, localized by the caller. */ +export const QUICK_MENU_GROUPS: ReadonlyArray<{ id: QuickMenuItem["group"]; labelKey: string }> = [ + { id: "project", labelKey: "Current mcpp project" }, + { id: "toolchain", labelKey: "mcpp toolchain management" }, + { id: "cache", labelKey: "Build cache" }, + { id: "languageServer", labelKey: "C++ Modules language service" }, + { id: "settings", labelKey: "Settings and diagnostics" }, ]; diff --git a/src/config/panel.ts b/src/config/panel.ts new file mode 100644 index 0000000..e025cde --- /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 { randomBytes } from "node:crypto"; +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 { effective, onDidChange, write, type WriteTarget } from "./access"; +import { + PANEL_UI, + decodePanelMessage, + renderPanelHtml, + type PanelAssets, + 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; +} + +/** 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 }; + session = active; + panel.webview.html = renderPanelHtml(buildModel(resource), assets(panel.webview, host)); + 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 "ready": + break; + 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)); +} + +function assets(webview: vscode.Webview, host: PanelHostContext): PanelAssets { + return { + cspSource: webview.cspSource, + nonce: randomBytes(16).toString("base64"), + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot(host), STYLESHEET)).toString(), + // The client script is inline and nonced; the CSP names no external script + // source, so this stays empty on purpose. + scriptUri: "", + }; +} 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/extension.ts b/src/extension.ts index ff04714..12ac62a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,10 @@ import * as vscode from "vscode"; import { McppCliController } from "./cli/controller"; -import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "./commands/ids"; +import { runProcess } from "./cli/process"; +import { parseProtocolInfo } from "./cli/protocol"; +import { buildSelfCheckText } from "./cli/selfCheck"; +import { CLI_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "./commands/ids"; import { findNearestMcppProject, type McppProjectDiscovery } from "./projects/discovery"; import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./projects/context"; import { @@ -9,9 +12,17 @@ import { type LanguageServerBridge, type LanguageServerCommandResult, } from "./mcppls/bridge"; -import { MCPPLS_EXTENSION_ID } from "./mcppls/contract"; +import { CAPABILITIES, MCPPLS_EXTENSION_ID } from "./mcppls/contract"; import { formatResult } from "./mcppls/messages"; -import { computeMcppTomlCompletions } from "./toml/completion"; +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, @@ -20,8 +31,12 @@ import { type ModuleSetupStepResult, } from "./workflows/moduleSetup"; import type { TaskCompletion } from "./cli/tasks"; -import { onDidChange as onConfigurationChanged, read } from "./config/access"; -import { setLanguagePreference, t, type LanguagePreference } from "./i18n/t"; +import { changedSettings, onDidChange as onConfigurationChanged, read } from "./config/access"; +import { registerSettingsPanel } from "./config/panel"; +import { languagePreference, setLanguagePreference, t, type LanguagePreference } from "./i18n/t"; +import { registerCacheView } from "./views/cacheView"; +import { registerLanguageServerView } from "./views/languageServerView"; +import { registerProjectView } from "./views/projectView"; /** * The commands an extension declares in its own `package.json`, read without @@ -40,6 +55,9 @@ function declaredCommandsOf(id: string): readonly string[] | undefined { .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")); @@ -183,45 +201,6 @@ async function autoConfigureModulesWizard( } } -// 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"); @@ -273,6 +252,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return; } const result = 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)); @@ -288,42 +268,64 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi isTrusted: () => vscode.workspace.isTrusted, }); + // ── 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. + registerProjectView(extensionContext, { currentProject: findCurrentProject }); + registerCacheView(extensionContext, { + output, + currentProject: findCurrentProject, + mcppExecutable: (project) => cliController.mcppExecutable(project), + isTrusted: () => vscode.workspace.isTrusted, + }); + registerLanguageServerView(extensionContext, { bridge, output }); + 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"), + ); + registerBuildScriptProviders(extensionContext, () => + read("mcpp.buildScript.diagnostics") + ? read<"warning" | "info" | "off">("mcpp.buildScript.diagnostics.severity") + : "off", + ); + extensionContext.subscriptions.push( output, 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()), ); @@ -334,6 +336,56 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi ); } +/** + * 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 probeResult = await runProcess(executable, ["--protocol-version"], project?.root, { timeoutMs: 20_000 }); + const info = 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, + }); + output.appendLine(""); + output.appendLine("===== mcpp: environment self-check ====="); + output.appendLine(text); + output.appendLine("========================================"); + output.show(true); +} + export function deactivate(): void { // VS Code disposes everything registered in activate(). } diff --git a/src/mcppls/stateSource.ts b/src/mcppls/stateSource.ts new file mode 100644 index 0000000..275d3d0 --- /dev/null +++ b/src/mcppls/stateSource.ts @@ -0,0 +1,52 @@ +/** + * 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. + */ + +import * as vscode from "vscode"; + +import { readStateFromExports, type McpplsStateView } from "./state"; +import { MCPPLS_EXTENSION_ID } from "./contract"; + +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(), + }; + if (extension === undefined) { + return { available: false, reason: `mcppls ${MCPPLS_EXTENSION_ID} is not installed`, ...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/toml/providers.ts b/src/toml/providers.ts new file mode 100644 index 0000000..079d1b2 --- /dev/null +++ b/src/toml/providers.ts @@ -0,0 +1,124 @@ +/** + * `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. + */ + +import * as vscode from "vscode"; + +import { computeMcppTomlCompletions } from "./completion"; +import { analyseManifest, type DiagnosticSettings, type Severity } from "./diagnostics"; + +export const MCPP_TOML_LANGUAGE = "mcpp-toml"; + +function completionItemKind(kind: "section" | "template"): vscode.CompletionItemKind { + return kind === "section" ? vscode.CompletionItemKind.Folder : 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, +): void { + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: MCPP_TOML_LANGUAGE }, + { + 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); + } + return computeMcppTomlCompletions(lines, position.line, position.character).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; + }); + }, + }, + "[", + ), + ); + + 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/views/cacheView.ts b/src/views/cacheView.ts new file mode 100644 index 0000000..ca09559 --- /dev/null +++ b/src/views/cacheView.ts @@ -0,0 +1,441 @@ +/** + * The cache view and every cleanup command. + * + * 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. + */ + +import * as vscode from "vscode"; + +import { estimateArtifacts, type ArtifactEstimate } from "../cli/artifacts"; +import { parseCacheDir, parseCacheList, summarizeCache, type CacheInventory } from "../cli/cache"; +import { planClean, withSharedCache, type CleanAction, type CleanPlan } from "../cli/clean"; +import { runProcess, type ProcessResult } from "../cli/process"; +import type { CacheEntry } from "../cli/cache"; +import { CACHE_COMMANDS } from "../commands/ids"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import { formatBytes, projectGc } from "../util/format"; +import { clampOutput } from "../util/text"; +import { buildCacheTree, type CacheTreeInput } from "./models"; +import { registerTreeView } from "./treeProvider"; + +export const CACHE_VIEW_ID = "mcpp.cache"; + +/** How long a cache query may take before we give up and say so. */ +const QUERY_TIMEOUT_MS = 60_000; +const CLEAN_TIMEOUT_MS = 300_000; + +export interface CacheViewDeps { + output: vscode.OutputChannel; + currentProject: () => McppProjectDiscovery | undefined; + mcppExecutable: (project: McppProjectDiscovery | undefined) => string; + isTrusted: () => boolean; +} + +interface CacheState { + entries: CacheEntry[]; + inventory?: CacheInventory; + artifacts?: ArtifactEstimate; + legacyPath?: string; + legacyBytes?: number; + error?: string; +} + +function workingDirectory(project: McppProjectDiscovery | undefined): string | undefined { + return project?.root; +} + +/** Settings that shape the numbers the view shows. */ +function viewSettings(): { topN: number; ageBoundaries: string[] } { + return { + topN: Math.max(1, read("mcpp.views.cache.topN")), + ageBoundaries: read("mcpp.views.cache.ageBuckets"), + }; +} + +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 runProcess(executable, [...argv], cwd, { timeoutMs: options.timeoutMs }) + : await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `mcpp ${argv[0]}` }, + () => runProcess(executable, [...argv], cwd, { timeoutMs: options.timeoutMs }), + ); + 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; +} + +export function registerCacheView(context: vscode.ExtensionContext, deps: CacheViewDeps): void { + let state: CacheState = { entries: [] }; + + const treeInput = (): CacheTreeInput => { + const settings = viewSettings(); + return { + projectRoot: deps.currentProject()?.root, + artifacts: + state.artifacts === undefined + ? undefined + : { + exists: state.artifacts.exists, + totalBytes: state.artifacts.totalBytes, + files: state.artifacts.files, + groups: state.artifacts.byTopLevel.length, + truncated: state.artifacts.truncated, + }, + inventory: + state.inventory === undefined + ? undefined + : { + root: state.inventory.root, + totalBytes: state.inventory.totalBytes, + totalEntries: state.inventory.totalEntries, + byKind: state.inventory.byKind, + topLabels: state.inventory.topLabels, + incomplete: state.inventory.incomplete.length, + oldestAccessed: state.inventory.oldestAccessed, + newestAccessed: state.inventory.newestAccessed, + ageBuckets: state.inventory.ageBuckets, + }, + legacyBytes: state.legacyBytes, + error: state.error, + }; + }; + + const view = registerTreeView(CACHE_VIEW_ID, () => buildCacheTree(treeInput())); + context.subscriptions.push(view.disposable); + + const refresh = async (): Promise => { + const project = deps.currentProject(); + const settings = viewSettings(); + state = { entries: [] }; + + if (!deps.isTrusted()) { + state.error = t("the workspace is not trusted"); + view.provider.refresh(); + return; + } + + const listed = await run(deps, project, ["cache", "list", "--format", "json"], { + timeoutMs: QUERY_TIMEOUT_MS, + quiet: true, + }); + if (listed.exitCode === 0) { + const parsed = parseCacheList(listed.stdout); + if (parsed === undefined) { + state.error = t("mcpp cache list did not return the documented document"); + } else { + state.entries = parsed.entries; + state.inventory = summarizeCache(parsed.root, parsed.entries, { + topN: settings.topN, + ageBoundaries: settings.ageBoundaries, + }); + } + } else { + state.error = t("mcpp cache list failed (exit {0})", listed.exitCode); + } + + const dir = await run(deps, project, ["cache", "dir"], { timeoutMs: QUERY_TIMEOUT_MS, quiet: true }); + if (dir.exitCode === 0) { + const parsed = parseCacheDir(dir.stdout); + state.legacyPath = parsed.legacyPath; + } + + if (project !== undefined && read("mcpp.cache.estimateProjectBytes")) { + state.artifacts = estimateArtifacts(project.root); + } + + view.provider.refresh(); + }; + + /** 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)); + }; + + 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)); + } + }), + ); + }; + + register(CACHE_COMMANDS.refreshStats, async () => { + if (!requireTrusted()) return; + await refresh(); + }); + + register(CACHE_COMMANDS.showPanel, async () => { + if (!requireTrusted()) return; + await refresh(); + await preview(t("Cache statistics"), cacheSummaryText(treeInput())); + }); + + 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: QUERY_TIMEOUT_MS, 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"); + const runLabel = t("Run"); + const choice = await vscode.window.showWarningMessage( + t(base.titleKey), + { modal: true, detail: t(base.detailKey) }, + runLabel, + alsoCache, + ); + if (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 runPlan(project, planClean("cacheGc", { 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); + } + } + view.provider.refresh(); + + // Keep the tree in step with settings that change its shape. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.views.cache") || event.affectsConfiguration("mcpp.cache")) { + view.provider.refresh(); + } + }), + ); +} + +/** The panel's text form; the webview panel is a later milestone. */ +export function cacheSummaryText(input: CacheTreeInput): string { + const lines: string[] = []; + const artifacts = input.artifacts; + lines.push(`## ${t("Project artifacts")}`); + if (artifacts === undefined) { + lines.push(`- ${t("Not measured yet")}`); + } else if (!artifacts.exists) { + lines.push(`- ${t("No target/ directory")}`); + } else { + lines.push(`- ${t("Estimated size")}: ${artifacts.totalBytes} B · ${artifacts.files} file(s) · ${artifacts.groups} group(s)`); + } + const inventory = input.inventory; + lines.push(""); + lines.push(`## ${t("Global build cache")}`); + if (inventory === undefined) { + lines.push(`- ${input.error ?? t("not read yet")}`); + } else { + lines.push(`- ${inventory.totalEntries} entries · ${inventory.totalBytes} B`); + for (const kind of inventory.byKind) { + lines.push(`- ${kind.kind}: ${kind.entries} · ${kind.bytes} B`); + } + if (inventory.incomplete > 0) { + lines.push(`- ${t("Incomplete entries")}: ${inventory.incomplete}`); + } + lines.push(""); + lines.push(`### ${t("Largest packages")}`); + for (const entry of inventory.topLabels) { + lines.push(`- ${entry.label}: ${entry.bytes} B · ${entry.entries}`); + } + } + return lines.join("\n"); +} diff --git a/src/views/languageServerView.ts b/src/views/languageServerView.ts new file mode 100644 index 0000000..55a1741 --- /dev/null +++ b/src/views/languageServerView.ts @@ -0,0 +1,149 @@ +/** + * The C++ Modules view: mcppls's state, and every action that forwards to it. + * + * The view states its provider in its own description, so nobody has to guess + * which extension owns what. Every button forwards an mcppls command — this + * extension never reimplements language-service behaviour and never writes an + * `mcppls.*` setting itself. + * + * Confirmation policy comes from the capability table (`danger` + `confirmHint`), + * so a new dangerous action cannot be added without saying what it does. + */ + +import * as vscode from "vscode"; + +import { LANGUAGE_SERVER_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "../commands/ids"; +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 { buildLanguageServerTree } from "./models"; +import { registerTreeView } from "./treeProvider"; + +export const LANGUAGE_SERVER_VIEW_ID = "mcpp.languageServer"; + +export interface LanguageServerViewDeps { + bridge: LanguageServerBridge; + output: vscode.OutputChannel; +} + +/** 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 } = {}, +): Promise { + 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; + } + } + 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); + } +} + +export function registerLanguageServerView( + context: vscode.ExtensionContext, + deps: LanguageServerViewDeps, +): void { + const view = registerTreeView(LANGUAGE_SERVER_VIEW_ID, () => + buildLanguageServerTree({ + installed: languageServerInstalled(), + enabled: languageServerEnabled(), + version: languageServerVersion(), + state: readLanguageServerState(), + }), + ); + context.subscriptions.push(view.disposable); + + 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(`C++ Modules 命令失败:${message}`); + void vscode.window.showErrorMessage(t("{0} failed: {1}", id, message)); + } + }), + ); + }; + + const forward = (id: string, key: string, args?: () => unknown[]): void => { + register(id, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, key, args === undefined ? {} : { args: args() }); + view.provider.refresh(); + }); + }; + + register(LANGUAGE_SERVER_COMMANDS.refreshState, async () => { + view.provider.refresh(); + }); + + 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"); + forward(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle, "diagnosticBundle"); + 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(() => view.provider.refresh()), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcppls.enable") || event.affectsConfiguration("mcpp.views.languageServer")) { + view.provider.refresh(); + } + }), + ); + + // A capability that has gone missing is worth one sentence, once. + if (!languageServerInstalled()) { + deps.output.appendLine( + t("The C++ Modules extension ({0}) is not installed or is disabled.", capability("refresh")?.key ?? ""), + ); + } +} diff --git a/src/views/projectView.ts b/src/views/projectView.ts new file mode 100644 index 0000000..ee59824 --- /dev/null +++ b/src/views/projectView.ts @@ -0,0 +1,70 @@ +/** + * The project view: identity, toolchain, targets, actions. + * + * The summary is read from `mcpp.toml` and refreshed when the manifest changes; + * anything unreadable is omitted rather than guessed, and `mcpp: Environment + * Self-check` is the place that reports what mcpp itself resolves. + */ + +import { readFileSync } from "node:fs"; + +import * as vscode from "vscode"; + +import { readProjectSummary } from "../projects/summary"; +import { MCPP_MANIFEST_GLOB } from "../projects/context"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import { buildProjectTree } from "./models"; +import { registerTreeView } from "./treeProvider"; + +export const PROJECT_VIEW_ID = "mcpp.project"; + +export interface ProjectViewDeps { + currentProject: () => McppProjectDiscovery | undefined; +} + +/** Read one manifest into a summary; a failure becomes `error`, never a throw. */ +export function summariseProject(project: McppProjectDiscovery | undefined): ReturnType | undefined { + if (project === undefined) { + return undefined; + } + try { + const text = readFileSync(project.manifestPath, "utf8"); + return readProjectSummary(project.root, text.split(/\r?\n/)); + } catch (error) { + return { + root: project.root, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +export function registerProjectView(context: vscode.ExtensionContext, deps: ProjectViewDeps): void { + let summary = summariseProject(deps.currentProject()); + + const view = registerTreeView(PROJECT_VIEW_ID, () => buildProjectTree(summary)); + context.subscriptions.push(view.disposable); + + const reload = (): void => { + summary = summariseProject(deps.currentProject()); + view.provider.refresh(); + }; + + const watcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); + context.subscriptions.push( + watcher, + watcher.onDidCreate(reload), + watcher.onDidChange(reload), + watcher.onDidDelete(reload), + vscode.window.onDidChangeActiveTextEditor(reload), + vscode.workspace.onDidChangeWorkspaceFolders(reload), + vscode.workspace.onDidGrantWorkspaceTrust(reload), + ); + + context.subscriptions.push( + vscode.commands.registerCommand("mcpp.internal.refreshProjectView", async () => { + reload(); + }), + ); + + reload(); +} diff --git a/src/views/treeProvider.ts b/src/views/treeProvider.ts new file mode 100644 index 0000000..1aea5e4 --- /dev/null +++ b/src/views/treeProvider.ts @@ -0,0 +1,83 @@ +/** + * One tree provider for all three views. + * + * 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, 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 ?? [])); +} + +export function toTreeItem(node: TreeNode): vscode.TreeItem { + const collapsible = + node.children === undefined + ? vscode.TreeItemCollapsibleState.None + : node.children.length === 0 + ? vscode.TreeItemCollapsibleState.None + : 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) { + item.iconPath = new vscode.ThemeIcon(node.icon); + } + 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. + */ +export class StaticTreeProvider implements vscode.TreeDataProvider { + 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 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 returns nothing; the provider can be refreshed by the caller. */ +export function registerTreeView( + viewId: string, + roots: () => readonly TreeNode[], +): { provider: StaticTreeProvider; disposable: vscode.Disposable } { + const provider = new StaticTreeProvider(roots); + const disposable = vscode.window.registerTreeDataProvider(viewId, provider); + return { provider, disposable }; +} diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 81c4b64..f2746d7 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -3,6 +3,8 @@ import { 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; @@ -23,6 +25,9 @@ interface PackageManifest { 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>; + colors?: Array<{ id: string; description?: string }>; grammars?: Array<{ language?: string; scopeName: string; injectTo?: string[]; path: string }>; }; } @@ -38,32 +43,32 @@ test("declares mcpp-language-server as the C++ modules language service", () => 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, "%untrustedWorkspaces.description%"); + // Same set, not necessarily the same order: the manifest's order is the + // palette's presentation order, which `ids.ts` has no business dictating. + assert.deepEqual( + (manifest.contributes?.commands?.map((command) => command.command) ?? []).slice().sort(), + contributedCommandIds().slice().sort(), + ); + assert.deepEqual( + manifest.contributes?.viewsContainers?.activitybar?.map((container) => container.id), + ["mcpp"], + ); + assert.deepEqual( + manifest.contributes?.views?.mcpp?.map((view) => view.id), + ["mcpp.project", "mcpp.cache", "mcpp.languageServer"], + ); 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?.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"]); @@ -85,8 +90,10 @@ test("declares mcpp-language-server as the C++ modules language service", () => test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服务", () => { const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + // Slice up to a marker that exists for its own sake, not as a test hook: the + // wizard is the last function before `activate`. const start = source.indexOf("async function autoConfigureModulesWizard"); - const end = source.indexOf("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); diff --git a/test/cli/selfCheck.test.ts b/test/cli/selfCheck.test.ts new file mode 100644 index 0000000..7757e57 --- /dev/null +++ b/test/cli/selfCheck.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildSelfCheckText, type SelfCheckInput } from "../../src/cli/selfCheck"; + +const BASE: SelfCheckInput = { + extensionVersion: "0.5.0", + vscodeVersion: "1.91.0", + platform: "linux-x64", + languagePreference: "auto", + trusted: true, + workspaceRoots: ["/w"], + projectRoot: "/w", + mcppPath: "/usr/bin/mcpp", + mcppProbe: { version: "2026.9.30.2", envelopeMax: 1, kinds: ["mcpp.env", "mcpp.toolchain.list"] }, + mcppls: { + installed: true, + version: "0.0.9", + enabled: true, + state: "ready · project mcpp · engine clangd 23.1.0", + capabilities: [ + { key: "refresh", state: "available", command: "mcppls.reloadBuildDescription" }, + { key: "moduleGraph", state: "missing" }, + ], + }, + cache: { totalBytes: 7_736_306_884, entries: 657, incomplete: 2 }, + changedSettings: [{ key: "mcpp.cache.staleDays", value: 7, source: "workspace" }], + lastRefresh: { at: "2026-10-02T12:00:00Z", state: "completed", command: "mcppls.reloadBuildDescription" }, +}; + +test("the snapshot names every version and platform up front", () => { + const text = buildSelfCheckText(BASE); + assert.match(text, /^mcpp-vscode 0\.5\.0 · VS Code 1\.91\.0 · linux-x64$/m); + assert.match(text, /Language\s+auto/); + assert.match(text, /Workspace\s+trusted · 1 root\(s\)/); +}); + +test("an untrusted workspace is called out", () => { + assert.match(buildSelfCheckText({ ...BASE, trusted: false }), /NOT trusted/); +}); + +test("the mcpp section reports the protocol and the advertised kinds", () => { + const text = buildSelfCheckText(BASE); + assert.match(text, /version\s+2026\.9\.30\.2/); + assert.match(text, /envelope\s+1/); + assert.match(text, /kinds\s+mcpp\.env, mcpp\.toolchain\.list/); +}); + +test("a missing protocol answer is reported as unknown, not left blank", () => { + const text = buildSelfCheckText({ ...BASE, mcppProbe: undefined }); + assert.match(text, /version\s+unknown \(mcpp --protocol-version did not answer\)/); +}); + +test("the language-service section lists each capability and its fate", () => { + const text = buildSelfCheckText(BASE); + assert.match(text, /refresh\s+available → mcppls\.reloadBuildDescription/); + assert.match(text, /moduleGraph\s+missing/); + assert.match(text, /last refresh\s+completed at 2026-10-02T12:00:00Z/); +}); + +test("a language service that has never refreshed says so", () => { + assert.match(buildSelfCheckText({ ...BASE, lastRefresh: undefined }), /last refresh\s+never/); +}); + +test("the cache figure is formatted, and an unread cache says so", () => { + assert.match(buildSelfCheckText(BASE), /shared\s+7\.20 GiB · 657 entries · 2 incomplete/); + assert.match(buildSelfCheckText({ ...BASE, cache: undefined }), /shared\s+not read/); +}); + +test("changed settings are listed with the scope that won", () => { + const text = buildSelfCheckText(BASE); + assert.match(text, /Settings changed from their default \(1\)/); + assert.match(text, /workspace\s+mcpp\.cache\.staleDays = 7/); +}); + +test("no changed settings is stated rather than shown as an empty list", () => { + assert.match(buildSelfCheckText({ ...BASE, changedSettings: [] }), /none/); +}); + +test("a workspace with no project says so", () => { + assert.match(buildSelfCheckText({ ...BASE, projectRoot: undefined }), /Project\s+no mcpp\.toml found/); +}); + +test("every workspace root is listed", () => { + const text = buildSelfCheckText({ ...BASE, workspaceRoots: ["/a", "/b"] }); + assert.match(text, /2 root\(s\)/); + assert.match(text, /folder\s+\/a/); + assert.match(text, /folder\s+\/b/); +}); diff --git a/test/commands/ids.test.ts b/test/commands/ids.test.ts index c8c7092..5d0c7c3 100644 --- a/test/commands/ids.test.ts +++ b/test/commands/ids.test.ts @@ -1,15 +1,35 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "../../src/commands/ids"; -import { quickMenuItems, quickMenuStatusText } from "../../src/commands/menu"; +import { + CACHE_COMMANDS, + CLI_COMMANDS, + DEPRECATED_COMMANDS, + LANGUAGE_SERVER_COMMANDS, + LEGACY_LANGUAGE_SERVER_COMMANDS, + TOOL_COMMANDS, + contributedCommandIds, +} from "../../src/commands/ids"; +import { QUICK_MENU_GROUPS, quickMenuItems, quickMenuStatusText } from "../../src/commands/menu"; -test("状态栏快捷菜单名称与 mcpp 项目状态易于区分", () => { - assert.equal(quickMenuStatusText, "$(tools) mcpp: 快捷菜单"); +const ALL = contributedCommandIds(); + +test("the status-bar label identifies mcpp and is not the language-service item", () => { + assert.equal(quickMenuStatusText, "$(tools) mcpp"); +}); + +test("no command id collides with a server-advertised mcppls id", () => { + for (const id of ALL) { + assert.ok(id.startsWith("mcpp."), `${id} must be namespaced with mcpp.`); + } +}); + +test("ids are unique across every group", () => { + assert.equal(new Set(ALL).size, ALL.length); }); -test("CLI 命令覆盖项目、工具链和 C++ Modules 语言服务", () => { - assert.deepEqual(Object.values(CLI_COMMANDS), [ +test("every 0.4.x id is still contributed", () => { + for (const id of [ "mcpp.showMenu", "mcpp.newProject", "mcpp.build", @@ -25,34 +45,68 @@ test("CLI 命令覆盖项目、工具链和 C++ Modules 语言服务", () => { "mcpp.autoConfigureModules", "mcpp.showModuleGraph", "mcpp.showLanguageServerLogs", + "mcpp.configureClangd", + ]) { + assert.ok(ALL.includes(id), `${id} disappeared`); + } +}); + +test("the C++ Modules view forwards one command per capability", () => { + assert.deepEqual(Object.values(LANGUAGE_SERVER_COMMANDS), [ + "mcpp.languageServer.refreshState", + "mcpp.languageServer.restart", + "mcpp.languageServer.restartEngine", + "mcpp.languageServer.resetWorkspaceCache", + "mcpp.languageServer.selectContext", + "mcpp.languageServer.showModuleGraph", + "mcpp.languageServer.showLogs", + "mcpp.languageServer.collectReport", + "mcpp.languageServer.exportDiagnosticBundle", + "mcpp.languageServer.runBuildToolInTerminal", + "mcpp.languageServer.manageConflicts", + "mcpp.languageServer.toggleInWorkspace", + "mcpp.languageServer.installTools", + "mcpp.languageServer.reviewChanges", ]); - assert.deepEqual( - quickMenuItems.map((item) => item.command), - [ - "mcpp.build", - "mcpp.run", - "mcpp.test", - "mcpp.clean", - "mcpp.showToolchains", - "mcpp.installToolchain", - "mcpp.selectDefaultToolchain", - "mcpp.configureLanguageServer", - "mcpp.refreshCompilationDatabase", - "mcpp.checkModuleSupport", - "mcpp.showModuleGraph", - "mcpp.showLanguageServerLogs", - "mcpp.autoConfigureModules", - ], - ); - assert.ok(quickMenuItems.every((item) => item.label.length > 0)); - assert.ok(quickMenuItems.some((item) => item.label.includes("模块语言服务"))); - assert.ok(quickMenuItems.some((item) => item.label.includes("模块图"))); - assert.ok(quickMenuItems.some((item) => item.label.includes("C++ Modules 日志"))); - assert.ok(quickMenuItems.every((item) => !item.label.includes("clangd"))); }); -test("保留 configureClangd 作为仅转发到 mcppls 的弃用别名", () => { +test("the cache group carries both cleanup levels and the read-only pair", () => { + assert.equal(CACHE_COMMANDS.cleanStale, "mcpp.cleanStaleArtifacts"); + assert.equal(CACHE_COMMANDS.cleanProject, "mcpp.cleanProjectArtifacts"); + assert.equal(CACHE_COMMANDS.verify, "mcpp.verifyGlobalCache"); + assert.equal(CACHE_COMMANDS.refreshStats, "mcpp.refreshCacheStats"); +}); + +test("the deprecated alias is contributed but never offered", () => { assert.equal(DEPRECATED_COMMANDS.configureClangd, "mcpp.configureClangd"); - assert.ok(!(Object.values(CLI_COMMANDS) as readonly string[]).includes(DEPRECATED_COMMANDS.configureClangd)); - assert.ok(quickMenuItems.every((item) => item.command !== DEPRECATED_COMMANDS.configureClangd)); + assert.ok(ALL.includes(DEPRECATED_COMMANDS.configureClangd)); + assert.ok(!quickMenuItems.some((item) => item.command === DEPRECATED_COMMANDS.configureClangd)); +}); + +test("every menu entry names a contributed command and a non-empty label", () => { + for (const item of quickMenuItems) { + assert.ok(ALL.includes(item.command), `${item.command} is not contributed`); + assert.ok(item.labelKey.length > 0); + assert.ok(!/clangd/i.test(item.labelKey), `${item.labelKey} mentions clangd`); + assert.ok(QUICK_MENU_GROUPS.some((group) => group.id === item.group), `${item.command} has no menu group`); + } +}); + +test("the menu covers the four things a user looks for", () => { + const commands = quickMenuItems.map((item) => item.command); + assert.ok(commands.includes(CLI_COMMANDS.build)); + assert.ok(commands.includes(CACHE_COMMANDS.cleanStale)); + assert.ok(commands.includes(LANGUAGE_SERVER_COMMANDS.restart)); + assert.ok(commands.includes(TOOL_COMMANDS.selfCheck)); +}); + +test("the legacy language-service ids are kept and distinct from the new ones", () => { + for (const id of Object.values(LEGACY_LANGUAGE_SERVER_COMMANDS)) { + assert.ok(ALL.includes(id), id); + } + assert.ok( + !Object.values(LANGUAGE_SERVER_COMMANDS).some((id) => + (Object.values(LEGACY_LANGUAGE_SERVER_COMMANDS) as readonly string[]).includes(id), + ), + ); }); diff --git a/test/config/panelHtml.test.ts b/test/config/panelHtml.test.ts new file mode 100644 index 0000000..fabddfe --- /dev/null +++ b/test/config/panelHtml.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PANEL_UI, + decodePanelMessage, + renderPanelHtml, + type PanelAssets, + type PanelModel, + type PanelRow, +} from "../../src/config/panelHtml"; + +const ASSETS: PanelAssets = { + cspSource: "vscode-webview://panel", + nonce: "nonce-abc123", + styleUri: "vscode-webview://panel/media/settings.css", + scriptUri: "", +}; + +const UI: Record = { + [PANEL_UI.title]: "mcpp settings", + [PANEL_UI.boundary]: "Only mcpp settings are changed here.", + [PANEL_UI.openMcpplsSettings]: "Open the C++ Modules settings", + [PANEL_UI.resourceLabel]: "Values shown for {0}", + [PANEL_UI.search]: "Search settings", + [PANEL_UI.onlyModified]: "Only modified", + [PANEL_UI.showAdvanced]: "Show advanced settings", + [PANEL_UI.target]: "Save settings to", + [PANEL_UI.targetUser]: "User settings", + [PANEL_UI.targetWorkspace]: "Workspace settings", + [PANEL_UI.targetWorkspaceUnavailable]: "No workspace folder is open", + [PANEL_UI.presets]: "Presets", + [PANEL_UI.modified]: "Changed from the default", + [PANEL_UI.deprecated]: "Deprecated", + [PANEL_UI.invalid]: "Invalid", + [PANEL_UI.appliesNextBuild]: "Takes effect on the next build", + [PANEL_UI.appliesNextClean]: "Takes effect on the next clean", + [PANEL_UI.appliesViewReload]: "Reload the window to see this", + [PANEL_UI.sourceDefault]: "Default", + [PANEL_UI.sourceUser]: "User", + [PANEL_UI.sourceWorkspace]: "Workspace", + [PANEL_UI.sourceWorkspaceFolder]: "Workspace folder", + [PANEL_UI.sourceInvalid]: "Invalid", + [PANEL_UI.reset]: "Reset", + [PANEL_UI.resetInvalid]: "Reset the invalid value to the default", + [PANEL_UI.openNative]: "Open in the Settings editor", + [PANEL_UI.arrayHint]: "One value per line", + [PANEL_UI.noMatches]: "No settings match the search", + [PANEL_UI.toggleSection]: "Toggle this section", +}; + +function row(overrides: Partial & { key: string }): PanelRow { + return { + title: overrides.key, + description: "A description.", + type: "boolean", + value: false, + scope: "resource", + applies: "immediate", + source: "default", + tier: "public", + since: "0.5.0", + ...overrides, + }; +} + +function page(rows: PanelRow[], extra: Partial = {}): string { + const model: PanelModel = { + sections: [{ id: "cache", title: "Cache", rows }], + presets: [{ id: "defaults", title: "Defaults", description: "Back to the shipped defaults." }], + ui: UI, + ...extra, + }; + return renderPanelHtml(model, ASSETS); +} + +/** The opening tag of the row element for `key`. */ +function rowTag(html: string, key: string): string { + const start = html.indexOf(`data-row="${key}"`); + assert.ok(start >= 0, `no row rendered for ${key}`); + const from = html.lastIndexOf("", start) + 1); +} + +test("the document carries the nonce in the CSP and on the script tag", () => { + const html = page([row({ key: "mcpp.path" })]); + assert.ok(html.includes("default-src 'none'")); + assert.ok(html.includes("style-src vscode-webview://panel;")); + assert.ok(html.includes("script-src 'nonce-nonce-abc123'")); + assert.ok(html.includes("img-src vscode-webview://panel\"")); + assert.ok(html.includes('', + description: "A bold description", + }), + ]); + assert.ok(!html.includes(" + + +`; +} + +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/views/cacheView.ts b/src/views/cacheView.ts index ca09559..aa050fa 100644 --- a/src/views/cacheView.ts +++ b/src/views/cacheView.ts @@ -25,11 +25,15 @@ import { formatBytes, projectGc } from "../util/format"; import { clampOutput } from "../util/text"; import { buildCacheTree, type CacheTreeInput } from "./models"; import { registerTreeView } from "./treeProvider"; +import { registerCachePanel, type CachePanelData } from "./cachePanel"; export const CACHE_VIEW_ID = "mcpp.cache"; -/** How long a cache query may take before we give up and say so. */ -const QUERY_TIMEOUT_MS = 60_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 { @@ -53,6 +57,15 @@ function workingDirectory(project: McppProjectDiscovery | undefined): string | u } /** Settings that shape the numbers the view shows. */ +/** 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"; +} + function viewSettings(): { topN: number; ageBoundaries: string[] } { return { topN: Math.max(1, read("mcpp.views.cache.topN")), @@ -90,7 +103,7 @@ async function run( const executable = deps.mcppExecutable(project); const cwd = workingDirectory(project); const result = options.quiet === true - ? await runProcess(executable, [...argv], cwd, { timeoutMs: options.timeoutMs }) + ? await runProcess(executable, [...argv], cwd, { timeoutMs: options.timeoutMs, maxBufferMiB: read("mcpp.runtime.maxOutputMiB") }) : await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, title: `mcpp ${argv[0]}` }, () => runProcess(executable, [...argv], cwd, { timeoutMs: options.timeoutMs }), @@ -179,6 +192,23 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV const view = registerTreeView(CACHE_VIEW_ID, () => buildCacheTree(treeInput())); context.subscriptions.push(view.disposable); + // 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); + status.command = CACHE_COMMANDS.showPanel; + 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 refresh = async (): Promise => { const project = deps.currentProject(); const settings = viewSettings(); @@ -191,7 +221,7 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV } const listed = await run(deps, project, ["cache", "list", "--format", "json"], { - timeoutMs: QUERY_TIMEOUT_MS, + timeoutMs: queryTimeoutMs(), quiet: true, }); if (listed.exitCode === 0) { @@ -209,7 +239,7 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV state.error = t("mcpp cache list failed (exit {0})", listed.exitCode); } - const dir = await run(deps, project, ["cache", "dir"], { timeoutMs: QUERY_TIMEOUT_MS, quiet: true }); + const dir = await run(deps, project, ["cache", "dir"], { timeoutMs: queryTimeoutMs(), quiet: true }); if (dir.exitCode === 0) { const parsed = parseCacheDir(dir.stdout); state.legacyPath = parsed.legacyPath; @@ -220,6 +250,7 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV } view.provider.refresh(); + updateStatus(); }; /** The figures a confirmation dialogue needs, without running anything new. */ @@ -270,10 +301,81 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV await refresh(); }); - register(CACHE_COMMANDS.showPanel, async () => { - if (!requireTrusted()) return; - await refresh(); - await preview(t("Cache statistics"), cacheSummaryText(treeInput())); + /** What the panel shows, from the same state the tree uses. */ + const panelData = async (): Promise => { + 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: state.legacyPath === undefined ? undefined : { bytes: state.legacyBytes ?? 0, path: state.legacyPath }, + }; + }; + + // `registerCachePanel` owns `mcpp.showCachePanel`; opening it refreshes first so + // the panel never shows a stale figure. + registerCachePanel(context, { + read: async () => { + if (deps.isTrusted()) { + await refresh(); + } + return 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 runPlan(project, planClean("cacheGc", { budgetGiB: 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); + }, }); register(CACHE_COMMANDS.showEntry, async (label) => { @@ -282,7 +384,7 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV return; } const project = deps.currentProject(); - const result = await run(deps, project, ["cache", "info", label], { timeoutMs: QUERY_TIMEOUT_MS, quiet: true }); + 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); }); @@ -395,12 +497,14 @@ export function registerCacheView(context: vscode.ExtensionContext, deps: CacheV } } view.provider.refresh(); + updateStatus(); - // Keep the tree in step with settings that change its shape. + // Keep the tree and the status item in step with the settings that shape them. context.subscriptions.push( vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("mcpp.views.cache") || event.affectsConfiguration("mcpp.cache")) { + if (event.affectsConfiguration("mcpp.views.cache") || event.affectsConfiguration("mcpp.cache") || event.affectsConfiguration("mcpp.ui.numberFormat")) { view.provider.refresh(); + updateStatus(); } }), ); diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 920a6aa..2156a28 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -369,18 +369,29 @@ test("tag release 工作流校验版本并发布 VSIX", () => { assert.match(workflow, /gh release upload.*--clobber/s); }); -test("PR CI 分离单元打包和 Extension Host E2E", () => { +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 run on Linux and macOS ARM64. + assert.match(workflow, /os: \[ubuntu-latest, macos-14\]/); + 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/); + 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@/); }); diff --git a/test/cli/search.test.ts b/test/cli/search.test.ts new file mode 100644 index 0000000..de62ca9 --- /dev/null +++ b/test/cli/search.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseSearchOutput, + searchArguments, + shouldSearch, +} from "../../src/cli/search"; + +/** + * A capture of `mcpp search z --all-versions` from mcpp 2026.9.30.2: single + * versions end the line with `(x.y.z)`, `--all-versions` lines list several + * comma-separated versions (with a trailing `...`), and xim-only entries carry + * no version at all. + */ +const REAL_CAPTURE = [ + " compat:gzip-hpp gzip-hpp — header-only gzip/deflate compression wrappers over zlib (0.1.0)", + " compat:libpng PNG reference library — portable PNG encode/decode (depends on zlib) (1.6.43)", + " compat:zlib A compression library (1.3.2)", + " freedesktop:wayland-protocols-unstable wayland-protocols unstable — the zwp_*/zxdg_* protocols still in flux (1.49.1, 1.49)", + " mcpplibs:aarch64-virt-rt Board support for QEMU's aarch64 virt machine (0.2.1, 0.2.0, 0.1.1, ...)", + " scode:zlib A Massively Spiffy Yet Delicately Unobtrusive Compression Library", + " xim:zlib A massively spiffy yet delicately unobtrusive compression library", +].join("\n"); + +test("parses a real capture newest-first and skips narration and unversioned lines", () => { + const versions = parseSearchOutput(REAL_CAPTURE); + assert.deepEqual( + versions.map((entry) => entry.version), + ["1.49.1", "1.49", "1.6.43", "1.3.2", "0.2.1", "0.2.0", "0.1.1", "0.1.0"], + ); + // `...` (the "more versions exist" marker) must not become a version. + assert.ok(versions.every((entry) => /^\d/.test(entry.version))); + assert.equal(versions.find((entry) => entry.version === "1.3.2")?.summary, "A compression library"); + assert.equal(versions.find((entry) => entry.version === "0.1.0")?.summary, "gzip-hpp — header-only gzip/deflate compression wrappers over zlib"); +}); + +test("accepts leading narration around the package list", () => { + const withNarration = [ + "Refreshing package index ...", + "", + REAL_CAPTURE, + "", + "note: run mcpp search --all-versions for the full list", + ].join("\n"); + assert.equal(parseSearchOutput(withNarration).length, parseSearchOutput(REAL_CAPTURE).length); +}); + +test("de-duplicates repeated versions across lines", () => { + const output = [ + " compat:zlib A compression library (1.3.2)", + " compat:zlib A compression library (1.3.2)", + " compat:zlib A compression library (1.2.9)", + ].join("\n"); + assert.deepEqual(parseSearchOutput(output).map((entry) => entry.version), ["1.3.2", "1.2.9"]); +}); + +test("orders versions semver-ish, not lexically", () => { + const output = [ + " a:one A library (1.9.0)", + " a:one A library (1.10.0)", + " a:one A library (1.9)", + ].join("\n"); + assert.deepEqual(parseSearchOutput(output).map((entry) => entry.version), ["1.10.0", "1.9.0", "1.9"]); +}); + +test("returns nothing for empty, whitespace-only and narration-only input", () => { + assert.deepEqual(parseSearchOutput(""), []); + assert.deepEqual(parseSearchOutput(" \n\n "), []); + const narration = [ + "Refreshing package index ...", + "note: 0 packages matched", + "warning: using the cached index", + " some:thing looks like a package but has no version", + ].join("\n"); + assert.deepEqual(parseSearchOutput(narration), []); + // 括号里不是版本号也不行。 + assert.deepEqual(parseSearchOutput(" a:b description (not a version)"), []); +}); + +test("tolerates carriage returns and terminal colours", () => { + const output = " compat:zlib A compression library (1.3.2)\r\n\u001b[32m compat:zlib A compression library (1.2.9)\u001b[0m"; + assert.deepEqual(parseSearchOutput(output).map((entry) => entry.version), ["1.3.2", "1.2.9"]); +}); + +test("searchArguments asks for every version", () => { + assert.deepEqual(searchArguments("zlib"), ["search", "zlib", "--all-versions"]); + assert.deepEqual(searchArguments("compat.zlib"), ["search", "compat.zlib", "--all-versions"]); +}); + +test("shouldSearch is enabled && trusted && !offline", () => { + for (const enabled of [true, false]) { + for (const trusted of [true, false]) { + for (const offline of [true, false]) { + assert.equal( + shouldSearch("zlib", { enabled, trusted, offline }), + enabled && trusted && !offline, + `enabled=${enabled} trusted=${trusted} offline=${offline}`, + ); + } + } + } + // 唯独"全开"才查询:这也是方案里默认关(enabled=false)的落点。 + assert.equal(shouldSearch("zlib", { enabled: true, trusted: true, offline: false }), true); + assert.equal(shouldSearch("zlib", { enabled: true, trusted: false, offline: false }), false); + assert.equal(shouldSearch("zlib", { enabled: true, trusted: true, offline: true }), false); + assert.equal(shouldSearch("zlib", { enabled: false, trusted: true, offline: false }), false); +}); diff --git a/test/config/wiring.test.ts b/test/config/wiring.test.ts new file mode 100644 index 0000000..23a1311 --- /dev/null +++ b/test/config/wiring.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { SETTINGS } from "../../src/config/registry"; + +/** + * "Declared but not read" is the failure this test exists for. + * + * A setting that nothing reads is worse than no setting: it is offered in the + * Settings UI, it is documented, and it does nothing. The audit in + * `.agents/docs/2026-10-02-implementation-plan.md` §8 found thirteen such gaps, + * so this gate makes the rule executable — a new registry entry that no module + * reads fails the build. + * + * The scan looks for the accessor calls this codebase actually uses: + * `read(...)`/`read(...)` from `src/config/access.ts`, and + * `get(...)`/`get(...)` on a `getConfiguration` result. A key named in either + * form counts as read. The exceptions below are the honest, shrinking list of + * settings still waiting for their feature; each must name why. + */ + +const EXCEPTIONS: Readonly> = { + "mcpp.task.buildArgs": "§8 G8 — extra argv for a task is not plumbed through `projectTaskPlan` yet", + "mcpp.task.runArgs": "§8 G8 — same", + "mcpp.task.testArgs": "§8 G8 — same", + "mcpp.task.cleanArgs": "§8 G8 — same", + "mcpp.task.revealTerminal": "§8 G8 — task presentation is fixed", + "mcpp.task.focusTerminal": "§8 G8 — task presentation is fixed", + "mcpp.task.clearTerminal": "§8 G8 — task presentation is fixed", + "mcpp.task.problemMatcher": "§8 G8 — no problem matcher is contributed yet", + "mcpp.task.editorTitleButtons": "§8 G8 — the editor/title `when` clause is fixed", + "mcpp.task.confirmClean": "§8 G8 — the clean command's own plan already confirms", + "mcpp.languageService.refreshAfterBuild": "§8 G8 — the candidate chain is not overridable yet", + "mcpp.languageService.menuItems": "§8 G8 — the quick menu always shows them", + "mcpp.languageService.notifyOnDegraded": "§8 G8 — the notice is unconditional", + "mcpp.languageService.readState": "§8 G8 — the state read is unconditional", + "mcpp.languageService.stateRefreshSeconds": "§8 G8 — there is no polling to configure", + "mcpp.languageService.confirmResetCache": "§8 G8 — the capability's own danger level confirms", + "mcpp.cache.warnAboveGiB": "§8 G8 — only the tree renders it, not the panel", + "mcpp.cache.autoRefreshSeconds": "§8 G8 — the view is refreshed on demand only", + "mcpp.cache.showLegacy": "§8 G6 — the legacy node is not populated yet", + "mcpp.cache.gc.confirmAboveGiB": "§8 G6 — the budget dialogue does not add a second confirmation", + "mcpp.views.project.show": "§8 G8 — the view's `when` clause is fixed", + "mcpp.views.cache.show": "§8 G8 — same", + "mcpp.views.languageServer.show": "§8 G8 — same", + "mcpp.ui.statusBar.show": "§8 G8 — the status item is always created", + "mcpp.ui.statusBar.showLanguageServer": "§8 G8 — the status text is mcpp-only", + "mcpp.ui.notifications.success": "§8 G8 — the success notice is fixed", + "mcpp.ui.notifications.dedupeMinutes": "§8 G8 — notices are not de-duplicated yet", + "mcpp.ui.confirmDestructiveOnly": "§8 G8 — confirmation comes from the cleanup plan", + "mcpp.project.discoveryBoundary": "§8 G8 — discovery always stops at the workspace folder", + "mcpp.runtime.timeoutSeconds": "§8 G8 — each call site passes its own timeout", + "mcpp.runtime.maxOutputMiB": "§8 G8 — the 16 MiB buffer is fixed", + "mcpp.runtime.concurrency": "§8 G8 — the registry is always per project", + "mcpp.log.level": "§8 G8 — the output channel is not levelled yet", + "mcpp.diagnostics.selfCheckOnStartup": "§8 G8 — the self-check is on demand only", + "mcpp.buildScript.intelligence": "§8 G8 — the providers are registered unconditionally", + "mcpp.buildScript.imports.knownModules": "§8 G8 — known-module recognition is unconditional", + "mcpp.buildScript.snippets": "§8 G8 — snippets are always offered", + "mcpp.toml.indexCompletion": "§8 G3 — the search adapter is not wired into completion yet", + "mcpp.toml.indexCompletionTimeoutSeconds": "§8 G3 — same", + "mcpp.ui.numberFormat": "§8 G13 — `formatBytes` is always binary", +}; + +function sourceFiles(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + sourceFiles(full, found); + } else if (entry.name.endsWith(".ts")) { + found.push(full); + } + } + return found; +} + +/** The keys a module actually asks for, in either accessor form. */ +function readKeys(): Set { + const keys = new Set(); + const accessor = /(?:read|get)(?:<[^>]*>)?\(\s*["']([^"']+)["']/g; + for (const file of sourceFiles(path.join(process.cwd(), "src"))) { + const text = readFileSync(file, "utf8"); + for (const match of text.matchAll(accessor)) { + keys.add(match[1]); + // Callers may pass the whole `mcpp.` key; the sub-key is what VS Code sees. + keys.add(match[1].replace(/^mcpp\./, "")); + } + } + return keys; +} + +test("every declared setting is either read by a module or listed with a reason", () => { + const keys = readKeys(); + const unwired: string[] = []; + for (const entry of SETTINGS) { + if (entry.deprecated === true) { + continue; // deprecated settings must NOT be read; see the test below + } + const sub = entry.key.replace(/^mcpp\./, ""); + if (keys.has(entry.key) || keys.has(sub)) { + continue; + } + if (entry.key in EXCEPTIONS) { + continue; + } + unwired.push(entry.key); + } + assert.deepEqual( + unwired, + [], + `these settings are declared but nothing reads them, and they carry no documented exception:\n ${unwired.join("\n ")}`, + ); +}); + +test("a documented exception names a reason and is still real", () => { + const byKey = new Map(SETTINGS.map((entry) => [entry.key, entry])); + for (const [key, reason] of Object.entries(EXCEPTIONS)) { + assert.ok(byKey.has(key), `${key} is not a registry setting; remove the stale exception`); + assert.match(reason, /§8 G\d+/, `${key} must point at the audit item that will remove it`); + } +}); + +test("deprecated settings are kept but read by nothing", () => { + const keys = readKeys(); + const deprecated = SETTINGS.filter((entry) => entry.deprecated === true); + assert.equal(deprecated.length, 4); + for (const entry of deprecated) { + const sub = entry.key.replace(/^mcpp\./, ""); + assert.ok(!keys.has(entry.key) && !keys.has(sub), `${entry.key} is deprecated but still read`); + } +}); + +test("the exception list only shrinks: it never grows beyond what §8 records", () => { + // 40 today, down from the 42 the audit listed once G1 was wired. Lowering this + // number is the point of the list; raising it means a new setting was added + // without wiring it. + assert.ok( + Object.keys(EXCEPTIONS).length <= 40, + `the exception list grew to ${Object.keys(EXCEPTIONS).length}; wire the setting instead`, + ); +}); + +test("the parameterised test uses real files, not an empty scan", () => { + assert.ok(statSync(path.join(process.cwd(), "src")).isDirectory()); + assert.ok(readKeys().size > 10, "the accessor scan found almost nothing, so it is broken"); +}); diff --git a/test/i18n/hardcoded.test.ts b/test/i18n/hardcoded.test.ts new file mode 100644 index 0000000..cc351bb --- /dev/null +++ b/test/i18n/hardcoded.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * A ceiling on untranslated user-visible strings. + * + * The English text is the key for every runtime string (`src/i18n/t.ts`), so a + * hard-coded sentence is invisible to `tools/l10n-check.mjs` **and** ignores + * `mcpp.ui.language`. Converting them all is mechanical work; letting new ones + * appear is not acceptable, so this test freezes the current number and fails if + * it grows. Lowering the number is the point. + */ + +/** + * Counted on 2026-10-02 for the 0.5.0 branch: **162** lines in `src/` still carry a + * Chinese literal, concentrated in `src/cli/controller.ts` and `src/extension.ts`. + * Every one of them is a gap recorded as §8 G11 in + * `.agents/docs/2026-10-02-implementation-plan.md`; the number only goes down. + */ +const CEILING = 162; + +const SCANNED_DIRECTORIES = ["src"]; + +function sourceFiles(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + sourceFiles(full, found); + } else if (entry.name.endsWith(".ts")) { + found.push(full); + } + } + return found; +} + +/** Lines carrying a CJK character outside a comment. */ +export function hardcodedChineseLines(root = process.cwd()): Array<{ file: string; line: number; text: string }> { + const hits: Array<{ file: string; line: number; text: string }> = []; + for (const dir of SCANNED_DIRECTORIES) { + for (const file of sourceFiles(path.join(root, dir))) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((text, index) => { + const trimmed = text.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return; + } + if (/[\u4e00-\u9fff]/.test(text)) { + hits.push({ file: path.relative(root, file), line: index + 1, text: trimmed }); + } + }); + } + } + return hits; +} + +test("the number of untranslated user-visible strings never grows", () => { + const hits = hardcodedChineseLines(); + assert.ok( + hits.length <= CEILING, + `hard-coded Chinese grew from ${CEILING} to ${hits.length} lines; route new strings through t(). ` + + `Worst offenders: ${hits.slice(0, 5).map((hit) => `${hit.file}:${hit.line}`).join(", ")}`, + ); +}); + +test("the scan actually finds the strings it is meant to watch", () => { + // A scan that silently stops matching would make the ceiling meaningless. + assert.ok(hardcodedChineseLines().length > 100, "the scan found almost nothing, so it is broken"); +}); diff --git a/test/toml/completion.test.ts b/test/toml/completion.test.ts index e7b75fe..9adbaf0 100644 --- a/test/toml/completion.test.ts +++ b/test/toml/completion.test.ts @@ -5,6 +5,7 @@ import { computeMcppTomlCompletions, type McppTomlSuggestion, } from "../../src/toml/completion"; +import { SCHEMA } from "../../src/toml/schema"; function labels(suggestions: McppTomlSuggestion[]): string[] { return suggestions.map((suggestion) => suggestion.label); @@ -27,10 +28,34 @@ test("suggests section headers on a partial bracket line", () => { assert.equal(targets?.insertSnippet, "[targets.${1:name}]"); }); -test("does not suggest the removed xlings.envs section", () => { - const suggestions = computeMcppTomlCompletions(["[xl"], 0, 3); - assert.ok(!labels(suggestions).includes("[xlings.envs]")); - assert.ok(labels(suggestions).includes("[xlings.workspace]")); +test("a section mcpp removed is never suggested, but an unmodelled one still is", () => { + // 快照描述 mcpp 真正校验的 schema,是段与键的主要来源;手写清单只用来补上 + // mcpp 接受、快照尚未建模的段(例如 [workspace.dependencies]), + // 所以「已移除」必须消失,「未建模」必须保留。 + const suggestions = labels(computeMcppTomlCompletions(["[xl"], 0, 3)); + assert.ok(!suggestions.includes("[xlings.envs]"), "a removed section came back"); + assert.ok(suggestions.includes("[xlings]")); + assert.ok(suggestions.includes("[xlings.workspace]"), "an unmodelled but accepted section disappeared"); +}); + +test("the header list is the snapshot with the curated labels and snippets", () => { + const suggestions = computeMcppTomlCompletions(["[hooks"], 0, 6); + // 快照的段都在(参数化段用清单里的写法),加上手写清单里快照没有的段。 + const names = labels(suggestions); + assert.ok(suggestions.length >= SCHEMA.sections.length); + assert.ok(names.includes("[workspace.dependencies]"), "an unmodelled but accepted section is missing"); + // 快照新增、手写清单没有的段也要出现。 + assert.ok(names.includes("[hooks]")); + assert.ok(names.includes("[modules]")); + assert.ok(names.includes("[c-abi]")); + // 参数化段保留手写 snippet。 + assert.equal( + suggestions.find((suggestion) => suggestion.label === "[targets.]")?.insertSnippet, + "[targets.${1:name}]", + ); + // 废弃段带一条 documentation 指向替代写法。 + const language = suggestions.find((suggestion) => suggestion.label === "[language]"); + assert.match(language?.documentation ?? "", /\[package\]\.standard/); }); test("offers nothing inside [[...]] array-table headers", () => { @@ -52,11 +77,52 @@ test("offers nothing in unknown sections", () => { assert.deepEqual(computeMcppTomlCompletions(["[mytool]", "key = "], 1, 6), []); }); -test("offers no static field keys (removed, waiting for upstream schema)", () => { - // 静态字段键/枚举刻意不做:等上游版本化 manifest schema。 - assert.deepEqual(computeMcppTomlCompletions(["[package]", ""], 1, 0), []); - assert.deepEqual(computeMcppTomlCompletions(["[package]", "standard = "], 1, 11), []); - assert.deepEqual(computeMcppTomlCompletions(["[targets.app]", "kind = "], 1, 7), []); +test("suggests schema keys inside a known section, skipping those already used", () => { + const suggestions = computeMcppTomlCompletions(["[package]", 'name = "x"', ""], 2, 0); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "template")); + const names = labels(suggestions); + assert.ok(!names.includes("name"), "an already-used key must not be offered again"); + assert.ok(names.includes("standard")); + assert.ok(names.includes("version")); + + const standard = suggestions.find((suggestion) => suggestion.label === "standard"); + assert.ok(standard); + assert.equal(standard.insertSnippet, 'standard = "c++20"'); + assert.match(standard.detail, /enum/); + assert.match(standard.detail, /c\+\+20/); + assert.match(standard.detail, /default/); + assert.deepEqual(standard.range, { startCharacter: 0, endCharacter: 0 }); + + // 已用键按段归属剔除:上一段的 name 不得影响 [build]。 + const build = computeMcppTomlCompletions(["[package]", 'name = "x"', "", "[build]", ""], 4, 0); + const buildNames = labels(build); + assert.ok(buildNames.includes("sources")); + assert.ok(!buildNames.includes("name")); +}); + +test("suggests enum values in the value position of a known enum key", () => { + const bare = computeMcppTomlCompletions(["[package]", "standard = "], 1, 11); + assert.deepEqual(labels(bare).slice(0, 3), ["c++20", "c++23", "c++26"]); + assert.equal(bare[0].insertSnippet, '"c++20"'); + for (const suggestion of bare) { + assert.deepEqual(suggestion.range, { startCharacter: 11, endCharacter: 11 }); + } + + // 光标已在字符串里:只替换内容,不再补引号。 + const inside = computeMcppTomlCompletions(["[package]", 'standard = "c++2'], 1, 17); + assert.equal(inside[0].insertSnippet, "c++20"); + assert.deepEqual(inside[0].range, { startCharacter: 12, endCharacter: 16 }); + + // [targets.] 行表继承基段的枚举键。 + const targets = computeMcppTomlCompletions(["[targets.app]", "kind = "], 1, 7); + assert.deepEqual(labels(targets), ["bin", "lib", "shared", "app"]); +}); + +test("offers no key suggestions in sections whose keys the user picks", () => { + // [toolchain] 的 openKeys: true:平台名由用户自选,给出固定词表就是错的。 + assert.deepEqual(computeMcppTomlCompletions(["[toolchain]", ""], 1, 0), []); + assert.deepEqual(computeMcppTomlCompletions(["[toolchain]", "gcc = "], 1, 6), []); }); test("suggests dependency writing templates", () => { diff --git a/test/toml/hover.test.ts b/test/toml/hover.test.ts new file mode 100644 index 0000000..14e62c8 --- /dev/null +++ b/test/toml/hover.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { hoverAt } from "../../src/toml/hover"; + +test("hovers a section header with its plane, key count and doc link", () => { + const info = hoverAt(["[package]", 'name = "x"'], 0, 3); + assert.ok(info); + assert.equal(info.title, "[package]"); + assert.match(info.body, /plane: `identity`/); + assert.match(info.body, /keys: 20/); + assert.match(info.documentation ?? "", /04-mcpp-toml\.md#21-package--package-metadata$/); +}); + +test("hovers a legacy section with what replaces it", () => { + const info = hoverAt(["[language]"], 0, 2); + assert.ok(info); + assert.equal(info.title, "[language]"); + assert.match(info.body, /legacy: replaced by `\[package\]\.standard`/); +}); + +test("hovers a parameterized row header on its base section", () => { + const info = hoverAt(["[targets.app]", 'kind = "lib"'], 0, 4); + assert.ok(info); + assert.equal(info.title, "[targets]"); + assert.match(info.body, /plane: `artifact`/); + assert.match(info.documentation ?? "", /#22-targetsname--build-targets$/); +}); + +test("hovers a key with type, enum values, default and since", () => { + const standard = hoverAt(["[package]", "standard = c++23"], 1, 3); + assert.ok(standard); + assert.equal(standard.title, "standard"); + assert.match(standard.body, /type: `enum`/); + assert.match(standard.body, /c\+\+23/); + assert.match(standard.body, /default: `"c\+\+23"`/); + + const mcpp = hoverAt(["[package]", 'mcpp = ">=2026.9.28.3"'], 1, 1); + assert.ok(mcpp); + assert.match(mcpp.body, /since: 2026\.9\.28\.3/); + assert.match(mcpp.body, /release floor/); +}); + +test("hovers an unmodelled key with the inference caveat", () => { + const info = hoverAt(["[build]", "flags = []"], 1, 2); + assert.ok(info); + assert.equal(info.title, "flags"); + assert.match(info.body, /unmodelled: the type is inferred/); +}); + +test("hovers an enum value with the value and its key's documentation", () => { + const info = hoverAt(["[package]", "standard = c++23"], 1, 12); + assert.ok(info); + assert.equal(info.title, "c++23"); + assert.match(info.body, /enum value of `standard`/); + assert.match(info.documentation ?? "", /04-mcpp-toml\.md#21/); +}); + +test("offers nothing outside a section, a known key or an enum value", () => { + // 段在快照里没有键表:依赖名不是 schema 键。 + assert.equal(hoverAt(["[dependencies]", 'zlib = "1.0"'], 1, 10), undefined); + // 未知段:不猜。 + assert.equal(hoverAt(["[mytool]", "x = 1"], 1, 1), undefined); + // 非枚举键的值。 + assert.equal(hoverAt(["[package]", 'name = "x"'], 1, 10), undefined); + // 空文档与越界光标。 + assert.equal(hoverAt([], 5, 5), undefined); +}); + +test("never throws on malformed input", () => { + const inputs: string[][] = [ + [""], + ["["], + ["[", "]]", "= = ="], + ["[package", "name"], + ["[[x]]", "{"], + ["[package]", 'name = "unterminated'], + ]; + for (const lines of inputs) { + assert.doesNotThrow(() => hoverAt(lines, 0, 99)); + assert.doesNotThrow(() => hoverAt(lines, 99, 99)); + } +}); diff --git a/test/toml/navigation.test.ts b/test/toml/navigation.test.ts new file mode 100644 index 0000000..e328ca3 --- /dev/null +++ b/test/toml/navigation.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { definitionAt } from "../../src/toml/navigation"; + +test("workspace = true jumps to the same key in [workspace.dependencies]", () => { + const lines = [ + "[dependencies]", + "compat.zlib = { workspace = true }", + "", + "[workspace.dependencies]", + 'compat.zlib = "1.3.2"', + ]; + // 光标在值 true 上,也在键 workspace 上:都应落到同名依赖键。 + assert.deepEqual(definitionAt(lines, 1, 30), { + line: 4, + startCharacter: 0, + endCharacter: "compat.zlib".length, + }); + assert.deepEqual(definitionAt(lines, 1, 20), { + line: 4, + startCharacter: 0, + endCharacter: "compat.zlib".length, + }); +}); + +test("workspace = true also works in a table-per-dependency section", () => { + const lines = ["[dependencies.foo]", "workspace = true", "", "[workspace.dependencies]", 'foo = "1"']; + assert.deepEqual(definitionAt(lines, 1, 3), { line: 4, startCharacter: 0, endCharacter: 3 }); + + const inline = ["[dependencies]", "foo = { workspace = true }", "", "[workspace.dependencies]", 'foo = "1"']; + assert.deepEqual(definitionAt(inline, 1, 20), { line: 4, startCharacter: 0, endCharacter: 3 }); +}); + +test("workspace = true without a matching workspace dependency jumps nowhere", () => { + assert.equal( + definitionAt(["[dependencies]", "foo = { workspace = true }"], 1, 20), + undefined, + ); +}); + +test("a relative path jumps to the resolved manifest's [package] header", () => { + const lines = ["[dependencies]", 'mylib = { path = "../mylib" }']; + const seen: string[] = []; + const found = definitionAt(lines, 1, 20, (relative) => { + seen.push(relative); + return 7; + }); + assert.deepEqual(seen, ["../mylib"]); + assert.deepEqual(found, { line: 7, startCharacter: 0, endCharacter: "[package]".length }); +}); + +test("a path that the callback cannot resolve jumps nowhere", () => { + const lines = ["[dependencies]", 'mylib = { path = "../mylib" }']; + assert.equal(definitionAt(lines, 1, 20, () => undefined), undefined); + // 回调可省略:纯函数在主 manifest 内没有任何目标可指。 + assert.equal(definitionAt(lines, 1, 20), undefined); +}); + +test("absolute paths and non-dependency path keys are left alone", () => { + const absolute = ["[dependencies]", 'mylib = { path = "/opt/mylib" }']; + assert.equal(definitionAt(absolute, 1, 20, () => 3), undefined); + + const lib = ["[lib]", 'path = "src/x.cppm"']; + assert.equal(definitionAt(lib, 1, 3, () => 3), undefined); +}); + +test("features = [...] jumps to the header of the element under the cursor", () => { + const lines = [ + "[dependencies]", + 'fmt = { features = ["simd", "gpu"] }', + "", + "[features.simd]", + "defines = []", + ]; + assert.deepEqual(definitionAt(lines, 1, 21), { + line: 3, + startCharacter: 0, + endCharacter: "[features.simd]".length, + }); + // 光标在 "gpu" 上,但没有 [features.gpu]。 + assert.equal(definitionAt(lines, 1, 31), undefined); +}); + +test("ordinary positions have no definition", () => { + assert.equal(definitionAt(["[package]", 'name = "x"'], 1, 10), undefined); + assert.equal(definitionAt(["[dependencies]", 'zlib = "1.0"'], 1, 10), undefined); + assert.equal(definitionAt([], 3, 3), undefined); +}); + +test("never throws on malformed input", () => { + const inputs: string[][] = [ + [""], + ["["], + ["[", "]]", "= = ="], + ["[dependencies]", 'foo = { path = "../'], + ["[[x]]", "{"], + ]; + for (const lines of inputs) { + assert.doesNotThrow(() => definitionAt(lines, 0, 99, () => 0)); + assert.doesNotThrow(() => definitionAt(lines, 99, 99)); + } +}); diff --git a/test/views/cachePanelHtml.test.ts b/test/views/cachePanelHtml.test.ts new file mode 100644 index 0000000..c19d55a --- /dev/null +++ b/test/views/cachePanelHtml.test.ts @@ -0,0 +1,290 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CACHE_PANEL_UI, + decodeCachePanelMessage, + renderCachePanelHtml, + type CachePanelAssets, + type CachePanelModel, +} from "../../src/views/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 artifacts", + [CACHE_PANEL_UI.projectFiles]: "{0} file(s) · {1} group(s)", + [CACHE_PANEL_UI.sharedTitle]: "Global build cache", + [CACHE_PANEL_UI.sharedEntries]: "{0} entries", + [CACHE_PANEL_UI.sharedRoot]: "Root: {0}", + [CACHE_PANEL_UI.sharedOldest]: "Oldest use: {0}", + [CACHE_PANEL_UI.sharedNewest]: "Newest use: {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.refresh]: "Refresh", + [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.details]: "Details", + [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.compositionHint]: "Share of the total cache size, by entry kind.", + [CACHE_PANEL_UI.compositionEmpty]: "No cache entries were found.", + [CACHE_PANEL_UI.age]: "Age distribution", + [CACHE_PANEL_UI.ageHint]: "Bytes and entries by how long ago they were last used.", + [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.topHint]: "The {0} largest cache labels, by bytes.", + [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.colActions]: "Actions", + [CACHE_PANEL_UI.budget]: "Budget simulator", + [CACHE_PANEL_UI.budgetHint]: "Simulates mcpp cache gc --max-size.", + [CACHE_PANEL_UI.budgetLabel]: "Keep the shared build cache under", + [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.", + [CACHE_PANEL_UI.barsHint]: "The same figures are listed as text next to each bar.", +}; + +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. + */ +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); +} + +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(" + + +`; +} + +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 "ready": + return { type: "ready" }; + 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; + default: + return undefined; + } +} diff --git a/src/library/detailPanel.ts b/src/library/detailPanel.ts new file mode 100644 index 0000000..294f1c2 --- /dev/null +++ b/src/library/detailPanel.ts @@ -0,0 +1,379 @@ +/** + * 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/views/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 { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +import { runProcess } from "../cli/process"; +import { read } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { addDependency } from "./addDependency"; +import { + badgesOf, + codeSnippets, + descriptorDependencies, + parseXpkgJson, + platformKey, + surfaceLabel, + type LibraryEntry, + type Surface, +} from "./indexModel"; +import { cachedDescriptorText, loadSnapshot, readDescriptorText, readExampleFiles } from "./indexLocator"; +import { + DETAIL_UI, + decodeDetailMessage, + renderDetailHtml, + 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; + +/** The index's public site; only the `mcpplibs` registry publishes one. */ +const INDEX_SITE = "https://mcpplibs.github.io/mcpp-index/packages"; + +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 }; + 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 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; + }); + 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 "ready": + return; + case "openUrl": + // `decodeDetailMessage` already restricted this to https. + void vscode.env.openExternal(vscode.Uri.parse(message.url)); + 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; + } + void session.panel.webview.postMessage({ type: "result", result }); + return; + } + default: + return; + } +} + +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; + } + panel.webview.html = renderDetailHtml(model, { + cspSource: panel.webview.cspSource, + nonce: randomBytes(16).toString("base64"), + styleUri: panel.webview + .asWebviewUri(vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY, STYLESHEET)) + .toString(), + }); +} + +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); + 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 snippets = + entry.example === undefined + ? [] + : codeSnippets(await readExampleFiles(snapshot.roots, entry.example), { context: 2, maxSnippets: 3, maxLines: 20 }); + + const dependencies = descriptorDependencies(text).map((dependency) => ({ ...dependency })); + 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 }), + ...(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 }), + ...(entry.registry === "mcpplibs" ? { indexUrl: `${INDEX_SITE}/${entry.id}/` } : {}), + commandTemplate: t("mcpp add {0}@{1}"), + commandDevTemplate: t("mcpp add {0}@{1} --dev"), + ...(info === undefined + ? { + parseNotice: t( + "mcpp xpkg parse could not read this descriptor; the versions below come from its text, which is less authoritative.", + ), + } + : {}), + 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 { + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Reading {0}…", entry.id) }, + () => + runProcess(session.deps.mcppExecutable(), ["xpkg", "parse", entry.file, "--json"], session.deps.projectRoot(), { + timeoutMs: PARSE_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + 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: [], + commandTemplate: t("mcpp add {0}@{1}"), + commandDevTemplate: t("mcpp add {0}@{1} --dev"), + parseNotice: notice, + dataSource: t("No descriptor was read."), + }; +} + +/** 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.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.dev]: t("dev"), + [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.addNoVersion]: t("This index publishes no version for this platform, so there is nothing to add."), + [DETAIL_UI.command]: t("Command"), + [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..9726509 --- /dev/null +++ b/src/library/indexLocator.ts @@ -0,0 +1,436 @@ +/** + * 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 `/.mcpp/registry/data` gives the + * same answer without parsing anything. + * + * 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 { read } from "../config/access"; +import { + descriptorEntry, + descriptorId, + declaredDependencies, + exampleCatalog, + openkalFacetFor, + parseDescriptorLua, + parseOpenkalJson, + 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; + +/** 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; +} + +/** 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; + } + return { + registry: path.basename(directory), + path: directory, + pkgs, + hasExamples: await isDirectory(path.join(directory, "tests", "examples")), + hasOpenkal: await isFile(path.join(directory, ".xpkgindex", "openkal-compat.json")), + }; +} + +/** + * 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 `/.mcpp/registry/data`. + * + * `[]` 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 { + const data = path.join(os.homedir(), ".mcpp", "registry", "data"); + 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)); +} + +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..9bdbd7f --- /dev/null +++ b/src/library/indexModel.ts @@ -0,0 +1,1277 @@ +/** + * 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; +} + +/** 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; +} + +/** 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; +} + +// ─────────────────────────────────────────────────────────────── filters ── + +export type FilterKind = "all" | "namespace" | "added" | "surface"; + +export interface LibraryFilter { + kind: FilterKind; + value?: string; +} + +export const ALL_FILTER: LibraryFilter = { kind: "all" }; +export const ADDED_FILTER: LibraryFilter = { kind: "added" }; + +/** 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)); +} + +export function matchesFilter(entry: LibraryEntry, filter: LibraryFilter): boolean { + switch (filter.kind) { + case "namespace": + return filter.value === entry.namespace; + case "added": + return entry.added; + case "surface": + return filter.value === entry.surface || entry.surfaces.includes(filter.value as Surface); + default: + return true; + } +} + +/** The rows the sidebar shows, after the chip and the query. */ +export function visibleEntries( + entries: readonly LibraryEntry[], + filter: LibraryFilter, + query: string, +): LibraryEntry[] { + return entries.filter((entry) => matchesFilter(entry, filter) && matchesQuery(entry, query)); +} + +/** Namespaces that actually occur, busiest first — the chips are data, not a list. */ +export function namespaceCounts(entries: readonly LibraryEntry[]): Array<{ value: string; count: number }> { + const counts = new Map(); + for (const entry of entries) { + if (entry.namespace === undefined) { + continue; + } + counts.set(entry.namespace, (counts.get(entry.namespace) ?? 0) + 1); + } + return [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => (b.count === a.count ? (a.value < b.value ? -1 : 1) : b.count - a.count)); +} + +/** Surface chips, in `SURFACES` order; a surface nobody has earns no chip. */ +export function surfaceCounts(entries: readonly LibraryEntry[]): Array<{ value: Surface; count: number }> { + return SURFACES.map((surface) => ({ + value: surface, + count: entries.filter((entry) => entry.surfaces.includes(surface)).length, + })).filter((chip) => chip.count > 0); +} + +/** How many rows are declared in the workspace's `mcpp.toml`. */ +export function addedCount(entries: readonly LibraryEntry[]): number { + return entries.filter((entry) => entry.added).length; +} + +/** 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..61c373c --- /dev/null +++ b/src/library/libraryHtml.ts @@ -0,0 +1,514 @@ +/** + * 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 and the filter chips + * 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 FilterKind, 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; +} + +/** One filter chip. Labels and counts are resolved by the caller. */ +export interface LibraryChip { + id: string; + kind: FilterKind; + value?: string; + label: string; + count: number; +} + +export interface LibraryModel { + /** Everything is already localized by the caller. */ + ui: Record; + rows: LibraryRow[]; + chips: LibraryChip[]; + /** The chip id that starts selected. */ + activeChip: string; + query: string; + networkSearch: boolean; + /** + * The setting the toggle writes. Carried in the model so the client and the + * host cannot disagree about which key the control drives. + */ + networkSearchSetting: string; + /** The data source, in plain words, for the footer. */ + dataSource: string; + /** `{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", + filters: "library.filters", + chipAll: "library.chip.all", + chipAdded: "library.chip.added", + 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, +}; + +export type LibraryMessage = + | { type: "ready" } + | { type: "refresh" } + | { type: "filter"; chip: string } + | { 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"); +} + +function renderChips(model: LibraryModel, label: UiLabel): string { + const chips = model.chips + .map((chip) => { + const active = chip.id === model.activeChip; + const data = chip.value === undefined ? "" : ` data-chip-value="${escapeHtml(chip.value)}"`; + return ( + `` + ); + }) + .join("\n "); + return [ + `
    `, + ` `, + ` `, + `
    `, + ` ${chips}`, + `
    `, + ` `, + `
    `, + ].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`. + * + * Filtering is local: a row carries its namespace, its added state and its + * surfaces as `data-*` attributes, and the chip plus the query decide its + * `hidden` flag. The host is told which filter and query are active, 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 chipElements = document.querySelectorAll("[data-chip]"); + var countElement = document.getElementById("library-count"); + var activeChip = state && state.activeChip ? state.activeChip : "all"; + var activeValue = ""; + + function post(message) { + if (api) { api.postMessage(message); } + } + + function chipValue(id) { + for (var index = 0; index < chipElements.length; index += 1) { + if (chipElements[index].getAttribute("data-chip") === id) { + return { kind: chipElements[index].getAttribute("data-chip-kind"), value: chipElements[index].getAttribute("data-chip-value") || "" }; + } + } + return { kind: "all", value: "" }; + } + + // The canonical semantics are indexModel.visibleEntries; this mirrors them on + // the data-* attributes and on the host-built data-haystack. + function rowMatches(row, kind, value, query) { + if (kind === "namespace" && row.getAttribute("data-namespace") !== value) { return false; } + if (kind === "added" && row.getAttribute("data-added") !== "true") { return false; } + if (kind === "surface") { + var surfaces = (row.getAttribute("data-surfaces") || "").split(" "); + if (surfaces.indexOf(value) < 0) { return false; } + } + 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 chip = chipValue(activeChip); + activeValue = chip.value || ""; + 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], chip.kind, activeValue, 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)); + } + for (var index2 = 0; index2 < chipElements.length; index2 += 1) { + var active = chipElements[index2].getAttribute("data-chip") === activeChip; + chipElements[index2].setAttribute("aria-pressed", active ? "true" : "false"); + if (active) { chipElements[index2].setAttribute("data-active", ""); } + else { chipElements[index2].removeAttribute("data-active"); } + } + } + + 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 chip = target.closest("[data-chip]"); + if (chip) { + activeChip = chip.getAttribute("data-chip"); + apply(); + post({ type: "filter", chip: activeChip }); + 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") }); } + }); + window.addEventListener("message", function (event) { + var data = event.data; + if (data && data.type === "filter" && typeof data.chip === "string") { + activeChip = data.chip; + apply(); + } + }); + + apply(); + post({ type: "ready" }); +})();`; +} + +/** + * 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 { + activeChip: model.activeChip, + 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)}

    +
    +${renderChips(model, label)} +
    +${renderList(model, label)} +
    +
    + ${escapeHtml(model.dataSource)} + +
    + + + +`; +} + +/** + * The rows the initial query keeps. The live filtering 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 chip = model.chips.find((candidate) => candidate.id === model.activeChip); + if (chip !== undefined) { + if (chip.kind === "namespace" && row.namespace !== chip.value) { + return false; + } + if (chip.kind === "added" && !row.added) { + return false; + } + if (chip.kind === "surface" && !row.surfaces.includes(chip.value as Surface)) { + return false; + } + } + 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 six 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 "ready": + return { type: "ready" }; + case "refresh": + return { type: "refresh" }; + case "filter": + return nonEmptyString(raw.chip) ? { type: "filter", chip: raw.chip } : undefined; + 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..554c762 --- /dev/null +++ b/src/library/libraryView.ts @@ -0,0 +1,505 @@ +/** + * 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 { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +import { runProcess } from "../cli/process"; +import { read, write } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { + addedCount, + badgesOf, + namespaceCounts, + searchText, + parseSearchOutput, + surfaceCounts, + surfaceLabel, + type LibraryEntry, + type Surface, +} from "./indexModel"; +import { loadSnapshot, type IndexRoot, type LibrarySnapshot } from "./indexLocator"; +import { + LIBRARY_UI, + decodeLibraryMessage, + renderLibraryHtml, + type LibraryChip, + 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; +} + +/** + * Register the view's subscriptions and hand back its provider. + * + * The provider is **not** registered here, so the caller owns that decision and + * cannot end up registering the same view id twice. `extension.ts` needs exactly + * this call: + * + * ```ts + * const library = registerLibraryView(extensionContext, { … }); + * extensionContext.subscriptions.push( + * vscode.window.registerWebviewViewProvider(LIBRARY_VIEW_ID, library.provider, { + * webviewOptions: { retainContextWhenHidden: false }, + * }), + * ); + * ``` + * + * `retainContextWhenHidden` is not supported by a webview view (and is refused + * with an error if it is set), 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( + 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 { + provider, + 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 — reaches the list, because no document is saved in + * that path and therefore no save event fires. + */ +export interface LibraryViewHandle { + /** Pass to `vscode.window.registerWebviewViewProvider(LIBRARY_VIEW_ID, …)`. */ + provider: vscode.WebviewViewProvider; + refresh(): Promise; + dispose(): void; +} + +class LibraryViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + private view: vscode.WebviewView | undefined; + private snapshot: LibrarySnapshot | undefined; + private activeChip = "all"; + 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; + + 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; + 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; + } + }); + // 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 "ready": + this.paint(); + return; + case "refresh": + await this.refresh(); + return; + case "filter": + // Recorded, not re-rendered: the client has already applied the chip to + // the document, and reassigning `webview.html` would lose the scroll + // position and the caret for no new data. The state is what the next + // data-driven render starts from. + this.activeChip = message.chip; + this.extra = []; + this.searchNote = undefined; + 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; + } + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Searching all registries…") }, + () => + runProcess(this.deps.mcppExecutable(), ["search", query], this.deps.projectRoot(), { + timeoutMs: SEARCH_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + 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.filters]: t("Filters"), + [LIBRARY_UI.chipAll]: t("All"), + [LIBRARY_UI.chipAdded]: t("Added"), + [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 once so it installs an index, or set mcpp.library.indexPath.", + ), + [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"), + }; + } + + private paint(): void { + const view = this.view; + if (view === undefined) { + return; + } + view.webview.html = renderLibraryHtml(this.model(), { + cspSource: view.webview.cspSource, + nonce: randomNonce(), + styleUri: view.webview + .asWebviewUri(vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY, STYLESHEET)) + .toString(), + }); + } + + 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 chips = chipsOf(entries, ui); + 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, + chips, + activeChip: this.activeChip, + 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: [], + chips: [{ id: "all", kind: "all", label: ui[LIBRARY_UI.chipAll], count: 0 }], + activeChip: "all", + 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, + }; +} + +/** + * The chips: all, one per namespace that actually occurs, "added", and one per + * surface that actually occurs. Counting here means the numbers on the chips and + * the rows beneath them come from the same list. + */ +function chipsOf(entries: readonly LibraryEntry[], ui: Record): LibraryChip[] { + const chips: LibraryChip[] = [ + { id: "all", kind: "all", label: ui[LIBRARY_UI.chipAll], count: entries.length }, + ]; + for (const namespace of namespaceCounts(entries)) { + chips.push({ + id: `ns:${namespace.value}`, + kind: "namespace", + value: namespace.value, + label: namespace.value, + count: namespace.count, + }); + } + const added = addedCount(entries); + if (added > 0) { + chips.push({ id: "added", kind: "added", label: ui[LIBRARY_UI.chipAdded], count: added }); + } + for (const surface of surfaceCounts(entries)) { + chips.push({ + id: `surface:${surface.value}`, + kind: "surface", + value: surface.value, + label: surfaceLabel(surface.value as Surface, (key) => ui[key] ?? key), + count: surface.count, + }); + } + return chips; +} + +/** `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"; +} + +/** The webview nonce, the same way the other webviews mint theirs. */ +function randomNonce(): string { + return randomBytes(16).toString("base64"); +} 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/views/cachePanel.ts b/src/views/cachePanel.ts index 044b510..b280f9c 100644 --- a/src/views/cachePanel.ts +++ b/src/views/cachePanel.ts @@ -1,5 +1,6 @@ /** - * The VS Code half of the cache panel: `mcpp: Cache statistics` (plan §3.4.3). + * The VS Code half of the cache view: the sidebar **WebviewView** (`mcpp.cache`, + * plan §8.1). * * The panel is a **reading aid** for what `mcpp cache list`, `mcpp cache dir` * and a bounded walk of `target/` already report: composition, age, the largest @@ -9,21 +10,30 @@ * * Deliberate boundaries: * - * - **Nothing runs on its own.** Every read comes from the injected `read()`; - * every write goes through the injected `run()`, which is the same routine the - * cache view uses, so a confirmation cannot be bypassed by opening a panel. - * - **One panel per window.** Reopening reveals and re-renders the existing one. - * - **A failure still renders.** When `read()` rejects, the panel draws an - * unavailable state with the reason instead of leaving the webview blank. + * - **Nothing runs on its own.** Every figure comes from the injected `refresh` + * + `read` pair; every write goes through the injected `run()`, which is the + * same routine the cache commands use, so a confirmation cannot be bypassed by + * opening the view. * - **The document is re-rendered after every action**, so there is exactly one * renderer (the pure one) and no client-side model application to keep in - * sync. + * sync. `renderCachePanelHtml` is idempotent, which is what a `WebviewView` + * needs: it has no `retainContextWhenHidden`, so hiding the sidebar destroys + * the document and showing it renders a new one. + * - **A failure still renders.** When the refresh/read pair rejects, the view + * draws an unavailable state with the reason instead of going blank. A global + * block that is unavailable is rendered *open*, because a reason hidden behind + * a collapsed disclosure is worse than no disclosure at all. + * - **One webview per window.** The provider is registered once; VS Code hands + * it the view whenever the sidebar shows it. + * + * The confirmation logic does not live here: `run()` is the caller's, and every + * destructive path still goes through `src/cli/clean.ts`'s plans and the graded + * modals in `src/views/cacheView.ts`. */ import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; -import { CACHE_COMMANDS } from "../commands/ids"; import { read } from "../config/access"; import { languagePreference, t } from "../i18n/t"; import { localeFromEditorLanguage } from "../i18n/translate"; @@ -37,7 +47,15 @@ import { type CachePanelModel, } from "./cachePanelHtml"; -const PANEL_VIEW_TYPE = "mcpp.cachePanel"; +/** The sidebar view this module provides; `package.json` spells the same id. */ +export const CACHE_VIEW_ID = "mcpp.cache"; + +/** + * VS Code registers `.focus` for every contributed view, so bringing the + * cache view on screen needs no command of our own. Used by the status item. + */ +export const CACHE_VIEW_FOCUS_COMMAND = `${CACHE_VIEW_ID}.focus`; + const MEDIA_DIRECTORY = "media"; const STYLESHEET = "cache.css"; @@ -60,113 +78,156 @@ export type CachePanelAction = Extract< >; export interface CachePanelDeps { - /** Recomputes what the panel shows; the same routine the tree uses. */ - read: () => Promise; + /** Brings the caller's figures up to date; may run mcpp. */ + refresh: () => Promise; + /** The figures as they stand, without running anything. */ + read: () => CachePanelData; /** Runs one of the cleanup commands; the caller owns confirmation. */ run: (message: CachePanelAction) => Promise; showEntry: (label: string) => Promise; + /** The webview view became visible or hidden; the caller owns the refresh timer. */ + onVisibilityChanged?: (visible: boolean) => void; } -interface CachePanelSession { - panel: vscode.WebviewPanel; - context: vscode.ExtensionContext; - /** Set by `onDidDispose`, so an in-flight `read()` cannot touch a dead webview. */ - disposed: boolean; +/** What `cacheView.ts` needs back from the registration. */ +export interface CachePanelProvider extends vscode.WebviewViewProvider { + /** True while the sidebar view is on screen. The caller's timer checks it. */ + readonly visible: boolean; + /** Redraws from the caller's current figures. A no-op while the view is not resolved. */ + refresh: () => void; } -/** One panel per window: reopening reveals and refreshes the existing one. */ -let session: CachePanelSession | undefined; - -export function registerCachePanel(context: vscode.ExtensionContext, deps: CachePanelDeps): void { +/** + * Register the provider for the `mcpp.cache` sidebar view. + * + * The provider is returned so the caller can ask "is the view on screen?" (the + * auto-refresh timer) and "redraw" (after a settings change — the caller already + * listens for those, and keeps the status item and the timer in step in the same + * handler). It is registered with `retainContextWhenHidden: false`, the only + * value a `WebviewView` supports, which is why the document is re-rendered on + * every resolve. + */ +export function registerCachePanel(context: vscode.ExtensionContext, deps: CachePanelDeps): CachePanelProvider { + const provider = new CacheWebviewViewProvider(context, deps); context.subscriptions.push( - vscode.commands.registerCommand(CACHE_COMMANDS.showPanel, () => open(context, deps)), - vscode.workspace.onDidChangeConfiguration((event) => { - if (session === undefined) { - return; - } - if ( - event.affectsConfiguration("mcpp.cache") || - event.affectsConfiguration("mcpp.views.cache") || - event.affectsConfiguration("mcpp.ui.numberFormat") - ) { - void render(session, deps); - } + vscode.window.registerWebviewViewProvider(CACHE_VIEW_ID, provider, { + webviewOptions: { retainContextWhenHidden: false }, }), - { - dispose: () => { - session?.panel.dispose(); - session = undefined; - }, - }, ); + return provider; } -async function open(context: vscode.ExtensionContext, deps: CachePanelDeps): Promise { - if (session !== undefined) { - session.panel.reveal(); - await render(session, deps); - return; +class CacheWebviewViewProvider implements CachePanelProvider { + private view: vscode.WebviewView | undefined; + private busy = false; + /** A redraw was asked for while one was running: do exactly one more pass. */ + private again = false; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly deps: CachePanelDeps, + ) {} + + get visible(): boolean { + return this.view?.visible === true; } - const panel = vscode.window.createWebviewPanel( - PANEL_VIEW_TYPE, - t("Cache statistics"), - vscode.ViewColumn.Active, - { + + /** + * The `WebviewViewProvider` contract: + * `resolveWebviewView(view: WebviewView, context: WebviewViewResolveContext, token: CancellationToken)`. + * The two trailing arguments are unused on purpose: the document is a pure + * function of the caller's figures, so a resolve needs neither the previous + * state nor a cancellation token, and the render it starts checks `this.view` + * before it touches the DOM. + * + * Called by VS Code every time the sidebar shows the view — and because a + * `WebviewView` cannot retain its context while hidden, that is also the point + * at which the document is drawn from scratch. + */ + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + view.webview.options = { enableScripts: true, - localResourceRoots: [mediaRoot(context)], - retainContextWhenHidden: false, - }, - ); - const active: CachePanelSession = { panel, context, disposed: false }; - session = active; - panel.webview.onDidReceiveMessage((raw: unknown) => { - void handle(active, deps, raw); - }); - panel.onDidDispose(() => { - active.disposed = true; - if (session === active) { - session = undefined; - } - }); - await render(active, deps); -} + localResourceRoots: [mediaRoot(this.context)], + }; + view.onDidDispose(() => { + if (this.view === view) { + this.view = undefined; + } + }); + view.onDidChangeVisibility(() => { + this.deps.onVisibilityChanged?.(view.visible); + }); + view.webview.onDidReceiveMessage((raw: unknown) => { + void this.handle(raw); + }); + this.deps.onVisibilityChanged?.(view.visible); + this.refresh(); + } -async function handle(active: CachePanelSession, deps: CachePanelDeps, raw: unknown): Promise { - const message = decodeCachePanelMessage(raw); - if (message === undefined) { - return; + refresh(): void { + if (this.busy) { + this.again = true; + return; + } + void this.run(); } - try { - if (message.type === "refresh") { - // Nothing to run: the render below re-reads everything. - } else if (message.type === "showEntry") { - await deps.showEntry(message.label); - } else { - await deps.run(message); + + /** One pass at a time; a request arriving mid-pass earns exactly one more. */ + private async run(): Promise { + this.busy = true; + try { + do { + this.again = false; + await this.render(); + } while (this.again && this.view !== undefined); + } finally { + this.busy = false; } - } catch { - // The caller owns confirmation and error reporting. The panel still - // re-renders, so the user sees the state that actually resulted. } - await render(active, deps); -} -/** - * Resolve the model and put it on the webview. A rejected `read()` becomes an - * unavailable model carrying the reason, because a blank panel tells the user - * nothing and a panel that is still usable can be refreshed. - */ -async function render(active: CachePanelSession, deps: CachePanelDeps): Promise { - let model: CachePanelModel; - try { - model = buildModel(await deps.read()); - } catch (error) { - model = unavailableModel(error instanceof Error ? error.message : String(error)); + private async handle(raw: unknown): Promise { + const message = decodeCachePanelMessage(raw); + if (message === undefined) { + return; + } + try { + if (message.type === "refresh") { + // Nothing to run: the render below re-reads everything. + } else if (message.type === "showEntry") { + await this.deps.showEntry(message.label); + } else { + await this.deps.run(message); + } + } catch { + // The caller owns confirmation and error reporting. The view still + // re-renders, so the user sees the state that actually resulted. + } + this.refresh(); } - if (active.disposed) { - return; + + /** + * Resolve the model and put it on the webview. A rejected refresh/read pair + * becomes an unavailable model carrying the reason, because a blank view tells + * the user nothing and a view that is still usable can be redrawn. + */ + private async render(): Promise { + const view = this.view; + if (view === undefined) { + return; + } + let model: CachePanelModel; + try { + await this.deps.refresh(); + model = buildModel(this.deps.read()); + } catch (error) { + model = unavailableModel(error instanceof Error ? error.message : String(error)); + } + if (this.view !== view) { + return; + } + view.webview.html = renderCachePanelHtml(model, assets(this.context, view)); } - active.panel.webview.html = renderCachePanelHtml(model, assets(active)); } function buildModel(data: CachePanelData): CachePanelModel { @@ -194,7 +255,7 @@ function buildModel(data: CachePanelData): CachePanelModel { return model; } -/** Both halves unavailable, with one reason. Used when `read()` rejects. */ +/** Both halves unavailable, with one reason. Used when the read rejects. */ function unavailableModel(message: string): CachePanelModel { return { ui: panelLabels(), @@ -244,6 +305,10 @@ function numberFormat(): NumberFormat { /** * Every visible string, resolved once per model. These are the literals * `tools/l10n-check.mjs` holds to `data/i18n/zh-cn.json`. + * + * A key the §8.1 layout no longer prints (`refresh`, `details`, `colActions`, + * the four section hints, `barsHint`) is gone rather than left as a dictionary + * entry: the constant and the renderer have to keep meaning the same thing. */ function panelLabels(): Record { return { @@ -252,58 +317,49 @@ function panelLabels(): Record { [CACHE_PANEL_UI.boundary]: t( "Every figure here is read from mcpp cache list, mcpp cache dir and a bounded walk of target/. Sizes are estimates; cleanup runs only after confirmation and never automatically.", ), - [CACHE_PANEL_UI.projectTitle]: t("Project artifacts"), + [CACHE_PANEL_UI.projectTitle]: t("Project cache"), [CACHE_PANEL_UI.projectFiles]: t("{0} file(s) · {1} group(s)"), + [CACHE_PANEL_UI.projectStale]: t("Stale artifacts: about {0}"), [CACHE_PANEL_UI.sharedTitle]: t("Global build cache"), [CACHE_PANEL_UI.sharedEntries]: t("{0} entries"), [CACHE_PANEL_UI.sharedRoot]: t("Root: {0}"), - [CACHE_PANEL_UI.sharedOldest]: t("Oldest use: {0}"), - [CACHE_PANEL_UI.sharedNewest]: t("Newest use: {0}"), [CACHE_PANEL_UI.legacyTitle]: t("Pre-v1 cache"), [CACHE_PANEL_UI.legacyPath]: t("Path: {0}"), [CACHE_PANEL_UI.unknown]: t("unknown"), [CACHE_PANEL_UI.projectUnavailable]: t("Project artifacts could not be measured."), [CACHE_PANEL_UI.sharedUnavailable]: t("The shared build cache could not be read."), [CACHE_PANEL_UI.actions]: t("Cache actions"), - [CACHE_PANEL_UI.refresh]: t("Refresh"), [CACHE_PANEL_UI.cleanStale]: t("Clean stale artifacts"), [CACHE_PANEL_UI.cleanProject]: t("Clean project artifacts"), [CACHE_PANEL_UI.prune]: t("Drop entries unused for a while"), [CACHE_PANEL_UI.verify]: t("Verify the cache"), [CACHE_PANEL_UI.cleanLegacy]: t("Remove the pre-v1 cache"), [CACHE_PANEL_UI.collect]: t("Collect to this budget"), - [CACHE_PANEL_UI.details]: t("Details"), [CACHE_PANEL_UI.detailsFor]: t("Show cache entry details for {0}"), [CACHE_PANEL_UI.reasonShared]: t("The shared build cache is not available."), [CACHE_PANEL_UI.reasonProject]: t("Project artifacts are not available."), [CACHE_PANEL_UI.reasonLegacy]: t("There is no pre-v1 cache to remove."), [CACHE_PANEL_UI.composition]: t("Composition by kind"), - [CACHE_PANEL_UI.compositionHint]: t("Share of the total cache size, by entry kind."), [CACHE_PANEL_UI.compositionEmpty]: t("No cache entries were found."), - [CACHE_PANEL_UI.age]: t("Age distribution"), - [CACHE_PANEL_UI.ageHint]: t("Bytes and entries by how long ago they were last used."), + [CACHE_PANEL_UI.age]: t("Last use"), [CACHE_PANEL_UI.ageEmpty]: t("No entry has a recorded last use."), [CACHE_PANEL_UI.ageUnder]: t("under {0} day(s)"), [CACHE_PANEL_UI.ageRange]: t("{0}–{1} day(s)"), [CACHE_PANEL_UI.ageOverflow]: t("more than {0} day(s)"), [CACHE_PANEL_UI.ageUnknown]: t("{0} entries have no recorded last use and are not counted in this bar."), [CACHE_PANEL_UI.top]: t("Largest packages (top {0})"), - [CACHE_PANEL_UI.topHint]: t("The {0} largest cache labels, by bytes."), [CACHE_PANEL_UI.topEmpty]: t("No cache label was read."), [CACHE_PANEL_UI.colLabel]: t("Label"), [CACHE_PANEL_UI.colEntries]: t("Entries"), [CACHE_PANEL_UI.colBytes]: t("Size"), [CACHE_PANEL_UI.colOldest]: t("Oldest use"), - [CACHE_PANEL_UI.colActions]: t("Actions"), - [CACHE_PANEL_UI.budget]: t("Budget simulator"), + [CACHE_PANEL_UI.budgetLabel]: t("Keep the shared build cache under"), [CACHE_PANEL_UI.budgetHint]: t( "Simulates mcpp cache gc --max-size: the estimate is a local projection, so mcpp's own policy decides in the end.", ), - [CACHE_PANEL_UI.budgetLabel]: t("Keep the shared build cache under"), [CACHE_PANEL_UI.budgetUnit]: t("GiB"), [CACHE_PANEL_UI.incompleteWarning]: t("{0} cache entries are incomplete; run mcpp cache verify to see which ones."), [CACHE_PANEL_UI.sizeWarning]: t("The shared build cache is {0}, at or above the {1} warning threshold."), - [CACHE_PANEL_UI.barsHint]: t("The same figures are listed as text next to each bar."), }; } @@ -323,10 +379,10 @@ function mediaRoot(context: vscode.ExtensionContext): vscode.Uri { return vscode.Uri.joinPath(context.extensionUri, MEDIA_DIRECTORY); } -function assets(active: CachePanelSession): CachePanelAssets { +function assets(context: vscode.ExtensionContext, view: vscode.WebviewView): CachePanelAssets { return { - cspSource: active.panel.webview.cspSource, + cspSource: view.webview.cspSource, nonce: randomBytes(16).toString("base64"), - styleUri: active.panel.webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot(active.context), STYLESHEET)).toString(), + styleUri: view.webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot(context), STYLESHEET)).toString(), }; } diff --git a/src/views/cachePanelHtml.ts b/src/views/cachePanelHtml.ts index 644834c..66bc34b 100644 --- a/src/views/cachePanelHtml.ts +++ b/src/views/cachePanelHtml.ts @@ -1,11 +1,11 @@ /** - * The cache panel's document, as a pure function (plan §3.4.3, §3.5). + * The cache view's document, as a pure function (plan §8.1). * * `src/views/cachePanel.ts` resolves the cache inventory into a * `CachePanelModel`; 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 proportional bars, the empty - * state) unit-testable. + * interesting parts (escaping, the strict CSP, the proportional bars, the + * collapsed global block, the empty state) unit-testable. * * House rules, the same ones `src/config/panelHtml.ts` pins down: * @@ -18,13 +18,19 @@ * - **No unescaped interpolation.** Every value that reaches the markup goes * through `escapeHtml`. * - **State is text plus shape, not colour.** Every bar is drawn twice: once as - * inline SVG (shape) and once as a ``-free textual list of the same - * figures (text). Colour is decoration, so a high-contrast theme and a screen - * reader both get the whole story. + * inline SVG (shape) and once as the text of the row beside it (the legend, or + * the figure line under the project bar). Colour is decoration, so a + * high-contrast theme and a screen reader both get the whole story. + * + * §8.1 puts one primary bar in the document (the project block's, 6 px) and + * compresses the legend into a single wrapped line separated by `·`; the type + * scale is 26 / 12 / 11 px and everything is separated by whitespace rather than + * nested borders. The global block is a real `
    ` that renders + * **collapsed**: the markup carries no `open` attribute. * * The visualisation is CSS plus inline SVG on purpose. A `` is not - * an inline style, so the composition and age bars can be proportional without - * ever breaking the CSP. + * an inline style, so the bars can be proportional without ever breaking the + * CSP. Bar *heights* are class-driven (`media/cache.css`), never inline. * * Numbers: every size and count is rendered through `model.format`, so this * module never chooses binary vs decimal units and never applies a locale. Two @@ -62,7 +68,13 @@ export interface CachePanelModel { totalBytes: number; files: number; groups: number; - staleNote?: string; + /** + * Bytes mcpp would drop with `mcpp clean --stale`. Optional on purpose: + * nothing in this extension can know that figure today (mcpp owns the + * staleness rule), so the block renders the line and the split bar only + * when a caller supplies one. + */ + staleBytes?: number; truncated?: string; }; shared: { @@ -88,7 +100,7 @@ export interface CachePanelModel { budgetGiB?: number; }; /** - * The caller's LRU projection for the budget simulator. Optional: when the + * The caller's LRU projection for the budget control. Optional: when the * caller has no estimate the renderer omits the line rather than inventing a * number. */ @@ -102,7 +114,7 @@ export interface CachePanelAssets { } /** - * The `ui` keys the renderer reads, so the caller and the renderer cannot drift + * The `ui` keys the panel uses, so the caller and the renderer cannot drift * apart on a string. `cachePanel.ts` fills all of them through `t()`. */ export const CACHE_PANEL_UI = { @@ -111,54 +123,45 @@ export const CACHE_PANEL_UI = { boundary: "cache.boundary", projectTitle: "cache.project.title", projectFiles: "cache.project.files", + projectStale: "cache.project.stale", sharedTitle: "cache.shared.title", sharedEntries: "cache.shared.entries", sharedRoot: "cache.shared.root", - sharedOldest: "cache.shared.oldest", - sharedNewest: "cache.shared.newest", legacyTitle: "cache.legacy.title", legacyPath: "cache.legacy.path", unknown: "cache.unknown", projectUnavailable: "cache.project.unavailable", sharedUnavailable: "cache.shared.unavailable", actions: "cache.actions", - refresh: "cache.action.refresh", cleanStale: "cache.action.cleanStale", cleanProject: "cache.action.cleanProject", prune: "cache.action.prune", verify: "cache.action.verify", cleanLegacy: "cache.action.cleanLegacy", collect: "cache.action.collect", - details: "cache.action.details", detailsFor: "cache.action.detailsFor", reasonShared: "cache.reason.shared", reasonProject: "cache.reason.project", reasonLegacy: "cache.reason.legacy", composition: "cache.composition.title", - compositionHint: "cache.composition.hint", compositionEmpty: "cache.composition.empty", age: "cache.age.title", - ageHint: "cache.age.hint", ageEmpty: "cache.age.empty", ageUnder: "cache.age.under", ageRange: "cache.age.range", ageOverflow: "cache.age.overflow", ageUnknown: "cache.age.unknown", top: "cache.top.title", - topHint: "cache.top.hint", topEmpty: "cache.top.empty", colLabel: "cache.col.label", colEntries: "cache.col.entries", colBytes: "cache.col.bytes", colOldest: "cache.col.oldest", - colActions: "cache.col.actions", - budget: "cache.budget.title", - budgetHint: "cache.budget.hint", budgetLabel: "cache.budget.label", + budgetHint: "cache.budget.hint", budgetUnit: "cache.budget.unit", incompleteWarning: "cache.warn.incomplete", sizeWarning: "cache.warn.size", - barsHint: "cache.bars.hint", } as const; export type CachePanelMessage = @@ -248,6 +251,27 @@ function isoSeconds(seconds: number | undefined): string | undefined { return value <= 0 ? undefined : new Date(value * 1000).toISOString(); } +/** + * `"12.4 MiB"` -> the 26 px value and the small unit beside it. The split is + * pure text: the renderer never re-formats a number, so binary and decimal read + * the same way they do everywhere else in the extension. A figure with no space + * (`"1000B"`) is rendered as one value with no unit element. + */ +function splitMetric(text: string): { value: string; unit?: string } { + const at = text.lastIndexOf(" "); + if (at <= 0 || at === text.length - 1) { + return { value: text }; + } + return { value: text.slice(0, at), unit: text.slice(at + 1) }; +} + +/** The primary figure: 26 px value, small unit, tabular figures (plan §8.1). */ +function renderMetric(text: string): string { + const parts = splitMetric(text); + const unit = parts.unit === undefined ? "" : `${escapeHtml(parts.unit)}`; + return `

    ${escapeHtml(parts.value)}${unit}

    `; +} + /** One segment of a bar: pre-rendered attributes, a caption and a size. */ interface BarPart { /** Already-escaped `data-*` attributes, including a leading space. */ @@ -256,8 +280,14 @@ interface BarPart { bytes: number; } -/** Inline SVG: one `` per part, widths proportional to bytes. */ -function renderBar(parts: readonly BarPart[], total: number, className: string): string { +/** + * Inline SVG: one `` per part, widths proportional to bytes. `className` + * chooses the height (6 px or 14 px) — the renderer never sets a style. + * + * Without an `ariaLabel` the graphic is decoration for the labelled text beside + * it; with one it becomes the shape whose name carries the same figure. + */ +function renderBar(parts: readonly BarPart[], total: number, className: string, ariaLabel?: string): string { let cumulative = 0; const groups = parts .map((part) => { @@ -273,22 +303,158 @@ function renderBar(parts: readonly BarPart[], total: number, className: string): ].join("\n"); }) .join("\n"); + const semantics = + ariaLabel === undefined + ? ` aria-hidden="true" focusable="false"` + : ` role="img" aria-label="${escapeHtml(ariaLabel)}" focusable="false"`; return [ - `

    ${escapeHtml(title)}

    `, - body, - ``, - ].join("\n"); +/** + * The legend, compressed into **one line** (§8.1): every item is inline, the + * `·` separators are generated by the stylesheet, and all items share a single + * `
      ` so the line wraps as text rather than as rows. + */ +function renderLegend(id: string, items: readonly string[]): string { + return `
        ${items.join("")}
      `; +} + +/** One legend item: swatch (shape), caption (text) and share (text). */ +function legendItem(attributes: string, caption: string, pct: number, detail: string): string { + return ( + `
    • ` + + `` + + `${escapeHtml(caption)}` + + `${escapeHtml(`${percentText(pct)}%`)}` + + `
    • ` + ); +} + +/** `Reason` plus the caller's explanation, when there is one. */ +function reasonText(label: UiLabel, key: string, note?: string): string { + const base = label(key); + return note === undefined || note.length === 0 ? base : `${base} ${note}`; +} + +function button(action: string, text: string, disabled: boolean, reason: string): string { + return ( + `` + ); +} + +// ─────────────────────────────────────────────────────────────── warnings + +function renderWarnings(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const warnings: string[] = []; + if (model.shared.available && finite(model.shared.incomplete) > 0) { + warnings.push( + `

      ${escapeHtml( + fill(label, CACHE_PANEL_UI.incompleteWarning, [formatters.count(finite(model.shared.incomplete))]), + )}

      `, + ); + } + const threshold = finite(model.limits.warnAboveGiB); + const gib = finite(model.shared.totalBytes) / 1024 ** 3; + if (threshold > 0 && gib >= threshold) { + warnings.push( + `

      ${escapeHtml( + fill(label, CACHE_PANEL_UI.sizeWarning, [ + formatters.bytes(finite(model.shared.totalBytes)), + formatters.bytes(threshold * 1024 ** 3), + ]), + )}

      `, + ); + } + return warnings.length === 0 ? "" : `
      \n${warnings.join("\n")}\n
      `; +} + +// ────────────────────────────────────────────────────────── project cache + +/** + * The project block, always on screen (§8.1): the primary figure, one line of + * secondary figures, one 6 px bar, the stale line when a caller can name it, and + * the two project actions. + */ +function renderProjectBlock(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const available = model.project.available; + const total = Math.max(0, finite(model.project.totalBytes)); + const value = available ? formatters.bytes(total) : label(CACHE_PANEL_UI.unknown); + + const parts: string[] = [ + `
      `, + `

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

      `, + ` ${renderMetric(value)}`, + ]; + + if (available) { + parts.push( + `

      ${escapeHtml( + fill(label, CACHE_PANEL_UI.projectFiles, [ + formatters.count(finite(model.project.files)), + formatters.count(finite(model.project.groups)), + ]), + )}

      `, + ); + } else { + parts.push( + `

      ${escapeHtml(model.project.note ?? label(CACHE_PANEL_UI.projectUnavailable))}

      `, + ); + } + + const stale = available ? Math.max(0, finite(model.project.staleBytes ?? 0)) : 0; + if (available && stale > 0 && total > 0) { + const bar = renderBar( + [ + { + attributes: ` data-segment="stale"`, + label: fill(label, CACHE_PANEL_UI.projectStale, [formatters.bytes(stale)]), + bytes: stale, + }, + { + attributes: ` data-segment="current"`, + label: label(CACHE_PANEL_UI.projectTitle), + bytes: Math.max(0, total - stale), + }, + ], + total, + "viz-bar viz-bar-thin project-bar", + fill(label, CACHE_PANEL_UI.projectStale, [formatters.bytes(stale)]), + ); + parts.push(` ${bar}`); + parts.push( + `

      ${escapeHtml( + fill(label, CACHE_PANEL_UI.projectStale, [formatters.bytes(stale)]), + )}

      `, + ); + } + + if (available && model.project.truncated !== undefined) { + parts.push(`

      ${escapeHtml(model.project.truncated)}

      `); + } + if (available && model.project.note !== undefined) { + parts.push(`

      ${escapeHtml(model.project.note)}

      `); + } + + const reason = reasonText(label, CACHE_PANEL_UI.reasonProject, model.project.note); + parts.push( + ` `, + ); + parts.push(`
      `); + return parts.join("\n"); } +// ─────────────────────────────────────────────────────────── global cache + /** One source segment of the composition bar, before percentages are known. */ interface CompositionSource { kind: string; @@ -317,42 +483,47 @@ function renderComposition(model: CachePanelModel, label: UiLabel): string { } } if (legacyBytes > 0) { - sources.push({ kind: "legacy", segment: "legacy", caption: label(CACHE_PANEL_UI.legacyTitle), bytes: legacyBytes }); + sources.push({ + kind: "legacy", + segment: "legacy", + caption: label(CACHE_PANEL_UI.legacyTitle), + bytes: legacyBytes, + }); } const total = sources.reduce((sum, source) => sum + Math.max(0, finite(source.bytes)), 0); + const aria = label(CACHE_PANEL_UI.composition); + if (sources.length === 0 || total <= 0) { + return [ + `
      `, + `

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

      `, + `
      `, + ].join("\n"); + } + const parts: BarPart[] = []; - const legend: string[] = []; + const items: string[] = []; for (const source of sources) { // `data-kind` stays the raw mcpp value: it is the hook the stylesheet and // the tests select on, so a translated caption must not change it. const attributes = ` data-kind="${escapeHtml(source.kind)}" data-segment="${source.segment}"`; parts.push({ attributes, label: source.caption, bytes: source.bytes }); - const values = [`${percentText(percent(source.bytes, total))}%`, formatters.bytes(finite(source.bytes))]; - if (source.entries !== undefined) { - values.push(formatters.count(finite(source.entries))); - } - legend.push( - `` + - `` + - `${escapeHtml(source.caption)}` + - `${escapeHtml(values.join(" · "))}` + - ``, - ); + const pct = percent(source.bytes, total); + const detail = [ + source.caption, + `${percentText(pct)}%`, + formatters.bytes(finite(source.bytes)), + ...(source.entries === undefined ? [] : [formatters.count(finite(source.entries))]), + ].join(" · "); + items.push(legendItem(attributes, source.caption, pct, detail)); } - const hint = `

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

      `; - if (parts.length === 0 || total <= 0) { - return renderSection("composition", label(CACHE_PANEL_UI.composition), `${hint}\n

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

      `); - } - const body = [ - hint, - renderBar(parts, total, "viz-bar"), - `
        `, - legend.join("\n"), - `
      `, + return [ + `
      `, + ` ${renderBar(parts, total, "viz-bar")}`, + ` ${renderLegend("composition", items)}`, + `
      `, ].join("\n"); - return renderSection("composition", label(CACHE_PANEL_UI.composition), body); } /** One age bucket, with its caption already resolved. */ @@ -364,7 +535,7 @@ interface AgeSource { index: number; } -/** The age distribution bar: one segment per bucket, `<1d` through the overflow. */ +/** The age bar: one segment per bucket, `<1d` through the overflow. */ function renderAge(model: CachePanelModel, label: UiLabel): string { const formatters = model.format; const buckets = model.shared.available ? model.shared.buckets : []; @@ -391,50 +562,74 @@ function renderAge(model: CachePanelModel, label: UiLabel): string { }); const ageTotal = sources.reduce((sum, source) => sum + Math.max(0, finite(source.bytes)), 0); + const heading = `

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

      `; + const notes: string[] = []; + const unaccounted = finite(model.shared.totalEntries) - accounted; + if (model.shared.available && unaccounted > 0) { + notes.push( + `

      ${escapeHtml( + fill(label, CACHE_PANEL_UI.ageUnknown, [formatters.count(unaccounted)]), + )}

      `, + ); + } + + if (sources.length === 0 || ageTotal <= 0) { + return [ + `
      `, + ` ${heading}`, + ...notes.map((note) => ` ${note}`), + `

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

      `, + `
      `, + ].join("\n"); + } + const parts: BarPart[] = []; - const legend: string[] = []; + const items: string[] = []; for (const source of sources) { const attributes = ` data-bucket="${source.oldest ? "oldest" : "recent"}" data-bucket-index="${source.index}"`; parts.push({ attributes, label: source.caption, bytes: source.bytes }); - legend.push( - `` + - `` + - `${escapeHtml(source.caption)}` + - `${escapeHtml(`${formatters.bytes(finite(source.bytes))} · ${formatters.count(finite(source.entries))}`)}` + - ``, - ); + const pct = percent(source.bytes, ageTotal); + const detail = [ + source.caption, + `${percentText(pct)}%`, + formatters.bytes(finite(source.bytes)), + formatters.count(finite(source.entries)), + ].join(" · "); + items.push(legendItem(attributes, source.caption, pct, detail)); } - const hints = [`

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

      `]; - const unaccounted = finite(model.shared.totalEntries) - accounted; - if (model.shared.available && unaccounted > 0) { - hints.push(`

      ${escapeHtml(fill(label, CACHE_PANEL_UI.ageUnknown, [formatters.count(unaccounted)]))}

      `); - } - if (parts.length === 0 || ageTotal <= 0) { - hints.push(`

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

      `); - return renderSection("age", label(CACHE_PANEL_UI.age), hints.join("\n")); - } - const body = [ - hints.join("\n"), - renderBar(parts, ageTotal, "viz-bar"), - `
        `, - legend.join("\n"), - `
      `, + return [ + `
      `, + ` ${heading}`, + ...notes.map((note) => ` ${note}`), + ` ${renderBar(parts, ageTotal, "viz-bar viz-bar-thin")}`, + ` ${renderLegend("age", items)}`, + `
      `, ].join("\n"); - return renderSection("age", label(CACHE_PANEL_UI.age), body); } -/** The largest packages, each row carrying its label and a `showEntry` button. */ +/** + * The largest packages, inside a second collapsed `
      ` (§8.1). Each label + * is itself the `showEntry` button, so dropping the old "Actions" column does + * not drop the drill-down. + */ function renderTop(model: CachePanelModel, label: UiLabel): string { const formatters = model.format; - const head = - `
    ` + - `` + - `` + - `` + - `` + - `` + - ``; + if (model.shared.top.length === 0) { + return [ + `
    `, + `

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

    `, + `
    `, + ].join("\n"); + } + + const head: Array<[string, string]> = [ + [CACHE_PANEL_UI.colLabel, label(CACHE_PANEL_UI.colLabel)], + [CACHE_PANEL_UI.colEntries, label(CACHE_PANEL_UI.colEntries)], + [CACHE_PANEL_UI.colBytes, label(CACHE_PANEL_UI.colBytes)], + [CACHE_PANEL_UI.colOldest, label(CACHE_PANEL_UI.colOldest)], + ]; + const header = `${head.map(([, text]) => ``).join("")}`; const rows = model.shared.top.map((row) => { const iso = isoSeconds(row.oldestAccessed); @@ -442,183 +637,174 @@ function renderTop(model: CachePanelModel, label: UiLabel): string { iso === undefined ? `${escapeHtml(label(CACHE_PANEL_UI.unknown))}` : ``; + const detailsFor = fill(label, CACHE_PANEL_UI.detailsFor, [row.label]); return [ ``, - ` `, - ` `, - ` `, - ` `, - ` `, + // `data-head` carries the column name onto the cell, so the stylesheet can + // stack the table into labelled rows at 200 px without a second renderer. + ` `, + ` `, + ` `, + ` `, ``, ].join("\n"); }); - const body = - model.shared.top.length === 0 - ? `

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

    ` - : [ - `

    ${escapeHtml(fill(label, CACHE_PANEL_UI.topHint, [formatters.count(finite(model.limits.topN))]))}

    `, - `
    ${escapeHtml(label(CACHE_PANEL_UI.colLabel))}${escapeHtml(label(CACHE_PANEL_UI.colEntries))}${escapeHtml(label(CACHE_PANEL_UI.colBytes))}${escapeHtml(label(CACHE_PANEL_UI.colOldest))}${escapeHtml(label(CACHE_PANEL_UI.colActions))}
    ${escapeHtml(text)}
    ${escapeHtml(row.label)}${escapeHtml(formatters.count(finite(row.entries)))}${escapeHtml(formatters.bytes(finite(row.bytes)))}${oldest}` + + `${escapeHtml(formatters.count(finite(row.entries)))}${escapeHtml(formatters.bytes(finite(row.bytes)))}${oldest}
    `, - `${head}`, - ``, - rows.join("\n"), - ``, - `
    `, - ].join("\n"); - return renderSection( - "top", - fill(label, CACHE_PANEL_UI.top, [formatters.count(finite(model.limits.topN))]), - body, - ); -} - -/** The budget simulator: a GiB input, a `collect` button, and the estimate. */ -function renderBudget(model: CachePanelModel, label: UiLabel): string { - const disabled = !model.shared.available; - const budget = model.limits.budgetGiB; - const value = budget === undefined || !Number.isFinite(budget) ? undefined : Math.max(0, Math.floor(budget)); - const notes = [`

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

    `]; - if (model.estimate !== undefined && model.estimate.length > 0) { - notes.push(`

    ${escapeHtml(model.estimate)}

    `); - } - notes.push( - `
    `, - ` `, - ` `, - ` ${escapeHtml(label(CACHE_PANEL_UI.budgetUnit))}`, - ` `, - `
    `, - ); - return renderSection("budget", label(CACHE_PANEL_UI.budget), notes.join("\n")); -} - -/** `Reason` plus the caller's explanation, when there is one. */ -function reasonText(label: UiLabel, key: string, note?: string): string { - const base = label(key); - return note === undefined || note.length === 0 ? base : `${base} ${note}`; + return [ + `
    `, + ` ${escapeHtml( + fill(label, CACHE_PANEL_UI.top, [formatters.count(finite(model.limits.topN))]), + )}`, + ` `, + ` ${header}`, + ` `, + rows.join("\n"), + ` `, + `
    `, + `
    `, + ].join("\n"); } -function renderActions(model: CachePanelModel, label: UiLabel): string { +/** + * The ones that must be asked for (plan §8.1): the budget input stays next to + * the button that uses it — `mcpp cache gc --max-size` has no implicit default, + * so the figure has to come from somewhere the user can see. + */ +function renderSharedActions(model: CachePanelModel, label: UiLabel): string { const sharedAvailable = model.shared.available; - const projectAvailable = model.project.available; - const anyAvailable = sharedAvailable || projectAvailable; const legacyBytes = finite(model.legacy?.bytes ?? 0); const sharedReason = reasonText(label, CACHE_PANEL_UI.reasonShared, model.shared.note); - const projectReason = reasonText(label, CACHE_PANEL_UI.reasonProject, model.project.note); - const refreshReason = sharedAvailable ? projectReason : sharedReason; - - const button = (action: string, text: string, disabled: boolean, reason: string): string => - ``; - + const budget = model.limits.budgetGiB; + const value = budget === undefined || !Number.isFinite(budget) ? undefined : Math.max(0, Math.floor(budget)); + const budgetHint = label(CACHE_PANEL_UI.budgetHint); + const estimate = + model.estimate === undefined || model.estimate.length === 0 + ? "" + : `${escapeHtml(model.estimate)}`; return [ - `