From 2a551e4cdae13ce37541fe8b09939a2df10f9f6c Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:06:41 +0800 Subject: [PATCH 01/25] docs(agents): the 0.0.6 plan: qt-demo, navigation into implementation units, build discovery, one settings module --- ...09-27-qt-demo-navigation-discovery-plan.md | 610 ++++++++++++++++++ 1 file changed, 610 insertions(+) create mode 100644 .agents/docs/2026-09-27-qt-demo-navigation-discovery-plan.md diff --git a/.agents/docs/2026-09-27-qt-demo-navigation-discovery-plan.md b/.agents/docs/2026-09-27-qt-demo-navigation-discovery-plan.md new file mode 100644 index 0000000..0fe05cd --- /dev/null +++ b/.agents/docs/2026-09-27-qt-demo-navigation-discovery-plan.md @@ -0,0 +1,610 @@ +# mcppls 优化方案:qt-demo 链式报错、跳到声明而不是实现、构建工具无感探测 + +状态:方案第 3 版(对照 mcpp 2026.9.27.1 复核;问题 2 找到根因——clangd 的后台索引不为模块单元准备依赖,方案随之重排; +D1、D2、D5 已定,D4′ 待再确认)· 2026-09-27 · 基于 mcppls `main` 2e80b85(0.0.5)、mcpp `main` b439fd97(2026.9.27.1, +本机从源码构建:xlings 索引与 GitHub Release 尚未发布该版本) + +依据: + +- qt-demo(`/home/speak/test/mcpp/qt-demo`,mcpp 2026.9.26.2 + `mcpp:plugins` 0.15.2 `rules-qt`,gcc 16.1.0,Qt 6.11.1): + 用户三次 VS Code 会话的日志(07:10、12:03、16:29,`~/.config/Code/logs/*/window*/exthost/sunrisepeak.mcpp-language-server/`), + `mcppls check`、`mcpp emit build-database` 的原始输出、引擎数据库;以及在副本上验证修法的端到端运行(§1.4); +- 跳转实测:自写 LSP 客户端(每秒请求一次 `textDocument/definition`,记录结果的变化)跑 mcppls 0.0.5 + payload 中的 + clangd 23.1.0,项目为 hello 系列、本仓库、GalTranslPP(`/home/speak/workspace/scode/GalTranslPP`); + clangd 行为对照 llvm-project 23.1.0 源码; +- 构建工具实测:cmake 4.4.2、xmake v3.1.1(含其 Lua 源码)在本机的离线行为和耗时; +- mcpp 2026.9.27.1(#719:#704–#716、`prepare.cppm` 拆成 16 个实现单元 + 实现分区 `:state`):qt-demo 的 emit 复测; + mcpp 自身源码作为大型"接口 + 实现单元 + 分区"项目做跳转实测(引擎数据库 507 条)。 + +编号:问题 1 为 Q1-*,问题 2 为 N-*(navigation),问题 3 为 B-*(build discovery),需要 mcpp / rules-qt 处理的为 M-*。 + +## 0. 摘要 + +### 0.1 三个结论 + +1. **qt-demo:`ui_mainwindow.h` 是持续存在的根因,两边都要修,mcppls 这边能独立解决。** mcpp 的 emit 数据库把生成物的 + `-I` 指向 emit 私有目录(`~/.mcpp/cache/build-database//target/.build-mcpp/out/qt`),那里没有 `ui_mainwindow.h`; + 同时把 `.ui/.qrc/.ts` 写成了 C++ 翻译单元(M-1、M-2)。16:29 的会话里 std 已经不再误判,**剩下的就是这一条**。 + 已验证修法:把私有目录换成项目自己的 `target/.build-mcpp/out/qt` 并剔除非 C++ 条目后,`main.cpp` 的 + `clangd --check` 退出码 0、零错误(Q1-2 + Q1-3,升为 P0)。另外 12:15 那次会话里 mcppls 把 std 的 + "找不到提供者" 误判成 "编不过",把整个项目切到了 libc++(Q1-1)。**mcpp 2026.9.27.1 复测:M-1、M-2 原样存在** + (`.ui/.qrc/.ts` 仍是翻译单元,`-I` 仍指向私有目录;emit 0.25 s,不写项目)。M-2 按 mcpp 规范是有意为之 + (emit 不写工程、不运行 action,R2.1/R2.5),所以向 mcpp 提的要求改为"在 S1 里标明生成物",mcppls 的 Q1-3 长期保留。 +2. **跳到声明:根因找到了,比第 2 版估计的严重。** clangd 23.1.0 的**后台索引编译模块单元时不准备模块依赖** + (`BackgroundIndex::index()` 不经过 ModulesBuilder,源码可证;日志里每个模块单元都是 `Failed to compile …, index may be + incomplete`,hello13 的 16 个项目单元全部如此,`math.cpp` 索引出 0 个符号)。实现单元里的定义能不能进索引,全看 clang 的 + 错误恢复能保住多少:签名只用内建类型、限定名能解析的,常常碰巧对得上(所以最小 hello 基本正常);签名里有模块里的类型 + (`PrepareState&`、`const std::string&`)、或成员函数属于分区里的类,就对不上——**索引里声明和定义变成两个符号** + (`workspace/symbol` 能同时看到 `state.cppm:495` 与 `graph.cpp:61` 两条)。实测:**mcpp 2026.9.27.1 自身**拆出来的 + `prepare` 模块,从 `driver.cpp` 点 `phase0_…`、`phase4b_…`,从 `cmd_build.cppm` 点 `prepare_build`,420 s 内三处全部停在 + 声明。打开过的文件走前台路径(会构建模块依赖),所以"打开过一次就好了"。**修法在 mcpp 源码上验证了三处中的两处**:让 clangd + 临时打开 `manifest.cpp`、`graph.cpp` 再关闭,5 s 内到 `.cpp`,关闭后 120 s 内一直正确(N-7)。第三处 `prepare_build` 另有原因: + 定义文件一直开着,hover 也知道两者是同一个函数,但索引里根本没有这个符号(`workspace/symbol` 为空),N-7 修不了,列为待查 O-1。GalTranslPP 的实现 `.cpp` 全是模块单元,同一机制适用, + 预计同样受影响;Windows 上的确认仍需 V-W。第 2 版里的 (a) 磁盘改动、(b) 解析不了、(d) `.ixx` 不被优先索引都还成立, + 但它们是这个根因之上的附加情况。 +3. **构建工具探测收敛成 `BuildSystemProvider`,并且可以关掉。** 只看文件的探测 → 读已有产物 → 离线、私有目录、有时限地 + 问构建工具 → 需要下载时问一次,同意后后台联网,期间 L4 顶上,拿到后无感切换。xmake 用的正是专门的 + `xmake project -k compile_commands`,**不编译**;但它会隐式配置并做**模块依赖扫描**,默认写进项目的 `build/`, + 所以要私有 `--builddir`;加上 `--policies=package.fetch_only,network.mode:private` 后不再联网更新仓库(已验证)。 + 新增设置 `mcppls.buildDiscovery`(`auto` / `off`)和按提供者的开关(B-7)。对照 mcpp 2026.9.27.1:emit 已声明自己的 + 副作用(`effects`: `read-project`、`write-global-cache`、`exec-build-script`),缺依赖有稳定代码 + `MCPP_OFFLINE_DOWNLOAD_REQUIRED`(mcppls 已按代码识别);但**部分回答里某个成员需要下载时,mcppls 只报 + `producer-partial`,不会进入询问流程**(B-8,新发现)。 + +### 0.2 已定的决策 + +| # | 决定 | +|---|---| +| D1 | CMake 首次配置改为离线优先(修订 BD7):不问就不联网 | +| D2 | 联网获取按工作区一次性同意;提供"不再询问";不改全局设置 | +| D5 | 首个模型的等待 10 s → 2.5 s,之后 L4 先上、后台继续 | +| D4′ | **待再确认**:原提案"tier 1/2 只按文件回退"做不到(§7 R1),改为"只在确认是 std 自身编译失败时整体切换,并同时移除工具链的 std 单元" | + +### 0.3 条目总表 + +| 主题 | # | 修什么 | 证据 | 归属 | 优先级 | 估算 | +|---|---|---|---|---|---|---| +| **qt-demo** | Q1-3 | 生成物目录:emit 私有目录缺文件时,只读地改用项目自己 `target/` 下的同一相对路径;都没有就报 `generated-files-missing`,按钮"在终端运行 mcpp build" | 副本端到端:`clangd --check` 退出码 0 | mcppls | **P0** | 1.5 天 | +| | Q1-2 | 引擎数据库剔除非 C/C++ 条目,计入 "left out" 并记日志 | 同上;clangd `'linker' input unused`、`Couldn't build compiler invocation` | mcppls | P0 | 0.5 天 | +| | Q1-1 | std 的 `unresolved` 不触发 kit 回退;回退只认 std 单元自身的编译失败(D4′) | 12:15:52.944 加入 `std.cc`,78 ms 后被判失败 | mcppls | P0 | 1 天 | +| | Q1-4 | 数据库代际:clangd 读到新一代之前报的模块失败只记日志 | 同上 | mcppls | P0 | 1 天(与 Q1-1 合做) | +| | M-1 | emit 不应把规则输入(`.ui/.qrc/.ts`)写成翻译单元(已提 mcpp#724 §1) | emit 原始输出 | mcpp | — | — | +| | M-2 | emit 的生成物路径指向从未填充的私有目录(规范 R2.1/R2.5 有意不运行 action):请 mcpp 在 S1 里标明哪些 `-I` 目录与单元是生成物、由哪个规则动作产生,必要时附上 `mcpp build` 会放到的工程内路径 | 2026.9.27.1 复测仍在;已提 mcpp#724 §2(含可选的"只跑无副作用生成器"模式) | mcpp / rules-qt | — | — | +| | M-3 | 离线 emit 缺依赖时整份失败 → 返回能规划的部分 + 缺什么(S2 `producer-partial` 已有形状) | 07:10 会话只拿到 inferred | mcpp | — | — | +| **跳转** | N-7 | **模块感知的索引补全**:mcppls 让 clangd 以前台路径(带模块依赖)逐个打开再关闭实现单元,补上后台索引建错的定义;三级队列——按需(definition 只拿到声明时,N-2)、相关(打开的文件所在模块与直接导入模块的实现单元,N-5)、其余(空闲时全部);clangd 每次重启后重做 | mcpp 源码:5 s 到 `.cpp`,关闭后 120 s 内稳定;hello B 场景 2 s | mcppls | **P0** | 3 天 | +| | N-1 | 未打开的源文件在磁盘上变了:送进 N-7 的"相关"级队列 | 修前 125 s 不恢复 | mcppls | P0 | 0.5 天(并入 N-7) | +| | N-6 | 上游:后台索引为模块单元准备依赖(`BackgroundIndex::index()` 接入 ModulesBuilder);登记到 #24,附 hello13 最小复现;另做一个探索:让 mcppls 的 module-hints 路径指向 clangd 自己构建的 BMI,后台索引即可成功(hello13:16/17 单元编译成功、9 个定义全部正确),但在 mcpp 源码上粗糙的链接方式让 clangd 12 s 内崩溃,需要设计 | §2.3 | 上游 / 探索 | P2 | 1 天(登记 + 复现);探索另计 | +| | N-3 | 解析不了的实现单元进状态栏(`implementation-unreadable`) | hello5、GalTranslPP(Linux) | mcppls | P1 | 1 天 | +| | N-4 | 夹具:分区里声明、签名含类类型的函数(最小复现),磁盘改动、新文件、坏实现单元 | §5 | mcppls | P1 | 1 天 | +| | V-W | GalTranslPP 的 Windows CI 跳转实测(修前 / 修后) | §2.4 | 验证 | P1 | 0.5 天 + CI 约 1 h | +| **构建探测** | B-1 | `BuildSystemProvider` 接口与注册表,现有 mcpp / CMake / compdb / inferred 平移 | §3.2 | mcppls | P1 | 3 天 | +| | B-2 | 需要下载时的询问 + 一次性联网获取 + L4 顶替 + 无感切换 | §3.5 | mcppls + 插件 | P1 | 3 天 | +| | B-3 | CMake 首次配置离线优先(D1);读 `CMakePresets.json` | 实测 5.5 s 失败、消息可识别 | mcppls | P1 | 1.5 天 | +| | B-4 | 首个模型等待 2.5 s(D5) | BD5 | mcppls | P1 | 0.5 天 | +| | B-5 | xmake 提供者 | 实测:离线配方有效、不写项目 | mcppls | P1 | 2 天 | +| | B-7 | 无感探测的开关:`mcppls.buildDiscovery`、按提供者启停、是否询问下载 | 用户要求 | mcppls + 插件 | P1 | 1 天 | +| | B-8 | mcpp 的部分回答里带 `MCPP_OFFLINE_DOWNLOAD_REQUIRED` 时:用已规划的部分,同时进入询问流程;`MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`(note)并入 `generated-files-missing` 的说明 | `src/project/mcpp.cpp:312` 只看 error、只报 partial | mcppls | P1 | 0.5 天 | +| | B-6 | meson 提供者(`--wrap-mode=nodownload`) | 未实测 | mcppls | P3 | 1 天 | + +mcppls 侧合计约 **24–26 人日**(不含 B-6 与 N-6 的探索)。 + +### 0.4 版本划分建议 + +| 版本 | 内容 | 估算 | +|---|---|---| +| **0.0.6 "准"** | Q1-1~Q1-4、N-7(含 N-1/N-2/N-5)、N-3、N-4、N-6 登记、V-W;同时向 mcpp 提 M-1/M-2/M-3 | 约 12 天 | +| **0.0.7 "顺"** | B-1、B-7、B-4、B-2、B-8、B-3、B-5 | 约 11.5 天 | +| 之后 | B-6(meson) | 约 1 天 | + +## 1. 问题 1:qt-demo 的链式报错 + +### 1.1 现场 + +项目:`[build] sources = ["src/*.cpp", "ui/*.ui", "res/*.qrc", "i18n/*.ts"]`,`build.mcpp` 调 `mcpp::rules::qt::compile` +(moc、uic、rcc、lupdate/lrelease),`src/main.cpp` 第 2 行 `#include "ui_mainwindow.h"`,之后 `import std;`。 + +三次会话: + +| 会话 | 模型 | 现象 | +|---|---|---| +| 07:10 | 先 inferred(`producer-needs-download`:`xim:qt-base@6.11.1` 未安装),07:11:40 起 mcpp 模型 | `ui_mainwindow.h` 找不到;`.ui/.qrc/.ts` 索引失败 | +| 12:03 | mcpp 模型(缓存),12:15 重读后加入 std 单元 | 同上,**加上** std 误判 → 整项目切 libc++ kit → 重启 | +| 16:29 | mcpp 模型,一开始就有 2 个 std 单元 | 只剩 `ui_mainwindow.h` 找不到(`IncludeCleaner` 刷屏)和三个非 C++ 条目;没有 kit 回退 | + +12:03 会话的关键行: + +``` +12:15:49.833 engine database changed: 6 compiled otherwise ...; restarting clangd in 2 s +12:15:52.944 engine database: 10 entries (2 standard library units) — 2 added std.cc, std.compat.cc +12:15:53.022 warning: argument unused during compilation: '-I …' '-O0' … (非 C++ 条目) +12:15:53.022 Failed to build module std; due to Don't get the module unit for module std +12:15:53.022 clangd could not build the standard library module …; reading the project with the semantic kit +12:15:53.830 engine database changed: 2 added std.compat.cppm, std.cppm, 2 removed std.cc, std.compat.cc, 6 compiled otherwise +12:15:58.764 clangd could not scan …/gcc/16.1.0/include/c++/16.1.0/bits/std.cc: fatal error: 'bits/stdc++.h' file not found +12:16:01.833 restarting clangd: a module's unit left the engine database (std) +``` + +### 1.2 链条 + +``` +M-2 生成物 -I 指向空目录 ──► main.cpp 致命失败(ui_mainwindow.h)──► main.cpp 无语义、无法跳转(每次会话都有) +M-1 .ui/.qrc/.ts 成了翻译单元 ──► 三条扫描、索引失败,日志刷屏(每次会话都有) +数据库刚加入 std.cc,clangd 仍在用旧一代 ──► "Don't get the module unit for module std"(只在 12:15 出现) + └─► mcppls 判为 std 编不过(Q1-1)──► 整项目切 kit ──► gcc 的 std.cc 用 kit 参数 → bits/stdc++.h 找不到 ──► 再重启 +``` + +### 1.3 责任划分 + +| 环节 | 归属 | 理由 | +|---|---|---| +| `.ui/.qrc/.ts` 成为 TU(`g++ … -c ui/mainwindow.ui -o …/mainwindow.ui.o`) | **mcpp**(M-1) | S1 的翻译单元是 C/C++ 编译命令;`rules-qt` 已用 `device_extensions` 声明了这些扩展名 | +| 生成物目录为空(私有目录里只有两个 0 字节文件,没有 `ui_mainwindow.h`) | **mcpp / rules-qt**(M-2) | emit 在私有目录(`$MCPP_HOME/cache/build-database/`)规划,按规范不写工程(R2.1)、不运行 action(R2.5,2026.9.27.1 起连宿主工具也不构建,记 note `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`),所以 uic 的产物永远不会出现在那里;项目自己的 `target/.build-mcpp/out/qt/ui_mainwindow.h` 是存在的。能要求 mcpp 的是**标明**这些路径是生成物,而不是去生成 | +| 缺依赖时只能拿到 inferred | **mcpp**(M-3,改进) | 离线时整份失败 | +| std 误判 → 整项目 kit | **mcppls**(Q1-1、Q1-4) | `src/engine/clangd.cpp:2001` 的 `stdFailed && kind != FailureKind::other` 把 `unresolved`(`src/engine/clangd/process.cpp:220`)也算作失败 | +| 非 C++ 条目进了引擎数据库 | **mcppls**(Q1-2,防御) | `is_cxx_source_name`(`src/project/scan.cpp:500`)已有扩展名表 | +| 生成头文件缺失没有提示,也不去找已构建好的 | **mcppls**(Q1-3) | 设计 P7;设计 2.1 对生成模块已经"去构建留下的地方找",头文件目录没有覆盖 | + +### 1.4 mcppls 的修法 + +**Q1-3 生成物目录(P0,已端到端验证)。** + +1. 数据库里的 `-I` 目录或源文件落在 mcpp 的 build-database 私有根下(`~/.mcpp/cache/build-database//target/…`, + `src/project/generated.cpp:70` 已认识这个根)时,取 `target/` 之后的相对路径,去 `<项目根>/target/` 下找; + 存在且含有被引用的文件就**只读地**替换。与设计 2.1 对生成模块的做法一致,扩展到头文件目录和生成的源文件。 +2. 都没有(从未构建)→ issue `generated-files-missing`:"`ui_mainwindow.h` 由构建规则生成,尚不存在;运行一次 + `mcpp build` 后可用",按钮"在终端运行"。只挂在引用它的文件上,只报一次。项目构建后 watch 到 `target/` 下对应文件出现, + 重新计划。 +3. 不受信任的工作区不做 1(与设计 2.1 的 untrusted 规则一致)。 +4. 这是临时对策:它依赖 mcpp 私有目录与 `target/` 的对应关系。M-2 落地后(S1 里标明生成物及其动作)改为按声明处理; + 对应关系对不上时只报 issue,不猜。 + +验证:在 qt-demo 副本上,把 emit 输出中的私有目录换成副本自己的 `target/.build-mcpp`、去掉 `.ui/.qrc/.ts` 三条,用 +`mcppls --database` 读取:`database 5 entries, 2 standard library units`,`clangd exit 0`,无任何错误 +(修前同样的 `check` 退出码 1,`ui_mainwindow.h` 找不到)。 + +**Q1-2 剔除非 C/C++ 条目。** 进入引擎数据库前按扩展名(`is_cxx_source_name` 加上 `.c`、`.m`、`.mm`)和 S1 的语言字段过滤, +剔除的计入 `left out`,日志写明 "3 entries are not C or C++ (mainwindow.ui, …); left out"。mcppls 自己的模块索引也不扫它们。 + +**Q1-1 std 回退只认"std 单元自身的编译失败"(D4′)。** + +- `unresolved`(找不到提供者)只说明 clangd 眼里的数据库没有 std 的单元。处理:核对 clangd 当前读到的那一代数据库里有没有 + std 的提供者。没有 → 代际竞态,等它读到新一代(Q1-4);有 → 按 `module-unresolved` 报在导入它的文件上,不切 kit。 +- 只有 `compile` 类失败、且失败的源文件就是 std 的单元时才切 kit。切换时**同时从数据库移除工具链的 std 单元** + (12:15:58 的 `bits/stdc++.h` 就是 gcc 的 `std.cc` 被套上 kit 参数造成的),issue 里写明原因,并提供"重试工具链的 std"。 +- 不做"按文件回退",原因见 §7 R1。 + +**Q1-4 数据库代际。** 每次写引擎数据库记一个代号;clangd 重启或重读后才算"读到了这一代"。之前报出的模块失败、扫描失败, +记日志但不触发回退、不建事故、不算入重启预算。 + +### 1.5 `windows.h` + +qt-demo 在 Linux 上三次会话都没有出现 `windows.h`。GalTranslPP 的 `Tool.ixx`、`Tool.cpp` 等在全局模块片段里 +`#include `,这类 Windows 项目在 Linux 上打开必然报这个错,属于预期(它们的依赖也只装在 Windows 上); +mcppls 应做的是 N-3:说清楚"这些单元读不了、为什么",而不是让错误连锁。如果 qt-demo 在 Windows 上也见到 +`windows.h`,仍需要那边的问题包(`mcppls.exportBundle`)才能判断。 + +## 2. 问题 2:点击函数跳到声明而不是实现 + +### 2.1 行业惯例 + +| 工具 | 默认点击 / F12 | 声明 | 在定义上再点 | +|---|---|---|---| +| LSP | `textDocument/definition`:定义(函数体) | `textDocument/declaration` | — | +| VS Code | Ctrl+Click、F12 = Go to Definition;Go to Declaration 单独命令;Ctrl+F12 = Go to Implementation(虚函数覆写) | | | +| clangd | 索引里有定义就给定义,否则给声明 | 给声明 | 在定义上 → 声明 | +| Visual Studio | F12 到 `.cpp` 里的函数体 | Ctrl+F12 | | +| Qt Creator | F2 Follow Symbol:到定义;在定义上 → 声明 | | 切换 | + +模块项目的约定:**`.cppm`/`.ixx` 相当于头文件,实现单元相当于源文件;Ctrl+Click 到实现,Go to Declaration 到接口, +在函数体上再点回到接口。** mcppls 的 `mcpp-split` 夹具已按此约定;mcppls 自己的引擎只回答模块名位置 +(`src/engine/native/index.cpp:187`),函数的定义完全取决于 clangd 的索引。问题在索引什么时候不全。 + +### 2.2 实测(Linux) + +| 场景 | 结果 | 结论 | +|---|---|---| +| 最小 hello(llvm@22 / gcc@16 / L4);分区成员、全局模块片段、`hello::add` 限定写法 | 3.4–4.5 s 首次应答即到 `.cpp`;`.cppm` 声明 ↔ `.cpp` 定义互相切换 | 正常(签名简单,碰巧对得上,见 §2.3) | +| 本仓库冷启动,只开 `src/project/model.cpp` | 15 s 内超时(clangd 在建模块);21–26 s 起四个函数全部到 `.cpp`,之后 7 分钟稳定 | 正常 | +| A:在打开着的 `math.cpp` 里写实现,保存后关闭 | 2 s 内到 `math.cpp`,关闭后仍正确 | 正常 | +| **B:未打开的 `types.cpp` 在磁盘上加了实现**(git pull、agent、外部编辑) | **一直是声明,125 s 不恢复** | 缺陷 → N-1 | +| C:新建 `extra.cpp` | 约 8 s 后可达(模型重载把它加进数据库) | 可接受 | +| **D:`greet.cpp` 包含不存在的头文件** | `bump` 始终是声明;同模块其他实现单元正常 | 缺陷 → N-3 | +| B 之后让 clangd 临时打开再关闭 `types.cpp` | 2 s 后到 `types.cpp`,80 s 内一直正确 | N-1 修法有效 | +| GalTranslPP(Linux),`ApiPool.cpp` 里的 `wide2Ascii`、`Tool.ixx` 里的声明 | 150 s 内一直为空;`Tool.cpp` 报 `bit7z/bitarchivereader.hpp` 找不到,其余单元同类(`Windows.h`、vcpkg 头文件) | 只能说明 D;Windows 需实测 | +| **mcpp 2026.9.27.1 源码**,只开 `driver.cpp`、`cmd_build.cppm`(冷启动,引擎数据库 507 条) | `phase0_…`→`state.cppm:490`、`phase4b_…`→`state.cppm:495`、`prepare_build`→`prepare.cppm:723`,420 s 不变;分片早已生成(`manifest.cpp.*.idx`、`graph.cpp.*.idx` 都在) | **缺陷 → N-7**;不是"还没轮到" | +| 同上,热启动,再打开 `manifest.cpp` | 立即到 `manifest.cpp:47`;`workspace/symbol` 对 `phase4b_graph_worklist` 返回**两条**:`state.cppm:495` 与 `graph.cpp:61` | 索引里声明与定义是两个符号 | +| 同上,打开 `manifest.cpp`、`graph.cpp` 再关闭 | 5 s 内两处都到 `.cpp`;关闭后 30/60/90/120 s 仍正确 | **N-7 修法有效** | +| 同上,`prepare_build`(主接口 `prepare.cppm` 里导出、带默认参数;定义在一直打开着的 `driver.cpp`) | 从调用处、从声明处都只到 `prepare.cppm:723`(100 s 不变);`driver.cpp` 0 条诊断;hover 在定义处显示 "provided by prepare.cppm";`workspace/symbol` 查 `prepare_build` **为空** | **另一个缺陷,O-1**:前台 AST 知道两者是同一个函数,但动态索引没记下这个符号 | +| hello8/9/11/13:分区里声明 `phase_a(State&)`、`phase_p(const Point&)`、`phase_s(const std::string&)`、`Box::f(const std::string&)`,实现单元里定义 | 全部停在声明;同一分区的 `plain_c(int)`、`Box::g(int)`(定义在无显式 import 的文件里)到 `.cpp`;加了 `:state` 分区后,原本正常的 `Point::sum`、`twice` 也开始停在声明 | 结果取决于错误恢复,不稳定 | +| hello13 的 clangd 日志(`--log-level debug`) | 16 个项目单元的后台索引全部 `Failed to compile …, index may be incomplete`;`math.cpp` 0 个符号 | 根因的直接证据 | + +B 的根因:clangd 的后台索引只在启动时(按摘要检查过期分片)和编译命令变化时重建文件;`didChangeWatchedFiles` 在 +clangd 里只用于编译数据库。mcppls 在 `Workspace::handle_watched_files`(`src/orchestrator/workspace.cpp:1827`)转发了 +事件,但 clangd 不会因此重建索引。打开的文件走动态索引,所以"在编辑器里改"是好的,"在别处改"是坏的;agent 改代码越多, +B 越常见。 + +D 的根因:`#include` 找不到是致命错误,clang 停止解析,该翻译单元不产生符号。 + +### 2.3 根因:后台索引不为模块单元准备依赖 + +clangd 23.1.0 的 `BackgroundIndex::index()`(`clang-tools-extra/clangd/index/Background.cpp:254` 起): +`buildCompilerInvocation(Inputs, IgnoreDiags)` → `prepareCompilerInstance(CI, /*Preamble=*/nullptr, …)` → +`createStaticIndexingAction`。整个过程没有 ModulesBuilder:前台打开文件时 clangd 会先构建该文件导入的模块的 BMI 并把 +`-fmodule-file=` 交给编译器,后台索引不做这一步。于是每个模块单元在后台都是"找不到导入的模块"地编译 +(`HadErrors = hasUncompilableErrorOccurred()` → 日志 `Failed to compile …, index may be incomplete`)。 + +后果按 clang 的错误恢复分三种: + +- 定义的签名和限定名都不依赖导入的东西(`int plain_c(int)` 这类):生成的 USR 与声明一致,碰巧能跳; +- 签名里有来自模块的类型、或所属的类来自模块:这些类型成了错误类型,定义生成了另一个 USR,**索引里声明和定义是两个符号**; +- 整个实现单元解析不下去:0 个符号(hello13 的 `math.cpp`)。 + +所以第 2 版"最小 hello 正常"的结论只对"签名简单、不用分区"的写法成立;拆分区、用自定义类型当参数(mcpp 的 `prepare`、 +GalTranslPP 的 `NormalJsonTranslator:TransAgent`、`DictionaryGenerator:ReviewAgent` 都是这种写法)就会稳定地跳到声明。 +打开的文件走前台路径(有 ModulesBuilder),它的动态索引是对的,而且实测关闭后仍保留(至少 120 s;clangd 重启后丢失)。 + +mcppls 自己的 WA-CLANGD-004(module hints)给每条命令加了 `-fmodule-file=<名>=/<名>.pcm`,设计上"路径从不写", +只为让 clangd 找到提供者。关掉它(`--disable-workaround WA-CLANGD-004`)结果不变,说明它不是原因;但它提供了一个上游修复之外的 +思路(N-6 探索):如果这些路径真的指向 clangd 前台构建出的 BMI(`/.cache/clangd/modules/<源>-//<名>.pcm`, +文件名与 hint 同名),后台索引就能加载模块。hello13 上手工链接后 16/17 个单元编译成功、9 个定义全部正确;但在 mcpp 源码上把 +271 个 BMI(多轮、多种命令留下的)粗暴链接过去,clangd 12 s 内崩溃——要做只能链接"当前这一代命令"构建出的那一份,且要处理 +BMI 过期,风险高,放在上游修复之后再评估。 + +上游:clangd/clangd#2569(模块内跨文件引用、重命名不可用)是同一类症状,但没有指出原因;N-6 把"后台索引缺 ModulesBuilder" +连同 hello13 的最小复现登记到 #24,并补充到上游。 + +### 2.4 冷启动:模块项目没有"优先索引" + +clangd 23.1.0(`clang-tools-extra/clangd/index/Background.cpp:178`): + +```cpp +void BackgroundIndex::boostRelated(llvm::StringRef Path) { + if (isHeaderFile(Path)) + Queue.boost(filenameWithoutExtension(Path), IndexBoostedFile); +} +``` + +`isHeaderFile`(`SourceCode.cpp:1240`)要求扩展名的驱动类型"只有预编译阶段"。`.cppm`/`.ccm` 是 `TY_CXXModule` +(有编译阶段),`.ixx` 在 `clang/lib/Driver/Types.cpp` 里没有登记(`TY_INVALID`),两者都不算头文件。结果:打开 `Tool.ixx` +或导入它的文件,`Tool.cpp` 不会被提前索引,只能等后台队列按顺序轮到。小项目几秒就排完(hello、本仓库都没感觉), +GalTranslPP 这种 227 个单元、每个实现单元都带重型全局模块片段的项目,窗口会长到几分钟,期间 Ctrl+Click 都落在 `.ixx`。 +它会放大 §2.3 的问题:本来打开一次就能补上的定义,也要等到用户真的打开那个实现单元。 + +### 2.5 V-W:GalTranslPP 的 Windows 实测(待同意) + +复用 Sunrisepeak/GalTranslPP 的临时 PR #1(`ci/mcppls-issue23-probe`,windows-2025,上次一轮约 40 分钟):探针脚本 +增加 definition 时间线——选 20 个"在 `.ixx` 声明、在 `.cpp` 定义"的调用点,从打开起每 5 s 请求一次,记录首次到 `.cpp` +的时间和一直停在 `.ixx` 的调用点;跑两轮:0.0.5 与带 N-7 的构建。调用点里要包含分区 `NormalJsonTranslator:TransAgent`、 +`DictionaryGenerator:ReviewAgent` 里声明的成员。**这会向该仓库的临时分支推送提交并触发 CI,需要你同意。** + +### 2.6 方案 + +**N-7 模块感知的索引补全(P0,替代第 2 版的 N-5、吸收 N-1 与 N-2)。** 既然后台索引建不对模块单元、前台打开能建对且关闭后保留, +mcppls 就替 clangd 把实现单元"过一遍前台":用已有的后台文档机制(`background_`,prime 单元用的那套)让 clangd `didOpen` +(磁盘文本)→ 等该文件 idle(`textDocument/clangd.fileStatus`,上限 10 s)→ `didClose`。一个队列,三级优先: + +| 级别 | 什么进队列 | 何时 | +|---|---|---| +| 按需(原 N-2) | definition 只回答一个位置、落在接口单元(含分区)里、且与 declaration 的回答相同时,该声明所在模块的实现单元 | 请求到来时;限时 1.5 s 后重问一次,仍是声明就原样返回(有 N-3 标记时附原因) | +| 相关(原 N-5、N-1) | 编辑器打开的文件所在模块、及它直接导入的模块的实现单元(不递归,单次最多 16 个);watch 到磁盘变化、编辑器没打开、摘要确实变了的源文件 | 打开文件、磁盘变化时 | +| 其余 | 数据库里其余的模块实现单元(`module X;`、实现分区) | clangd 空闲、没有前台请求时,逐个 | + +- 并发最多 2;已经过一遍且摘要未变的跳过;解析失败(致命错误)的记给 N-3,不重试直到它的输入变化。 +- clangd 重启(计划、恢复、崩溃)后动态索引丢失,"相关"级立即重做,"其余"级在空闲时重做。 +- 单次 watch 事件超过 20 个文件(git checkout、rebase;D3)时,这些文件只进"其余"级,不插队。 +- 成本:每个单元一次前台 AST 构建(mcpp 源码上两个实现单元一起 5 s)。"其余"级只针对实现单元:mcpp 源码的引擎数据库 + 507 条里,接口单元 181、实现单元 16(`prepare` 拆出来的)、导入模块的普通单元 310,所以要补的只有 16 个;实现单元多的项目 + (GalTranslPP 每个 `.ixx` 配一个 `.cpp`)按 V-W 的实测定并发与上限。 +- 范围:N-7 修的是**跳到定义**。同一根因也让 310 个导入模块的普通单元在后台索引里残缺,跨文件的 Find References、 + Call Hierarchy、Rename 只能看到打开过的文件(clangd/clangd#2569 的症状)。把这些单元也过一遍前台代价大,不在 N-7 内, + 留给 N-6(上游修复或 BMI 方案)。 +- 不改编译命令去"骗" clangd 重建(会重建 BMI);不为了索引重启 clangd。 +- 上游修好(N-6)或 N-6 的 BMI 方案成熟后,"其余"级可以关掉,按需与相关两级仍保留(它们也解决 `.ixx`/`.cppm` 不被优先索引的问题)。 + +**N-3 解析不了的实现单元进状态(P1)。** clangd 报出的致命错误(`fatal error: … file not found`、模块扫描失败)落在实现单元上时, +记为 `implementation-unreadable`:状态栏写"1 个实现单元无法读取:`greet.cpp`(`ui_generated.h` 找不到),其中的定义不可跳转"; +是生成物时与 Q1-3 合并。 + +**文档。** 用户文档加一段约定:Ctrl+Click / F12 到实现,Go to Declaration 到接口,Peek 同时看两边;"全 `.cppm`"写法的项目, +定义本来就在接口里。 + +## 3. 问题 3:构建工具无感探测的抽象层 + +### 3.1 现状与差距 + +| 现状(0.0.5) | 位置 | 差距 | +|---|---|---| +| `detect_project` 固定顺序:配置的数据库 > `mcpp.toml` > `CMakeLists.txt` > `compile_commands.json` > inferred | `src/project/detect.cpp` | 每加一个工具要改多处 switch;不认 xmake、meson | +| mcpp:离线 emit(`MCPP_OFFLINE=1`),需要下载时只有 "Run in Terminal" | `src/project/mcpp.cpp:327`,`src/orchestrator/workspace.cpp:1524` | 没有"同意后由 mcppls 后台联网";联网只能把 `mcppls.buildTool` 设成 `online`(永久、全局) | +| CMake:有构建目录就读;否则在私有目录配置,首次允许下载(BD7) | `src/project/cmake.cpp:163` | 与 D1 冲突;不读 `CMakePresets.json`,私有配置可能选到和用户不同的编译器 | +| 没有模型时等 producer 10 s(BD5) | `src/orchestrator/workspace.cpp` | D5:2.5 s | +| 离线只靠 `MCPP_OFFLINE` 这个约定的环境变量 | `modules/platform/src/toolrun.cpp:81` | 每个工具需要自己的离线配方 | +| 无感探测没有总开关;`mcppls.buildTool = off` 只管"不执行",已有产物照读 | — | B-7 | + +### 3.2 抽象:`BuildSystemProvider` + +```cpp +// 只看文件、不执行:< 50 ms。多个提供者都认领时取 confidence 最高的(mcpp.toml 与 CMakeLists.txt 并存等)。 +struct Claim { std::string provider; int confidence; std::string manifest; std::vector markers; }; + +enum class Outcome { ok, partial, needs_download, failed, timed_out }; +struct Answer { + Outcome outcome; + std::optional database; // ok / partial + std::vector missing; // needs_download:缺什么(包名、FetchContent 名) + std::string terminalCommand; // 用户在终端里自己跑的等价命令 + std::string reason; // failed:构建工具自己的诊断 +}; + +class BuildSystemProvider { +public: + virtual std::string_view id() const = 0; // "mcpp" "cmake" "xmake" "meson" "compile-commands" + virtual std::optional detect(std::string_view root) const = 0; // 纯文件系统 + virtual std::optional existing(const Claim&) const = 0; // 读已有产物,不执行 + virtual Answer describe(const Claim&, const DescribeContext&) const = 0; // 执行构建工具;context 说明是否离线 + virtual std::vector watch_inputs(const Claim&) const = 0; // 触发重新描述的文件 + virtual std::vector fingerprint(const Claim&) const = 0; // 模型缓存的输入 +}; +``` + +`DescribeContext` 沿用 `ProviderContext`(trusted、runner、offline、soft/hard 时限、`onSlow`),另加 `privateDirectory` +(每个工作区、每个提供者一份,在 mcppls 缓存目录下)。inferred 不是提供者,是所有提供者都不可用时的兜底。 + +**副作用契约(每个提供者都要满足,conformance 的 `workspace-unchanged` 覆盖全部):** + +1. 不写工作区,所有状态在 `privateDirectory`。 +2. 离线配方由提供者给出(环境变量、参数),`toolrun` 统一记录 offline、网络观察、耗时、stderr 尾部。 +3. 失败分类:`needs_download`(可以询问)、`failed`(构建脚本错,报构建工具原话)、`timed_out`。 +4. 不交互:离线阶段绝不从标准输入读确认。 + +### 3.3 各工具的实现要点 + +| 提供者 | detect | existing(不执行) | describe 离线 | 同意后联网 | 实测 | +|---|---|---|---|---|---| +| **mcpp** | `mcpp.toml` | — | `mcpp emit build-database --format json`,`MCPP_OFFLINE=1` | 同一命令去掉离线 | qt-demo:2026.9.26.2 0.6 s,2026.9.27.1 0.25 s | +| **CMake** | `CMakeLists.txt`(+ `CMakePresets.json`) | `build*/`、`out/build/*`、`cmake-build-*`、preset 的 `binaryDir` 里的 `build_database.json` / `compile_commands.json` | 私有目录配置,`-DFETCHCONTENT_FULLY_DISCONNECTED=ON`;有 preset 时跟随其 generator、toolchainFile、cacheVariables | 私有目录去掉 `FULLY_DISCONNECTED` 重新配置,之后回到离线 | 缺 fmt:5.5 s 失败,消息 `FETCHCONTENT_FULLY_DISCONNECTED … requires the source directory … populated`,项目目录无变化 | +| **xmake** | `xmake.lua` | 项目根或 `.vscode/` 下的 `compile_commands.json` | 见 3.4 | 去掉 `fetch_only` 与 `network.mode:private`,加 `-y` | 见 3.4 | +| **meson** | `meson.build` | `builddir/`、`build/` 下的 `compile_commands.json` | 私有目录 `meson setup --wrap-mode=nodownload` | 去掉 `nodownload` | 未测 | +| **compile-commands** | `compile_commands.json` / `build/compile_commands.json` | 就是它 | — | — | — | + +CMake 跟随 preset 很重要:私有配置不跟随 preset 时,可能选到另一个编译器,给出的语义和用户实际构建的对不上。 + +### 3.4 mcpp 2026.9.27.1 对照 + +| mcpp 的事实 | 来源 | 对本方案的影响 | +|---|---|---| +| emit 不写工程、在 `$MCPP_HOME/cache/build-database/` 规划;运行构建程序,但不运行 action;不构建宿主工具,库中没有的记 note `MCPP_BUILD_DATABASE_HOST_TOOL_DEFERRED`(#707) | mcpp `docs/specs/build-database.md` R2.1–R2.5 | 副作用契约对 mcpp 天然成立;生成物缺失是常态而非异常(Q1-3、M-2);被推迟的宿主工具产生的文件也属于"生成物缺失",B-8 把这条 note 并进 `generated-files-missing` 的说明 | +| 信封声明副作用:`effects` = `read-project`、`write-global-cache`、`exec-build-script`(`--protocol-version` 同样声明,R2.6) | qt-demo 实测信封 | 提供者契约直接读它:出现 `write-project` 视为违约(记事故、不用该结果);`exec-build-script` 表示会运行项目自己的 `build.mcpp`,只在受信任工作区执行(与现状一致) | +| 缺依赖的稳定代码 `MCPP_OFFLINE_DOWNLOAD_REQUIRED`(e2e 735) | mcpp 测试 | mcppls 已按代码识别(`src/spec/discovery.cpp:91`),旧版本的文字匹配保留作兼容 | +| 按成员规划,失败成员给 `error` 诊断、其余成员照常给 `data`(2026.9.26.2 起,#699) | CHANGELOG | **缺口 B-8**:mcppls 只在"整份失败"时识别需要下载;部分回答里某成员的错误是 `MCPP_OFFLINE_DOWNLOAD_REQUIRED` 时只报 `producer-partial`(`src/project/mcpp.cpp:312`),不会询问。B-8:用已规划的部分,同时按 §3.6 询问 | +| 工作空间成员继承根的 `[xlings.workspace]`(#713、#714) | CHANGELOG | GalTranslPP 这种工作空间,成员现在也会因为根声明的包未安装而需要下载,走部分回答 + B-8 | +| 离线时"记录为已安装但载荷已删除"的包被拒绝并点名(#712、#716) | CHANGELOG | 同属 `needs_download`;询问文字用 mcpp 自己的消息 | + +### 3.5 xmake:专门的 CDB 命令不编译,但仍需两处隔离 + +`xmake project -k compile_commands ` 是 xmake 专门的编译数据库生成命令,**不编译**。实测它做两件事: +隐式配置(工具链探测、flag 检查,冷 5.4 s、热 3.9 s)和 **C++ 模块依赖扫描**(对每个模块文件调用 g++ 做扫描,结果写进 +`build/.gens/…/rules/bmi/cache/scans/*.json` 和 `build/.deps/…/*.d`)。所以: + +1. **不加隔离会写项目**:只跑 `xmake project -k compile_commands` 时,项目里多出 `build/`(`.gens`、`.deps`,已实测); + 不设 `XMAKE_CONFIGDIR` 时配置写进项目的 `.xmake/`(xmake 的默认行为,本次实测都设了私有目录)。 + `xmake project` 没有 builddir 参数,builddir 只能由 `xmake f` 设定,所以首次需要两步: + ``` + XMAKE_CONFIGDIR=/config xmake f -c --confirm=no \ + --policies=package.fetch_only,network.mode:private --builddir=/build + XMAKE_CONFIGDIR=/config xmake project -k compile_commands /out + ``` + 之后只跑第二条(配置已缓存在私有目录)。实测两步 5–7 s、只跑第二条 3.9 s,项目目录无变化。 +2. **离线要显式关网络**:只加 `package.fetch_only` 时,本机仓库未拉取过,xmake 先 `updating repositories`(联网 11 s、写 + `~/.xmake`)。xmake 源码(`modules/private/action/require/impl/repository.lua` 的 `pulled()`)在 + `network.mode` 策略为 `private` 时跳过仓库更新;加上 `network.mode:private` 后实测不再联网,缺包时 5.8 s 内报 + `The packages(xxhash) not found`,归为 `needs_download`。 +3. 生成的命令带 `-fmodule-mapper=/tmp/.xmake…/*.mapper.txt`(gcc),这些是 BMI 参数,mcppls 的规范化(P3)本就会去掉; + 模块角色由扫描恢复,层级为 L3。 + +耗时超过 2.5 s 的时限属于正常,按 D5 先上 L4、拿到后切换。 + +### 3.6 状态机与交互 + +``` +打开工作区 + │ buildDiscovery = off? → 只用 mcppls.database(若配置),否则 L4;结束 + │ 各提供者 detect(纯文件,< 50 ms),按 buildDiscovery.providers 过滤 + ├─ 有模型缓存 → 立即用(BD4),后台离线确认 + ├─ existing 有产物 → 立即用 + └─ 离线 describe,最多等 2.5 s(D5) + ├─ 在时限内成功 → 用它 + ├─ 超时限 → L4 先上(状态栏"正在读取构建描述(cmake,4 s)"),继续等到 hard 上限,成功后切换 + ├─ failed → L4 + 构建工具原话(现有 model-fallback) + ├─ needs_download → L4 + 询问(askBeforeDownload = false 时只进状态栏) + └─ partial 且其中有成员 needs_download → 用已规划的部分 + 询问(B-8) + +询问(非模态通知;每个工作区只问一次,除非缺的东西变了): + "qt-demo 需要下载依赖才能拿到完整的构建信息(xim:qt-base@6.11.1)。现在先按源码扫描提供基础功能。" + [下载并继续] [在终端运行] [不再询问] + ├─ 下载并继续 → 后台联网 describe(Network::allowed,hard 10 min,workDoneProgress,可取消) + │ ├─ 成功 → 一次计划内 clangd 重启(不计入重启预算,RD6),打开的文档保持;之后回到离线 + │ └─ 失败/取消 → 保持 L4,issue 是构建工具原话,按钮"在终端运行" + ├─ 在终端运行 → 现有 mcppls.runBuildToolInTerminal;watch 到输入变化后离线重试 + └─ 不再询问 → 记在工作区状态;状态栏仍显示 producer-needs-download +``` + +要点: + +- **同意是一次性的、只对这个工作区(D2)**;不改 `mcppls.buildTool`。 +- **L4 的质量**:qt-demo 这种依赖大型 SDK 的项目,L4 拿不到 Qt 头文件,"临时可用"只对模块本身成立;M-3(离线时返回部分数据库) + 落地后,等待期间就有已安装依赖的全部语义(mcppls 已支持 `producer-partial`)。 +- **RD1 的例外写明**:RD1 是为"producer 慢但会成功"设的。按 D5,超过 2.5 s 就 L4 先上,拿到构建模型后切换一次; + 离线失败或需要下载时同样 L4 先上。RD1 改写为"构建模型到达后只切换一次,切换不计入重启预算"。 +- **CMake 联网获取的代价**:FetchContent 下载到私有目录,与用户自己的构建目录各下一份;在询问文字里写明。 + +### 3.7 开关(B-7) + +| 设置 / 参数 | 取值 | 作用 | +|---|---|---| +| `mcppls.buildDiscovery` / `--build-discovery` | `auto`(默认)、`off` | `off`:不探测构建系统、不读已有产物、不执行、不询问;只用显式配置的 `mcppls.database`,否则 L4 | +| `mcppls.buildDiscovery.providers` | 数组,默认 `["mcpp", "cmake", "xmake", "meson", "compile-commands"]` | 从中去掉某个提供者即停用它(例如只想用 CMake 已有的构建目录、不要 xmake) | +| `mcppls.buildDiscovery.askBeforeDownload` | `true`(默认)、`false` | `false`:需要下载时只在状态栏说明,不弹询问 | +| `mcppls.buildTool`(已有) | `offline`(默认)、`online`、`off` | 不变:管"构建工具怎么执行"。`off` 仍会探测、仍读已有产物,只是不执行;与 `buildDiscovery = off` 的区别写进设置说明 | + +不受信任的工作区等同 `buildDiscovery = off`(与现在一致)。设置变化时重新加载模型(`extension.ts` 已对 `mcppls.buildTool` +这样处理)。服务端参数、VS Code 设置、Zed/CLion 的 initializationOptions 三处同步;`cxxModules/status` 的 `project` +字段报告当前取值(S3 需加字段,见 §7 R6)。 + +### 3.8 编辑器插件侧 + +- VS Code:`cxxModules/status` 里出现 `producer-needs-download` 且带 `askOnline: true` 时弹通知(每工作区一次),按钮调用 + 服务端命令 `mcppls.describeOnline`(参数为工作区根)。进度走 LSP 的 `window/workDoneProgress`,不依赖插件。 +- Zed、CLion:没有通知按钮时,issue 文本里给出等价命令;`mcppls.describeOnline` 也能从命令面板触发。 + +## 4. 与现有设计的关系 + +| 设计条目 | 变化 | +|---|---| +| BD5 | 10 s → 2.5 s(D5) | +| BD7 | 删除:CMake 首次配置也离线(D1) | +| RD1 | 改写为"构建模型到达后只切换一次,不计入重启预算" | +| P1(故障只影响它所在的地方) | Q1-1/Q1-4 修掉一个违反点 | +| 设计 2.1 "生成模块去构建留下的地方找" | 扩展到生成的头文件目录和源文件(Q1-3),长期保留 | +| 设计 2(引擎抽象:clangd 负责 C++ 语义) | 新增:mcppls 负责让 clangd 的索引覆盖模块实现单元(N-7),直到上游后台索引支持模块 | +| WA-CLANGD-004(module hints) | 不变;登记"hint 路径指向真实 BMI"作为 N-6 的探索方向 | +| 新增 | `BuildSystemProvider` 与副作用契约;`mcppls.buildDiscovery` | + +## 5. 验证计划 + +| 夹具 / 检查 | 覆盖 | 类型 | +|---|---|---| +| `mcpp-rules-generated`(新) | mock mcpp 给出 M-1/M-2 形状的 S1:非 C++ 条目剔除且计入 left out;生成物目录缺失时 `generated-files-missing`;项目 `target/` 有生成物时被只读使用、`main.cpp` 零诊断;不回退 kit;工作区不变 | conformance(mock) | +| `std-generation-race`(新) | 数据库加入 std 单元后到 clangd 重读前报的 `unresolved` 不触发 `std-fallback-kit` | conformance(mock) | +| `mcpp-partition-definition`(新,N-7) | hello13 的形状:分区里声明、签名含类类型的函数与成员,定义在实现单元;只打开调用方,definition 在 10 s 内到 `.cpp`(修前停在声明);clangd 重启后同样 | conformance(真 mcpp) | +| `mcpp-split` 加三项 | B:未打开的实现单元在磁盘上加定义,10 s 内到 `.cpp`;C:新建实现单元;D:解析不了的单元给 `implementation-unreadable` | conformance(真 mcpp) | +| `mcpp-emit-partial-download`(新,B-8) | mock mcpp 的部分回答里一个成员报 `MCPP_OFFLINE_DOWNLOAD_REQUIRED`:其余成员被使用,状态带询问(`askOnline`) | conformance(mock) | +| mcpp 源码 | 实机:`driver.cpp` 的 `phase0_…`、`phase4b_…` 与 `cmd_build.cppm` 的 `prepare_build` 冷启动后到 `.cpp` | 手工 | +| `cmake-fetchcontent-offline`(新) | 首次配置离线、缺依赖 → `producer-needs-download`,工作区不变;`mcppls.describeOnline` 在私有目录完成(CI 用本地 git 仓库代替网络) | conformance | +| `xmake-modules`(新,B-5) | 私有配置目录与 builddir、工作区不变、`network.mode:private` 下缺包归为 `needs_download` | conformance | +| `build-discovery-off`(新,B-7) | `off` 时不执行任何程序、不读已有 `compile_commands.json`、状态为 L4 | conformance | +| qt-demo 实机 | 修后:`main.cpp` 零诊断(已构建过时),或只有一条 `generated-files-missing`(未构建时);没有 `std-fallback-kit`;没有非 C++ 条目的日志 | 手工 | +| V-W | GalTranslPP Windows:修前 / 修后首次到 `.cpp` 的时间与停在 `.ixx` 的调用点数 | CI(待同意) | + +## 6. 待决策 + +| # | 问题 | 建议 | +|---|---|---| +| D3 | N-1 批量变化的阈值 | 单次 > 20 个文件时改为空闲时计划重启(先实测重启后的分片重建) | +| D4′ | kit 回退的范围(修正版) | 只在 std 单元自身编译失败时整体切换,同时移除工具链 std 单元,提供"重试工具链的 std";不做按文件回退 | +| D6 | Q1-3 读取项目 `target/` 里的生成物 | 只在受信任的工作区、只读 | +| D7 | xmake / meson 优先级 | xmake 提到 P1(离线配方已验证);meson P3 | +| D8 | V-W 是否推送到 GalTranslPP 的临时分支跑 Windows CI | 建议跑,结果决定 N-7 的并发与上限 | +| D9 | N-7 的"其余"级(空闲时把所有实现单元过一遍前台)是否默认开启 | 开启;实现单元多时由 V-W 定上限;提供 `mcppls.index.primeImplementationUnits`(`auto`/`off`)关掉 | +| D10 | N-6 的 BMI 方案是否在 0.0.7 做 | 不做;先登记上游,等上游态度与 N-7 的实际开销再定 | + +待查: + +| # | 问题 | 下一步 | +|---|---|---| +| O-1 | mcpp 的 `prepare_build`:主接口导出、默认参数、定义在实现单元,定义文件打开时索引里也没有这个符号(`workspace/symbol` 为空),跳转只到声明 | 缩成最小复现(主接口导出函数 + 实现单元定义 + 实现单元 `import :partition`);看 clangd 的 SymbolCollector 对"规范声明来自 BMI"的符号是否跳过;结果并入 N-6 的上游登记。0.0.6 内完成定位,修法另定 | + +## 7. 自我 review + +| # | 问题 | 处理 | +|---|---|---| +| R1 | **第 1 版的 D4"tier 1/2 只按文件回退"做不到。** 一个 clangd 的数据库里,模块名到提供者只能有一个映射;让一部分文件用 kit 的 `std`、另一部分用 gcc 的 `std`,就要同时放两个 `std` 提供者,clangd 只会取其一,另一半文件静默地用错 std | 改为 D4′:整体切换只在确认 std 自身编译失败时发生,并移除工具链 std 单元。**需要你再确认** | +| R2 | 第 1 版把 Q1-3 放在 P1,但 16:29 的会话证明它是 qt-demo 唯一持续的问题 | 升为 P0,并做了端到端验证 | +| R3 | Q1-3 依赖 mcpp 私有目录与 `target/` 的路径对应,属于耦合 mcpp 的实现细节 | 写明为临时对策;对应不上时只报 issue、不猜;M-2 落地后按 S1 声明处理 | +| R4 | N-7 的临时打开会让 clangd 为该单元构建模块依赖,大项目里每个几秒 CPU | 并发上限 2、相关级单次上限 16、已过一遍且未变化的跳过;V-W 用实测数据定上限 | +| R5 | 第 1 版的 xmake 结论("私有 XMAKE_CONFIGDIR + --builddir")没说清楚为什么需要配置步骤,且离线配方有漏洞(仍联网更新仓库) | §3.4 重写:CDB 命令本身不编译,隔离是因为模块扫描写 builddir;离线配方补上 `network.mode:private` 并验证 | +| R6 | B-2 的 `askOnline`、B-7 的新设置会改变 S3(`cxxModules/status` 的 issue 与 `project` 字段) | 按规范流程改 S3、更新 `conformance/traceability.json`,和实现同一个 PR | +| R7 | D5(2.5 s 后 L4 先上)意味着慢一点的 producer(2.5–60 s)会多一次切换重启,L4 期间的索引白做 | 接受(用户已定);切换不计入重启预算;有模型缓存时不受影响(缓存优先) | +| R8 | `buildDiscovery = off` 与 `buildTool = off` 容易混淆 | 设置说明里并列写出两者的差别;`off` 优先 | +| R9 | 第 1 版把 `windows.h` 推测为 std 误判导致 | 撤回推测:Linux 三次会话均无;GalTranslPP 这类 Windows 项目在 Linux 上缺 `Windows.h` 属预期 | +| R10 | N-2 依赖"definition 与 declaration 相同"来判断"只知道声明",会多一次 declaration 请求 | 只在 definition 结果落在接口单元时才发这一次;有 N-7 的相关级之后命中率应该很低,保留作为兜底 | +| R11 | **第 2 版对问题 2 的判断错了。** 它说"路由正确、hello 正常,问题只在索引没轮到",实际是后台索引对模块单元建错;hello 正常是因为签名简单、碰巧对得上(只开 `main.cpp` 的 hello7 也全对,所以不是探测方式的问题;但第 2 版有几组探测同时打开了实现文件,那几组本来就不能说明后台索引) | §2.3 重写,N-7 取代 N-5;新增按"分区 + 类类型参数"的最小复现与夹具 | +| R12 | 第 3 版一度用"直接跑 clangd、不经 mcppls"做对照,想证明是纯上游问题;但那组对照里连 `Point::sum`、`twice` 也停在声明(去掉 BMI 参数后后台索引同样失败,且更糟),不是干净对照 | 不用它下结论;根因以 clangd 源码 + mcppls 下的 debug 日志为准 | +| R13 | 第 2 版向 mcpp 要"emit 生成出生成物"(M-2),与 mcpp 规范 R2.1/R2.5(不写工程、不运行 action)冲突,mcpp 不会接受 | M-2 改为"在 S1 里标明生成物";Q1-3 不再是临时对策,长期保留 | +| R14 | 部分回答 + 需要下载的组合没有覆盖(B-8);mcppls 丢弃 note 级诊断,`HOST_TOOL_DEFERRED` 看不到 | 新增 B-8 | +| R15 | N-7 靠"关闭后动态索引仍保留"这一观察(最长验证 120 s),不是 clangd 的承诺 | 夹具里检查关闭后 10 分钟仍正确;clangd 重启后重做;若某版 clangd 关闭即丢,改为保持打开(占内存,设上限) | +| R16 | N-7 只修跳到定义;Find References、Call Hierarchy、Rename 同样受后台索引缺陷影响 | 写明范围(§2.6),留给 N-6;状态或文档里说明"跨文件引用只覆盖打开过的文件" | +| R17 | 本版开始时 mcpp 2026.9.27.1 尚未发布,复测用的是本机从 b439fd97 构建的二进制;会话后段本机的 `mcpp` 已是 2026.9.27.1,fresh 副本上的复测用的是它 | 结论不变;发布版上的 mcpp 源码跳转实测留到 V-W 一起重跑 | +| R19 | 复测时在 qt-demo 原项目里跑了 2026.9.27.1 的 emit,它改写了 `qt-demo/.mcpp/.xlings.json`(mtime 17:28),而当时只比对了项目根目录的列表,没发现 | 已告知;这也是 mcpp 的缺陷(emit 写工程,违反 R2.1),列在 mcpp#724 的附带发现里。之后的实测只在副本上跑 | +| R18 | 第 3 版一开始把 N-7 写成"在 mcpp 源码上验证有效",实际三个探测点只验证了两个,第三个(`prepare_build`)另有原因 | 改为"三处中的两处",新增 O-1;N-7 的夹具只断言已验证的形状 | + +## 8. 附:复现 + +- qt-demo:在项目目录 `mcppls --payload check src/main.cpp`;`mcpp emit build-database` 看 M-1/M-2 的原始输出; + 修法验证:emit 输出中私有目录替换为 `<副本>/target/.build-mcpp`、去掉 `.ui/.qrc/.ts` 后 `mcppls --database db.json check src/main.cpp`。 +- 跳转:hello 系列在会话临时目录,用自写 LSP 客户端对 `mcppls serve` 每秒请求一次 definition;B = 启动后改写未打开的 + `types.cpp` 并发 `didChangeWatchedFiles`;D = 在 `greet.cpp` 的全局模块片段里 `#include "ui_generated.h"`。N-4 会把它们 + 固化进 `mcpp-split` 夹具。 +- clangd 源码:`llvm-project-23.1.0.src.tar.xz`(`.payload-cache/`)中的 `clangd/index/Background.cpp:178`、 + `clangd/SourceCode.cpp:1240`、`clang/lib/Driver/Types.cpp`。 +- CMake:`FetchContent_Declare(fmt …)` + `-G Ninja -DFETCHCONTENT_FULLY_DISCONNECTED=ON`,私有构建目录。 +- xmake:见 §3.4 的两条命令;对照组为不加 `network.mode:private`(出现 `updating repositories`)和不加 `--builddir`(出现项目 `build/`)。 +- 后台索引根因:hello13 = hello2 + 分区 `:state`(`struct State`、`int phase_a(State&)`、`int phase_p(const Point&)`、 + `std::string phase_s(const std::string&)`、`struct Box { int f(const std::string&); int g(int); }`),定义分在 + `phase_a.cpp`、`phase_b.cpp`、`box.cpp`;只打开 `main.cpp`、`driver.cpp`,`mcppls --log-level debug` 的日志里看 + `Failed to compile …, index may be incomplete`。 +- mcpp 源码:`b439fd97` 检出到临时目录、`mcpp build`,`mcppls serve --mcpp <新 mcpp>`,只打开 `src/build/prepare/driver.cpp` + 与 `src/cli/cmd_build.cppm`;再临时打开 / 关闭 `manifest.cpp`、`graph.cpp` 验证 N-7。 +- clangd 源码:`clangd/index/Background.cpp:254` 起的 `BackgroundIndex::index()`(无 ModulesBuilder)。 + +## 9. 0.0.6 实施计划(单 PR) + +决定(2026-09-27):D1、D2、D4′、D5、D8、D9 同意;D10 改为"上游缺陷凡是 mcppls 侧能做的,直接在 mcppls 侧实现"(混合引擎: +mcppls 自己的引擎做一部分,clangd 做一部分),同时登记上游。另加一项:**把 mcppls 的全部可配置项收拢成一个配置模块**,并配一章文档。 +全部进 0.0.6,一个 PR。 + +### 9.1 实施时的新发现(改变了第 3 版的做法) + +- mcppls **已有**"按需找定义"(`search_definition_`,`src/engine/clangd.cpp`):clangd 只给出接口里的声明时,打开该模块的其他单元再问一次。 + 它在 mcpp 源码上失败,是因为只打开 4 个单元(`UNITS_PER_SEARCH`),且只按文件名 stem 和目录排序,不看名字定义在哪:`phase0_…` + 声明在 `state.cppm`,模块 `mcpp.build.prepare` 有 17 个其他单元,挑中的 4 个里没有 `manifest.cpp`。所以 N-8 不是另起一套, + 而是让选单元这一步**按名字**(先词法扫描哪些单元里有这个名字的定义),并在 clangd 仍对不上时(O-1)直接给出词法找到的位置。 +- mcpp 2026.9.27.1 构建不了本仓库(mcpp#725,已提):CI 固定 2026.9.26.1,不受影响;nightly 会先遇到。本 PR 不改清单,等 mcpp 修复; + 在 #24 登记 UP-M。 + +### 9.2 用户体验的硬规则(用户要求,2026-09-27) + +1. **插件侧的提示一律非阻塞**:VS Code 右下角的通知,用户可以一直不点;不用模态框,不在激活、启动或任何请求的路径上 `await` 它。 +2. **提示不影响 mcppls 的后续功能**:弹出询问时,L4 已经在工作;用户点不点、什么时候点,都只影响"是否联网获取"这一件事。 +3. **自动分级**:L4 → L3 → L1/L2 的升级都是自动的,不需要用户操作;更好的模型到达时计划内切换一次(不计入重启预算)。 +4. **环境自己变好也要接得住**:用户在终端里自己跑了构建、装好了依赖(`mcpp build`、`xlings install`、`cmake`),mcppls 通过监视构建输入与 + 后台退避重试(需要下载时 30 s、1 min、2 min、5 min,之后每 5 min;监视到的输入变化立即重试)发现环境完善,自动升级; + 此时尚未点的询问失效(插件收到新状态后撤回或忽略),不再重复弹出。 +5. 同一件事每个工作区只问一次(D2);"不再询问"记在工作区状态里;缺的东西变了才会再问。 + +### 9.3 任务与依赖 + +| # | 任务 | 依赖 | 角度 | +|---|---|---|---| +| T0 | 分支 `release/0.0.6`;本计划 | — | — | +| **T1 配置模块** `src/config/settings.cppm`(`mcppls.config.settings`):每个设置一行——键、类型、取值、默认、命令行拼写、所在位置(服务端 / 客户端)、生效方式(重载模型 / 重启)、起始版本、说明、旧名(升级映射);解析 命令行 > initializationOptions > 默认,未知取值回落默认并记为问题;`mcppls settings [--format markdown\|json]` 输出参考表;`report` 带 `settings`(生效值、来源、问题) | T0 | 架构、一致性、无感升级 | +| T1a | 命令行全局选项、`session_options`、`handle_initialize_` 都改为从 T1 读;`workspace/didChangeConfiguration` 对"重载模型"类设置直接生效 | T1 | 架构、优雅 | +| T1b | 文档 `docs/30-settings.md`(及 zh-CN)的参考表由 `mcppls settings --format markdown` 生成;单元测试比对注册表与文档、VS Code `package.json` 三者一致 | T1 | 一致性 | +| T2 | Q1-2 剔除非 C/C++ 条目;Q1-3 生成物目录(项目 `target/` 只读替换,`generated-files-missing`,监视生成物出现) | T0 | 稳定性、体验 | +| T3 | Q1-1 + Q1-4:std 回退只认 std 单元自身编译失败;数据库代际;回退时移除工具链 std 单元 | T0 | 稳定性 | +| T4 | N-8:选单元按名字(词法定义扫描,含分区与实现分区);clangd 仍只给声明时返回词法定位;`UNITS_PER_SEARCH` 以名字命中为准 | T0 | 体验、架构(混合引擎) | +| T5 | N-7:相关级(打开文件时,其模块与直接导入模块的实现单元)、磁盘变化级(未打开且摘要变了)、其余级(空闲时);设置 `index.primeImplementationUnits` | T4、T1 | 体验、稳定性(并发与上限) | +| T6 | N-3:`implementation-unreadable` | T5 | 体验 | +| T7 | B-1:`BuildSystemProvider` 注册表;mcpp / CMake / compile-commands 平移 | T0 | 架构 | +| T8 | B-3 CMake:首次配置离线(`FETCHCONTENT_FULLY_DISCONNECTED`)、预设(`CMakePresets.json` 的 `binaryDir` 与配置);B-5 xmake;B-6 meson | T7 | 跨平台、兼容性 | +| T9 | B-2 + B-8 + B-4:需要下载时询问(状态里 `askOnline`、服务端命令 `mcppls.describeOnline`、一次性联网)、部分回答 + 需要下载、首个模型等 2.5 s、需要下载时退避重试 | T7、T1 | 体验(§9.2) | +| T10 | B-7:`buildDiscovery`、`buildDiscovery.providers`、`buildDiscovery.askBeforeDownload` | T1、T7 | 体验 | +| T11 | VS Code:非阻塞询问、"不再询问"、新设置;Zed/CLion 的 initializationOptions 与文档 | T1、T9 | 体验、跨平台 | +| T12 | 规范:S3(issue 的 `askOnline`、`project.settings`、新 issue 码)+ schema/traceability;设计记录(BD5、BD7、RD1、新决定);`docs/20-projects.md`;CHANGELOG;#24(UP-M:mcpp#724、#725;UP:后台索引与模块) | 全部 | 一致性 | +| T13 | 验证:单元测试;夹具 `mcpp-rules-generated`、`std-generation-race`、`mcpp-partition-definition`、`mcpp-split`(磁盘改动)、`mcpp-emit-partial-download`、`cmake-fetchcontent-offline`、`build-discovery-off`;xmake 夹具(Linux);三平台 CI | 全部 | 稳定性、跨平台 | +| T14 | 版本 0.0.6、PR、CI 全绿、自我 review、squash 合入、Release、本地验证、交付目录 | T13 | — | + +并行:T1、T2+T3、T4、T7+T8 互不依赖,可同时进行;T5/T6 在 T4 之后,T9/T10/T11 在 T1 与 T7 之后。 + +### 9.4 各角度的检查项 + +| 角度 | 要求 | +|---|---| +| 架构 | 配置只有一个来源(T1);构建工具只经 `BuildSystemProvider`;上游缺陷的补偿都是登记过的 WA 或混合引擎的一部分 | +| 稳定性 | 新的后台打开都走现有 `background_` 的上限与超时;不为索引重启 clangd;代际检查避免竞态误判 | +| 优雅简洁 | 复用 `search_definition_`、`background_`、`toolrun`、现有 issue 通道;不引入新进程 | +| 用户体验 | §9.2 五条;状态栏说清楚层级与原因 | +| 兼容性 | 旧设置名继续被接受(注册表的旧名映射);旧客户端不认识 `askOnline` 也能工作;mcpp 旧版本的文字识别保留 | +| 跨平台 | 新代码不含平台分支(平台差异只在 `modules/os`);xmake/meson/CMake 的参数在 Windows 上同样成立;三平台 CI | +| 一致性 | 设置表、文档、`package.json` 由测试互相校验;S3 规则有 traceability | +| 无感升级 | 模型缓存格式不变;设置默认值保持旧行为(除 D1、D5 这两处已定的变化);升级后首次启动不需要用户操作 | From 74852119f5ae1e106af276f428a5c1c80e62b240 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:12:14 +0800 Subject: [PATCH 02/25] fix(clangd): the standard library moves to the kit only when its unit does not compile or does not exist qt-demo lost its toolchain semantics to a race: std.cc joined the engine database and 78 ms later clangd, still on the database it had read before, reported "Don't get the module unit for module std". That was taken as the standard library failing, the whole project was read with libc++ and clangd restarted twice. A report that a module has no unit is now weighed against the database clangd has actually read: before it has read the one the unit joined, the report is about the old database and is logged, not acted on. The kit replaces the toolchain's std only when std's own unit fails to compile or the plan has no unit for it at all (plan 2026-09-27 Q1-1, Q1-4, D4'). The decision is a pure function, failure_action, tested with qt-demo's sequence. --- src/engine/clangd.cpp | 36 ++++++++++++++++++++++++++++------ src/engine/clangd/process.cpp | 7 +++++++ src/engine/clangd/process.cppm | 13 ++++++++++++ tests/test_server.cpp | 23 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/src/engine/clangd.cpp b/src/engine/clangd.cpp index 72bcf67..ff5380a 100644 --- a/src/engine/clangd.cpp +++ b/src/engine/clangd.cpp @@ -1998,7 +1998,24 @@ class ClangdEngine final : public Engine { const auto standard = moduleSources_.find("std"); const bool stdFailed { parsed.module == "std" || parsed.module == "std.compat" || (kind == FailureKind::compile && standard != moduleSources_.end() && base::same_path(parsed.failedSource, standard->second)) }; - if (stdFailed && kind != FailureKind::other && !stdFromKit_) { + // Plan 2026-09-27 Q1-4: "no unit for module M" from a clangd that has not read the database the unit + // joined yet is about the database it had, not the one it has now. qt-demo: std.cc joined the database + // and 78 ms later clangd, still on the previous one, answered "Don't get the module unit for module std"; + // taken at its word, the whole project was moved to the semantic kit and clangd restarted twice. + // Q1-1 (D4'): the kit replaces the toolchain's standard library only when its unit failed to compile, or + // when the plan has no unit for it at all; a unit the plan has and clangd has read but still "does not + // get" is a scanning problem of the files that import it, which the kit would not make any better. + const auto provider = moduleSources_.find(parsed.module); + const bool providerPlanned { provider != moduleSources_.end() && !generated_path_(provider->second) }; + const FailureAction action { failure_action(kind, FailureContext { .standardLibrary = stdFailed, .providerPlanned = providerPlanned, + .providerRead = engine_read_unit_of_(parsed.module, Clock::now()), + .alreadyOnKit = stdFromKit_ }) }; + if (action == FailureAction::ignore) { + log::info("clangd has not read the unit of module {} yet ({}): {}; not taken as a failure", parsed.module, host_->root_directory(), parsed.reason); + host_->record_event("module-unresolved-before-read", Json { { "module", parsed.module }, { "reason", parsed.reason } }); + return; + } + if (action == FailureAction::use_kit) { stdFromKit_ = true; log::warning("clangd could not build the standard library module ({}): {}; reading the project with the semantic kit", host_->root_directory(), parsed.reason); @@ -2481,11 +2498,8 @@ class ClangdEngine final : public Engine { if (name == "std" || name == "std.compat" || resolvedElsewhere_.contains(name)) continue; const bool fresh { planned == fileImports_.end() || std::ranges::find(planned->second, name) == planned->second.end() }; if (!fresh && !watched) continue; - const auto joined = moduleJoinedAt_.find(name); - if (joined == moduleJoinedAt_.end()) return std::format("it imports {}, which the engine database has no unit for yet (UP-02)", name); - // A clangd that has read no database yet reads this one, whole, when it is given its first file. - const bool read { !databaseRead_ || (databaseReadAt_ && *databaseReadAt_ >= joined->second) || now >= joined->second + DATABASE_REREAD }; - if (!read) return std::format("it imports {}, whose unit clangd has not read from the engine database yet (UP-02)", name); + if (!moduleJoinedAt_.contains(name)) return std::format("it imports {}, which the engine database has no unit for yet (UP-02)", name); + if (!engine_read_unit_of_(name, now)) return std::format("it imports {}, whose unit clangd has not read from the engine database yet (UP-02)", name); } return std::nullopt; } @@ -2629,6 +2643,16 @@ class ClangdEngine final : public Engine { // ---- fix plan F13, F17.3: what a plan changed ------------------------------------------------ // Stand-ins and prime units: files this server writes, which nothing but clangd's own lookups ever builds. + // Whether this clangd has read the engine database that gave `module` its current unit. A clangd that + // has read no database yet reads this one, whole, when it is given its first file; one that read an + // earlier database rereads it within DATABASE_REREAD. A module whose unit has been there since before + // this clangd started (no join recorded) is read. + bool engine_read_unit_of_(std::string_view module, Clock::time_point now) const { + const auto joined = moduleJoinedAt_.find(module); + if (joined == moduleJoinedAt_.end()) return true; + return !databaseRead_ || (databaseReadAt_ && *databaseReadAt_ >= joined->second) || now >= joined->second + DATABASE_REREAD; + } + bool generated_path_(std::string_view path) const { const auto within = [&](const std::string& directory) { return !directory.empty() && (base::is_within(path, directory) || base::is_within(path, base::path_key(directory))); diff --git a/src/engine/clangd/process.cpp b/src/engine/clangd/process.cpp index e19f010..9760dde 100644 --- a/src/engine/clangd/process.cpp +++ b/src/engine/clangd/process.cpp @@ -223,6 +223,13 @@ FailureKind failure_kind(const ModuleFailure& failure) { return FailureKind::other; } +FailureAction failure_action(FailureKind kind, const FailureContext& context) { + if (kind == FailureKind::unresolved && context.providerPlanned && !context.providerRead) return FailureAction::ignore; + const bool kitHelps { kind == FailureKind::compile || (kind == FailureKind::unresolved && !context.providerPlanned) }; + if (context.standardLibrary && kitHelps && !context.alreadyOnKit) return FailureAction::use_kit; + return FailureAction::record; +} + std::string parse_clangd_version(std::string_view output) { for (auto line : base::split_lines(output)) { const std::size_t marker { line.find("clangd version ") }; diff --git a/src/engine/clangd/process.cppm b/src/engine/clangd/process.cppm index 436938f..e974ac1 100644 --- a/src/engine/clangd/process.cppm +++ b/src/engine/clangd/process.cppm @@ -127,6 +127,19 @@ bool loader_failure(std::string_view line); // built. `compile`: the unit was found and did not compile; its importers get errors, not a hang (S3). enum class FailureKind { unresolved, compile, other }; FailureKind failure_kind(const ModuleFailure& failure); + +// What a module failure clangd reported means for the session (plan 2026-09-27 Q1-1, Q1-4). +// ignore the unit joined an engine database this clangd has not read yet: the report is about the old one +// use_kit the standard library's own unit failed to compile, or the plan has no unit for it: the kit replaces it +// record everything else: the failure is recorded where it is, as before +enum class FailureAction { ignore, use_kit, record }; +struct FailureContext { + bool standardLibrary { false }; // the module is std or std.compat, or the unit that failed is std's + bool providerPlanned { false }; // the plan gives the module a unit of the project or the toolchain (not a stand-in) + bool providerRead { true }; // this clangd has read the engine database in which that unit joined + bool alreadyOnKit { false }; // the standard library is already the kit's +}; +FailureAction failure_action(FailureKind kind, const FailureContext& context); // "clangd version 23.1.0 (https://github.com/llvm/llvm-project ea7d852a70e8...)" -> "23.1.0" std::string parse_clangd_version(std::string_view output); diff --git a/tests/test_server.cpp b/tests/test_server.cpp index 6bef781..25f0366 100644 --- a/tests/test_server.cpp +++ b/tests/test_server.cpp @@ -443,6 +443,29 @@ int main() { expect(cld::failure_kind(*other) == cld::FailureKind::other); }; + "only a standard library that does not compile, or that has no unit, moves the project to the kit"_test = [] { + using cld::FailureAction; + using cld::FailureKind; + // qt-demo, 12:15:52.944: std.cc joined the engine database; 78 ms later clangd, still on the database it + // had read before, reported "Don't get the module unit for module std". + expect(cld::failure_action(FailureKind::unresolved, { .standardLibrary = true, .providerPlanned = true, .providerRead = false }) + == FailureAction::ignore) << "a report about a database clangd has not read yet is not a failure"; + expect(cld::failure_action(FailureKind::unresolved, { .standardLibrary = true, .providerPlanned = true, .providerRead = true }) + == FailureAction::record) << "a unit clangd has read and still cannot get is not a reason to leave the toolchain"; + expect(cld::failure_action(FailureKind::unresolved, { .standardLibrary = true, .providerPlanned = false }) + == FailureAction::use_kit) << "a toolchain with no std module gets the kit's"; + expect(cld::failure_action(FailureKind::compile, { .standardLibrary = true, .providerPlanned = true }) + == FailureAction::use_kit) << "std that does not compile gets the kit's"; + expect(cld::failure_action(FailureKind::compile, { .standardLibrary = true, .providerPlanned = true, .alreadyOnKit = true }) + == FailureAction::record) << "once on the kit, nothing switches again"; + expect(cld::failure_action(FailureKind::other, { .standardLibrary = true, .providerPlanned = true }) + == FailureAction::record); + expect(cld::failure_action(FailureKind::compile, { .standardLibrary = false, .providerPlanned = true }) + == FailureAction::record) << "a project module never moves the project to the kit"; + expect(cld::failure_action(FailureKind::unresolved, { .standardLibrary = false, .providerPlanned = true, .providerRead = false }) + == FailureAction::ignore) << "the same race for any module is not recorded as unresolved"; + }; + "restarts in a row are spaced out"_test = [] { using namespace std::chrono_literals; cld::RestartGate gate; From 99d5c66acd748fed775058c049e466b0a09d78b5 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:16:04 +0800 Subject: [PATCH 03/25] fix(model): a rule's inputs are left out and its generated output is read from the project's own build On a rules-qt project mcpp's build database lists the Qt form, resource list and translation as translation units with a compiler command, and names uic's and moc's output in its private planning directory, where by its own specification no action ever runs (mcpp-community/mcpp#724). clangd then failed on the three inputs at every scan and on every file including ui_mainwindow.h. Units no C-family compiler reads are left out, with a notice. Include directories and sources in a producer's planning directory are replaced, read-only, by the same paths under the project's own target/ when a build has written them; when none has yet, the status says which generated files are missing, offers to build in a terminal, and watches where the build will put them so the model is loaded again (plan 2026-09-27 Q1-2, Q1-3). On a copy of qt-demo, main.cpp goes from a fatal missing header to clangd --check exiting 0. --- src/orchestrator/workspace.cpp | 5 ++ src/project/generated.cpp | 88 ++++++++++++++++++++++++++++++++++ src/project/generated.cppm | 20 ++++++++ src/project/model.cpp | 37 ++++++++++++++ src/project/scan.cpp | 13 +++++ src/project/scan.cppm | 5 ++ tests/test_project.cpp | 70 +++++++++++++++++++++++++++ 7 files changed, 238 insertions(+) diff --git a/src/orchestrator/workspace.cpp b/src/orchestrator/workspace.cpp index d71a37c..ace54a6 100644 --- a/src/orchestrator/workspace.cpp +++ b/src/orchestrator/workspace.cpp @@ -1537,6 +1537,11 @@ struct Workspace::Impl final : engine::Host { // Needing a download is reported above, with what to do about it; the load's own // issue says the same thing with nothing to do, and saying it twice helps nobody. if (issue.code == spec::NEEDS_DOWNLOAD) continue; + // Plan 2026-09-27 Q1-3: what only a build makes is made by building; the model is loaded again when it is. + if (issue.code == "generated-files-missing") { + add(issue.code, issue.message, "mcppls.runBuildToolInTerminal", "Build in Terminal", "environment"); + continue; + } add(issue.code, issue.message, "mcppls.showLogs"); } } diff --git a/src/project/generated.cpp b/src/project/generated.cpp index 462d414..f5bf697 100644 --- a/src/project/generated.cpp +++ b/src/project/generated.cpp @@ -3,6 +3,7 @@ module mcppls.project.generated; import std; import mcppls.base.path; import mcppls.platform.fs; +import mcppls.spec.database; import mcppls.project.scan; namespace mcppls::project { @@ -77,4 +78,91 @@ std::optional find_generated_source(std::string_view moduleName, co return newest(cached); } +std::optional private_target_relative(std::string_view path) { + const std::string normalized { base::normalize_path(path) }; + constexpr std::string_view MARKER { "/cache/build-database/" }; + const std::size_t marker { normalized.find(MARKER) }; + if (marker == std::string::npos) return std::nullopt; + const std::size_t key { marker + MARKER.size() }; + const std::size_t afterKey { normalized.find('/', key) }; + if (afterKey == std::string::npos || afterKey == key) return std::nullopt; + constexpr std::string_view TARGET { "/target/" }; + if (normalized.compare(afterKey, TARGET.size(), TARGET) != 0) return std::nullopt; + return normalized.substr(afterKey + TARGET.size()); +} + +namespace { + +// Nothing a reader could use: absent, or only empty files (mcpp leaves an empty placeholder where an action's +// source output will go, so that the plan can name it). +bool holds_nothing(std::string_view path) { + if (platform::fs::is_regular_file(path)) { + const auto stamp = platform::fs::stamp(path); + return !stamp || stamp->size == 0; + } + if (!platform::fs::is_directory(path)) return true; + return std::ranges::all_of(platform::fs::list_directory(path), [](const std::string& entry) { + return !platform::fs::is_directory(entry) && holds_nothing(entry); + }); +} + +// The option spellings that name an include directory, as one argument (`-I/x`) or with the next one (`-I /x`). +constexpr std::array INCLUDE_OPTIONS { "-I", "-isystem", "-iquote", "-idirafter", "/I" }; + +} // namespace + +GeneratedPaths use_project_build_output(spec::Database& database, std::string_view root) { + GeneratedPaths paths; + std::map, std::less<>> decided; // private path -> its replacement, if any + auto replacement = [&](const std::string& path, bool directory) -> std::optional { + if (const auto known = decided.find(path); known != decided.end()) return known->second; + std::optional chosen; + if (const auto relative = private_target_relative(path)) { + const std::string counterpart { base::join_path(root, base::join_path("target", *relative)) }; + const bool exists { directory ? platform::fs::is_directory(counterpart) : platform::fs::is_regular_file(counterpart) }; + if (exists && !holds_nothing(counterpart)) { + chosen = counterpart; + paths.relocated.push_back(path); + } else if (holds_nothing(path)) { + paths.missing.push_back(path); + paths.watch.push_back(base::join_path("target", directory ? base::join_path(*relative, "*") : *relative)); + } + } + decided.emplace(path, chosen); + return chosen; + }; + auto rewrite_arguments = [&](std::vector& arguments) { + for (std::size_t i { 0 }; i < arguments.size(); ++i) { + for (const std::string_view option : INCLUDE_OPTIONS) { + if (!arguments[i].starts_with(option)) continue; + if (arguments[i].size() == option.size()) { + if (i + 1 < arguments.size()) { + if (auto chosen = replacement(arguments[i + 1], true)) arguments[i + 1] = *chosen; + ++i; + } + } else if (auto chosen = replacement(arguments[i].substr(option.size()), true)) { + arguments[i] = std::string { option } + *chosen; + } + break; + } + } + }; + for (auto& set : database.sets) { + rewrite_arguments(set.baselineArguments); + for (auto& unit : set.units) { + rewrite_arguments(unit.arguments); + rewrite_arguments(unit.localArguments); + const std::string source { spec::absolute_source(unit) }; + if (auto chosen = replacement(source, false)) { + // The command names the source too; it names the same file. + for (auto& argument : unit.arguments) { + if (base::same_path(argument, source) || base::same_path(argument, unit.source)) argument = *chosen; + } + unit.source = *chosen; + } + } + } + return paths; +} + } // namespace mcppls::project diff --git a/src/project/generated.cppm b/src/project/generated.cppm index e778b57..371cc62 100644 --- a/src/project/generated.cppm +++ b/src/project/generated.cppm @@ -10,6 +10,7 @@ export module mcppls.project.generated; import std; +import mcppls.spec.database; import mcppls.project.infer; export namespace mcppls::project { @@ -28,4 +29,23 @@ struct GeneratedSourceOptions { // locations are read, never the whole home directory or a general recursive search. std::optional find_generated_source(std::string_view moduleName, const GeneratedSourceOptions& options); +// Generated build output a producer describes but never writes (plan 2026-09-27 Q1-3). mcpp plans in a +// private directory, `$MCPP_HOME/cache/build-database//`, and by its own specification runs no action +// there (SPEC-005 R2.1, R2.5), so what a rule's action generates -- `ui_mainwindow.h` from uic, moc's and +// rcc's sources -- is named in the database and never appears. A build of the project puts the same files +// under the project's own `target/`, at the same relative path. Include directories and sources in the +// private directory are replaced by the project's counterpart when that exists, read-only; the ones whose +// counterpart does not exist yet are reported, with where a build will put them, so the model is loaded +// again when it does. +struct GeneratedPaths { + std::vector relocated; // private paths the project's own build output replaced + std::vector missing; // private paths with nothing in them yet and no counterpart in the project + std::vector watch; // glob patterns, relative to the root, where a build writes what is missing +}; +GeneratedPaths use_project_build_output(spec::Database& database, std::string_view root); + +// The part of `path` after a producer's private planning directory's `target/`, or nullopt when `path` is not +// in one: `/cache/build-database//target/.build-mcpp/out/qt` -> `.build-mcpp/out/qt`. +std::optional private_target_relative(std::string_view path); + } // namespace mcppls::project diff --git a/src/project/model.cpp b/src/project/model.cpp index a6960c1..d4c2af8 100644 --- a/src/project/model.cpp +++ b/src/project/model.cpp @@ -20,6 +20,7 @@ import mcppls.project.provider; import mcppls.project.mcpp; import mcppls.project.cmake; import mcppls.project.generated; +import mcppls.project.scan; namespace mcppls::project { @@ -270,6 +271,42 @@ ProjectModel load_project(std::string_view rootInput, const LoadOptions& options model.database = std::move(loaded->database); model.facts = std::move(loaded->facts); + if (model.source != SourceKind::inferred) { + // Plan 2026-09-27 Q1-2: a rule's inputs listed as translation units (mcpp-community/mcpp#724: a Qt form, + // resource list and translation, each with a compiler command) are nothing a C-family compiler reads; + // given to clangd they only fail, once per scan, and are left out here instead. + std::vector notCompiled; + for (auto& set : model.database.sets) { + const auto dropped = std::ranges::remove_if(set.units, [&](const spec::TranslationUnit& unit) { + if (compiled_by_c_family(unit.source, unit.arguments)) return false; + notCompiled.push_back(std::string { base::file_name(unit.source) }); + return true; + }); + set.units.erase(dropped.begin(), dropped.end()); + } + if (!notCompiled.empty()) { + model.notices.push_back(ModelIssue { "not-compiled-inputs", + std::format("{} {} of the build description {} not C or C++ ({}); left out", notCompiled.size(), notCompiled.size() == 1 ? "entry" : "entries", + notCompiled.size() == 1 ? "is" : "are", base::join(notCompiled, ", ")) }); + } + // Q1-3: what a rule generates is named in the producer's private planning directory and made only by a + // build; the project's own build output is read instead when there is one (read-only), and when there is + // none yet the status says so and the model is loaded again as soon as a build writes it. + const GeneratedPaths generated { use_project_build_output(model.database, model.root) }; + if (!generated.relocated.empty()) { + model.notices.push_back(ModelIssue { "generated-output-used", + std::format("{} generated {} the build description names in its planning directory {} read from the project's own build output", + generated.relocated.size(), generated.relocated.size() == 1 ? "path" : "paths", generated.relocated.size() == 1 ? "is" : "are") }); + } + if (!generated.missing.empty()) { + std::vector names; + for (const auto& path : generated.missing) names.push_back(std::string { base::file_name(path) }); + model.issues.push_back(ModelIssue { "generated-files-missing", + std::format("files the build generates ({}) do not exist yet; files that include them have no semantics until the project is built once", + base::join(names, ", ")) }); + model.watch.insert(model.watch.end(), generated.watch.begin(), generated.watch.end()); + } + } // A database a producer wrote is level 2 at least (enrichment saw to that); the S1 library structures // its arguments into options for level 3, which mcpp leaves to it (mcpp-community/mcpp#636). Before // the renaming below, while each unit's source is still spelled as its arguments spell it. diff --git a/src/project/scan.cpp b/src/project/scan.cpp index d8445b8..3b1765a 100644 --- a/src/project/scan.cpp +++ b/src/project/scan.cpp @@ -505,4 +505,17 @@ bool is_cxx_source_name(std::string_view path) { return std::ranges::any_of(EXTENSIONS, [&](std::string_view candidate) { return base::iequals_ascii(candidate, extension); }); } +bool compiled_by_c_family(std::string_view source, std::span arguments) { + if (is_cxx_source_name(source)) return true; + static constexpr std::array OTHERS { ".c", ".m", ".mm", ".cu", ".hip", ".i", ".ii", ".mi", ".mii", ".C" }; + const std::string_view extension { base::extension(source) }; + if (std::ranges::any_of(OTHERS, [&](std::string_view candidate) { return candidate == extension || (candidate != ".C" && base::iequals_ascii(candidate, extension)); })) { + return true; + } + return std::ranges::any_of(arguments, [](const std::string& argument) { + return argument.starts_with("-x") || argument.starts_with("--language") || argument.starts_with("/Tp") || argument.starts_with("/Tc") + || argument == "/TP" || argument == "/TC"; + }); +} + } // namespace mcppls::project diff --git a/src/project/scan.cppm b/src/project/scan.cppm index a5b8e2b..cc3be7b 100644 --- a/src/project/scan.cppm +++ b/src/project/scan.cppm @@ -71,5 +71,10 @@ std::string imported_name(const ScanResult& result, const ImportDeclaration& imp // True when the file name has a conventional C++ source or module extension. bool is_cxx_source_name(std::string_view path); +// Whether a unit of a build database is something a C-family compiler compiles (plan 2026-09-27 Q1-2): C, +// C++ and module units, Objective-C, CUDA and HIP by extension, or anything its arguments force a language on +// (`-x c++`, `/Tp`). A rule's own input -- a Qt form, a resource list, a translation -- is not, even when a +// producer lists it with a compiler command. +bool compiled_by_c_family(std::string_view source, std::span arguments); } // namespace mcppls::project diff --git a/tests/test_project.cpp b/tests/test_project.cpp index ba8596b..526a289 100644 --- a/tests/test_project.cpp +++ b/tests/test_project.cpp @@ -20,6 +20,7 @@ import mcppls.project.compdb; import mcppls.project.cmake; import mcppls.project.modelcache; import mcppls.project.generated; +import mcppls.project.scan; namespace fs = mcppls::platform::fs; namespace b = mcppls::base; @@ -572,6 +573,75 @@ version = "1" fs::remove_all(home); }; + "a producer's planning directory is told from any other path"_test = [] { + expect(p::private_target_relative("/h/.mcpp/cache/build-database/81d15f0e10d7a75b/target/.build-mcpp/out/qt") == std::optional { ".build-mcpp/out/qt" }); + expect(p::private_target_relative("/h/.mcpp/cache/build-database/k/target/x86_64-linux-gnu/f/obj/a.o") == std::optional { "x86_64-linux-gnu/f/obj/a.o" }); + expect(!p::private_target_relative("/p/qt-demo/target/.build-mcpp/out/qt").has_value()) << "the project's own build directory"; + expect(!p::private_target_relative("/h/.mcpp/cache/build-database/k/src/x.cpp").has_value()) << "not under the planning target"; + }; + + "generated build output a producer only names is read from the project's own build (qt-demo)"_test = [] { + const std::string root { make_root("generated-output") }; + const std::string home { make_root("generated-output-home") }; + // What mcpp's emit describes for a rules-qt project: an include directory in its planning directory + // holding only empty placeholders, and a moc source that is one of them. + const std::string planning { b::join_path(home, ".mcpp/cache/build-database/81d15f0e10d7a75b") }; + write(planning, "target/.build-mcpp/out/qt/moc_counter.cpp", ""); + write(planning, "target/.build-mcpp/out/qt/qrc_demo.cpp", ""); + const std::string privateOut { b::join_path(planning, "target/.build-mcpp/out/qt") }; + auto database_of = [&] { + s::Database database; + s::Set set; + set.name = "qt-demo"; + set.baselineArguments = { "-I" + privateOut, "-std=c++23" }; + s::TranslationUnit main; + main.source = b::join_path(root, "src/main.cpp"); + main.arguments = { "g++", "-I" + privateOut, "-std=c++23", "-c", main.source }; + s::TranslationUnit moc; + moc.source = b::join_path(privateOut, "moc_counter.cpp"); + moc.arguments = { "g++", "-I", privateOut, "-c", moc.source }; + set.units = { main, moc }; + database.sets.push_back(set); + return database; + }; + + // Never built: nothing to read instead; what is missing is reported, with where a build writes it. + auto unbuilt = database_of(); + const auto before = p::use_project_build_output(unbuilt, root); + expect(before.relocated.empty()); + expect(std::ranges::find(before.missing, privateOut) != before.missing.end()); + expect(std::ranges::find(before.watch, std::string { "target/.build-mcpp/out/qt/*" }) != before.watch.end()); + expect(unbuilt.sets[0].units[0].arguments[1] == "-I" + privateOut) << "left as it is"; + + // Built once: the project's own output replaces the planning directory's, arguments and source alike. + write(root, "target/.build-mcpp/out/qt/ui_mainwindow.h", "namespace Ui { class MainWindow {}; }\n"); + write(root, "target/.build-mcpp/out/qt/moc_counter.cpp", "// moc output\n"); + const std::string projectOut { b::join_path(root, "target/.build-mcpp/out/qt") }; + auto built = database_of(); + const auto after = p::use_project_build_output(built, root); + expect(after.missing.empty()); + expect(built.sets[0].baselineArguments[0] == "-I" + projectOut); + expect(built.sets[0].units[0].arguments[1] == "-I" + projectOut); + expect(built.sets[0].units[1].arguments[2] == projectOut) << "the separate spelling too"; + expect(built.sets[0].units[1].source == b::join_path(projectOut, "moc_counter.cpp")); + expect(built.sets[0].units[1].arguments.back() == b::join_path(projectOut, "moc_counter.cpp")); + fs::remove_all(root); + fs::remove_all(home); + }; + + "a rule's inputs are not translation units, whatever command a producer gives them"_test = [] { + const std::vector gcc { "g++", "-c" }; + expect(!p::compiled_by_c_family("/p/ui/mainwindow.ui", gcc)); + expect(!p::compiled_by_c_family("/p/res/demo.qrc", gcc)); + expect(!p::compiled_by_c_family("/p/i18n/qt_demo_zh_CN.ts", gcc)); + expect(p::compiled_by_c_family("/p/src/main.cpp", gcc)); + expect(p::compiled_by_c_family("/p/src/a.c", gcc)); + expect(p::compiled_by_c_family("/p/src/m.ixx", gcc)); + expect(p::compiled_by_c_family("/p/src/k.cu", gcc)); + expect(p::compiled_by_c_family("/p/src/generated.inl", std::vector { "clang++", "-x", "c++", "-c" })) << "a language forced on it"; + expect(p::compiled_by_c_family("/p/src/generated.inl", std::vector { "cl.exe", "/Tp/p/src/generated.inl" })); + }; + "a generated module's real source is recovered before a stand-in is needed"_test = [] { const std::string root { make_root("generated") }; const std::string home { make_root("generated-home") }; From 9bb6ecf38dd8de8afd87e0c02116666c4eb7ef5d Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:26:58 +0800 Subject: [PATCH 04/25] feat(clangd): implementation units reach clangd's index through its foreground clangd's background index compiles a module unit without building the modules it imports: every unit of a module project logs "Failed to compile ..., index may be incomplete", and a definition in an implementation unit is indexed apart from its declaration, or not at all, until the unit has been open. In mcpp's own prepare module, go-to-definition on phase0_manifest_and_workspace stayed on its declaration for as long as it was measured (420 s). Registered as WA-CLANGD-008, the engine now opens implementation units in clangd a few at a time, lets the foreground build them with their modules, and closes them again; their symbols stay in clangd's index. The units of an opened file's module and of the modules it imports go first, a unit changed on disk while not open goes again, and the rest follow once clangd has been idle for a while. A restart of clangd starts over. A unit that does not build (a header nothing generated yet) is told as implementation-unreadable (plan 2026-09-27 N-7, N-3). On mcpp's source, both phase functions now resolve to their .cpp from the first answer. --- src/engine/clangd.cpp | 199 ++++++++++++++++++++++++++++- src/engine/clangd.cppm | 4 + src/engine/clangd/workarounds.cpp | 13 +- src/engine/clangd/workarounds.cppm | 1 + src/engine/engine.cppm | 1 + tests/test_server.cpp | 126 ++++++++++++++++++ 6 files changed, 338 insertions(+), 6 deletions(-) diff --git a/src/engine/clangd.cpp b/src/engine/clangd.cpp index ff5380a..e67cd6c 100644 --- a/src/engine/clangd.cpp +++ b/src/engine/clangd.cpp @@ -36,6 +36,7 @@ EngineTraits traits_for_version(std::string_view version, std::span, std::less<>> moduleUnits_; // module -> its units other than its interface std::map> interfaceModules_; // path key of an importable unit -> its module struct BackgroundUnit { @@ -315,10 +318,28 @@ class ClangdEngine final : public Engine { Clock::time_point usedAt; bool built { false }; std::string state; // clangd's last fileStatus state for it + bool priming { false }; // opened to put its definitions in clangd's index (N-7), not for a waiting request }; std::map> background_; // path key std::set> closedBackground_; // path keys whose closing diagnostics are still to come std::map, std::less<>> backgroundRefused_; // units that did not build in time, as they were + // Plan 2026-09-27 N-7 (WA-CLANGD-008). clangd's background index compiles a module unit without building the modules + // it imports, so a definition in an implementation unit is indexed apart from its declaration, or not at all, until + // the unit has been open. Implementation units are therefore opened here, a few at a time, built through clangd's + // foreground -- which builds their modules first -- and closed again: their symbols stay in clangd's index. + // Relevant ones first (the units of an opened file's module and of the modules it imports, and units changed on + // disk), the rest once clangd has been idle for a while. A restart of clangd loses what its index held. + struct ImplementationUnit { + std::string path; + std::string module; + }; + std::deque implementationQueue_; + std::set> implementationQueued_; // path keys + std::map, std::less<>> implementationBuilt_; // path key -> the file as it was built + std::map> implementationUnreadable_; // path key -> why it did not build (N-3) + bool implementationSeedOpen_ { true }; // the open documents' relevant units are still to be queued + bool implementationRestQueued_ { false }; + std::optional implementationRestAt_; struct DefinitionSearch { Json message; // the client's request Json firstAnswer; // clangd's answer before the units were built @@ -408,6 +429,9 @@ class ClangdEngine final : public Engine { { "filesWaitingForDatabase", held_files_() }, { "fileStates", fileStatus_ }, { "backgroundUnits", background_files_() }, + // N-7 (WA-CLANGD-008): implementation units built for clangd's index, waiting, and the ones that did not build. + { "implementationIndex", Json { { "built", implementationBuilt_.size() }, { "queued", implementationQueue_.size() }, + { "unreadable", implementationUnreadable_.size() }, { "on", priming_implementations_() } } }, { "definitionSearches", searches_.size() }, { "unresolvedModules", std::move(unresolved) }, { "modulesThatDidNotCompile", std::move(compileFailures) }, @@ -693,6 +717,10 @@ class ClangdEngine final : public Engine { if (!entry.imports.empty()) fileImports_[key] = entry.imports; if (!entry.module.empty()) fileModule_[key] = entry.module; } + // N-7: the units of this plan, for the open documents first and the rest once clangd is idle again. + implementationSeedOpen_ = true; + implementationRestQueued_ = false; + if (std::erase_if(implementationUnreadable_, [&](const auto& item) { return !writtenArguments_.contains(item.first); }) > 0) update_unreadable_issue_(); std::vector backgroundLeaving; for (const auto& [key, unit] : background_) { if (!writtenArguments_.contains(key) || excluded_.contains(key) || restartNeeded) backgroundLeaving.push_back(key); @@ -743,6 +771,8 @@ class ClangdEngine final : public Engine { if (accepting_) { open_or_hold_(document, false); prepare_imports_of_(document); + queue_implementations_of_(document.path); + pump_implementations_(Clock::now()); } else if (!document.path.empty() && !writtenDatabase_.empty() && !writtenArguments_.contains(base::path_key(document.path)) && project::is_cxx_source_name(document.path) && base::is_within(document.path, host_->root_directory())) { // Opened while clangd restarts: the database it reads may not have the file yet. Before the first plan nothing is @@ -814,6 +844,19 @@ class ClangdEngine final : public Engine { // Fix plan F16: a change on disk (from another program, or an editor that does not send didSave) is // looked at the same way, and a file whose disk text would spin clangd is left out of what it is told. if (const Json* changes = lsp::find_path(message, { "params", "changes" }); changes != nullptr && changes->is_array()) { + // N-7: clangd's background index does not index a unit again when it changes on disk; a unit the editor + // does not have open (a git pull, another program, a coding agent) is built again for the index. + for (const auto& change : *changes) { + if (!change.is_object() || change.value("type", 0) == 3) continue; + const std::string path { host_->path_of_uri(change.value("uri", std::string {})) }; + if (path.empty()) continue; + const std::string key { base::path_key(path) }; + const auto module = fileModule_.find(key); + if (module == fileModule_.end() || interfaceModules_.contains(key)) continue; + implementationBuilt_.erase(key); + queue_implementation_(path, module->second, true); + } + pump_implementations_(Clock::now()); Json kept = Json::array(); for (const auto& change : *changes) { const std::string path { change.is_object() ? host_->path_of_uri(change.value("uri", std::string {})) : std::string {} }; @@ -1059,11 +1102,13 @@ class ClangdEngine final : public Engine { for (const auto& waiting : requests) consider(waiting.limit); } for (const auto& [key, unit] : background_) consider(unit.built ? unit.usedAt + BACKGROUND_IDLE : unit.openedAt + BACKGROUND_BUILD_LIMIT); + consider(implementationRestAt_); return deadline; } void handle_timers() override { const auto now = Clock::now(); + if (implementationRestAt_ && *implementationRestAt_ <= now) queue_rest_of_implementations_(now); if (pendingExit_ && now >= pendingExit_->at + EXIT_CONTEXT_WAIT) settle_exit_(); if (!closingAfterBuild_.empty()) settle_disk_builds_(now); if (diskRecheckAt_ && *diskRecheckAt_ <= now) recheck_disk_(now); @@ -1248,6 +1293,7 @@ class ClangdEngine final : public Engine { void start_process_() { handshakeDone_ = false; + forget_implementations_(); // a new clangd's index has none of what the last one was given loadFailure_.reset(); accepting_ = false; stuck_.clear(); @@ -1721,6 +1767,7 @@ class ClangdEngine final : public Engine { } } prepare_modules_(); + pump_implementations_(Clock::now()); host_->status_changed(); } @@ -1815,10 +1862,16 @@ class ClangdEngine final : public Engine { if (const auto unit = background_.find(diagnosedKey); unit != background_.end() && !host_->has_document(uri)) { if (!unit->second.built) { unit->second.built = true; - log::info("{} built in clangd in {} ms to find definitions ({})", unit->second.path, - std::chrono::duration_cast(Clock::now() - unit->second.openedAt).count(), host_->root_directory()); + const auto took = std::chrono::duration_cast(Clock::now() - unit->second.openedAt).count(); + if (unit->second.priming) log::debug("{} built in clangd in {} ms for its index ({})", unit->second.path, took, host_->root_directory()); + else log::info("{} built in clangd in {} ms to find definitions ({})", unit->second.path, took, host_->root_directory()); } + // Every unit built here, for a search or for the index, is now in clangd's index (N-7). + implementation_built_(diagnosedKey, unit->second, params.value("diagnostics", Json::array())); + const bool priming { unit->second.priming }; unit_built_(diagnosedKey); + if (priming && !waited_on_(diagnosedKey)) close_background_(diagnosedKey); + pump_implementations_(Clock::now()); return; } // The empty list clangd sends when such a unit is closed, even when the editor opens the file right after. @@ -3099,7 +3152,7 @@ class ClangdEngine final : public Engine { return std::nullopt; } - bool open_in_background_(const std::string& path, std::string_view module, Clock::time_point now) { + bool open_in_background_(const std::string& path, std::string_view module, Clock::time_point now, bool priming = false) { const std::string key { base::path_key(path) }; if (const auto refused = backgroundRefused_.find(key); refused != backgroundRefused_.end()) { if (platform::fs::stamp(path) == refused->second) return false; @@ -3130,8 +3183,9 @@ class ClangdEngine final : public Engine { if (!send_(lsp::make_notification("textDocument/didOpen", std::move(params)))) return false; note_database_read_(); closedBackground_.erase(key); - background_[key] = BackgroundUnit { path, uri, now, now, false, {} }; - log::info("opening {} in clangd to find definitions in module {} ({})", path, module, host_->root_directory()); + background_[key] = BackgroundUnit { path, uri, now, now, false, {}, priming }; + if (priming) log::debug("opening {} in clangd to index its definitions (module {}, {})", path, module, host_->root_directory()); + else log::info("opening {} in clangd to find definitions in module {} ({})", path, module, host_->root_directory()); return true; } @@ -3148,6 +3202,141 @@ class ClangdEngine final : public Engine { background_.erase(unit); } + // ---- implementation units in clangd's index (plan 2026-09-27 N-7, WA-CLANGD-008) ------------------ + + bool priming_implementations_() const { return options_.primeImplementationUnits && traits_.indexesModuleUnitsWithoutModules; } + + void queue_implementation_(const std::string& path, const std::string& module, bool first) { + const std::string key { base::path_key(path) }; + if (implementationQueued_.contains(key) || !writtenArguments_.contains(key)) return; + if (const auto built = implementationBuilt_.find(key); built != implementationBuilt_.end() && built->second == platform::fs::stamp(path)) return; + // The editor has it open: clangd builds it anyway. + if (editor_uri_of_(key)) return; + implementationQueued_.insert(key); + if (first) implementationQueue_.push_front(ImplementationUnit { path, module }); + else implementationQueue_.push_back(ImplementationUnit { path, module }); + } + + // The units of `path`'s own module and of the modules it imports directly: where a definition it reaches is. + void queue_implementations_of_(std::string_view path) { + if (!priming_implementations_() || path.empty()) return; + const std::string key { base::path_key(path) }; + std::vector modules; + auto add_module = [&](std::string_view name) { + const std::string primary { name.substr(0, name.find(':')) }; // a partition belongs to its module's units + if (!primary.empty() && std::ranges::find(modules, primary) == modules.end()) modules.push_back(primary); + }; + if (const auto own = fileModule_.find(key); own != fileModule_.end()) add_module(own->second); + if (const auto imports = fileImports_.find(key); imports != fileImports_.end()) { + for (const auto& name : imports->second) { + if (name.starts_with(':')) { + if (const auto own = fileModule_.find(key); own != fileModule_.end()) add_module(own->second); + } else { + add_module(name); + } + } + } + std::size_t queued { 0 }; + for (const auto& module : modules) { + const auto units = moduleUnits_.find(module); + if (units == moduleUnits_.end()) continue; + for (const auto& unit : units->second) { + if (queued >= IMPLEMENTATIONS_PER_OPEN) return; + if (base::same_path(unit.path, path)) continue; + queue_implementation_(unit.path, module, true); + ++queued; + } + } + } + + bool engine_idle_() const { + return accepting_ && pending_.empty() && searches_.empty() && awaitingDiagnostics_.empty() && !primer_.busy(); + } + + // Opens queued units while fewer than IMPLEMENTATIONS_AT_ONCE are building; once nothing is queued and clangd has + // been idle for Options::implementationIdle, every other unit of every module is queued (the rest). + void pump_implementations_(Clock::time_point now) { + if (!priming_implementations_() || !accepting_ || !handshakeDone_) return; + if (implementationSeedOpen_) { + implementationSeedOpen_ = false; + for (const auto& document : host_->documents()) queue_implementations_of_(document.path); + } + std::size_t building { static_cast(std::ranges::count_if(background_, [](const auto& item) { return item.second.priming && !item.second.built; })) }; + while (building < IMPLEMENTATIONS_AT_ONCE && !implementationQueue_.empty()) { + ImplementationUnit unit { std::move(implementationQueue_.front()) }; + implementationQueue_.pop_front(); + const std::string key { base::path_key(unit.path) }; + implementationQueued_.erase(key); + if (background_.contains(key) || editor_uri_of_(key)) continue; + if (!open_in_background_(unit.path, unit.module, now, true)) continue; + ++building; + } + if (implementationQueue_.empty() && building == 0 && !implementationRestQueued_ && !implementationRestAt_) { + implementationRestAt_ = now + options_.implementationIdle; + } + } + + void queue_rest_of_implementations_(Clock::time_point now) { + implementationRestAt_.reset(); + if (!priming_implementations_() || implementationRestQueued_) return; + if (!engine_idle_()) { + implementationRestAt_ = now + options_.implementationIdle; + return; + } + implementationRestQueued_ = true; + for (const auto& [module, units] : moduleUnits_) { + for (const auto& unit : units) queue_implementation_(unit.path, module, false); + } + if (!implementationQueue_.empty()) log::info("building {} implementation units for clangd's index ({})", implementationQueue_.size(), host_->root_directory()); + pump_implementations_(now); + } + + // A unit opened for the index was built: clangd's index has its definitions now (WA-CLANGD-008's premise), and + // one that did not compile says why (N-3). + void implementation_built_(const std::string& key, const BackgroundUnit& unit, const Json& diagnostics) { + implementationBuilt_[key] = platform::fs::stamp(unit.path); + std::string reason; + for (const auto& diagnostic : diagnostics) { + if (diagnostic.value("severity", 0) != 1) continue; + const std::string code { diagnostic.contains("code") && diagnostic["code"].is_string() ? diagnostic["code"].get() : std::string {} }; + const std::string message { diagnostic.value("message", std::string {}) }; + if (code == "pp_file_not_found" || code == "module_not_found" || message.find("file not found") != std::string::npos) { + reason = message; + break; + } + } + const bool wasUnreadable { implementationUnreadable_.contains(key) }; + if (!reason.empty()) implementationUnreadable_[key] = reason; + else implementationUnreadable_.erase(key); + if (wasUnreadable != !reason.empty() || !reason.empty()) update_unreadable_issue_(); + } + + void update_unreadable_issue_() { + std::erase_if(issues_, [](const Issue& issue) { return issue.code == "implementation-unreadable"; }); + if (implementationUnreadable_.empty()) { + host_->status_changed(); + return; + } + const auto& [firstKey, firstReason] = *implementationUnreadable_.begin(); + std::string firstFile { firstKey }; + if (const auto unit = background_.find(firstKey); unit != background_.end()) firstFile = unit->second.path; + const std::size_t count { implementationUnreadable_.size() }; + add_issue_(Issue { "implementation-unreadable", + std::format("{} implementation {} cannot be read ({}: {}); definitions in {} are not reached by go-to-definition", + count, count == 1 ? "unit" : "units", base::file_name(firstFile), firstReason, count == 1 ? "it" : "them"), + "mcppls.showLogs", "code" }); + host_->status_changed(); + } + + void forget_implementations_() { + implementationQueue_.clear(); + implementationQueued_.clear(); + implementationBuilt_.clear(); + implementationSeedOpen_ = true; + implementationRestQueued_ = false; + implementationRestAt_.reset(); + } + void unit_built_(const std::string& key) { std::vector ready; for (auto it = searches_.begin(); it != searches_.end();) { diff --git a/src/engine/clangd.cppm b/src/engine/clangd.cppm index 9b4c22e..5e4d71e 100644 --- a/src/engine/clangd.cppm +++ b/src/engine/clangd.cppm @@ -32,6 +32,10 @@ struct Options { std::chrono::milliseconds stuckWatch { std::chrono::seconds { 5 } }; std::vector extraArguments; std::vector disabledWorkarounds; // registered workarounds turned off (import-hang plan §9) + // mcppls.index.primeImplementationUnits (plan 2026-09-27 N-7): implementation units are built through clangd's + // foreground in the background, so go-to-definition reaches them; off leaves only the search a definition request starts. + bool primeImplementationUnits { true }; + std::chrono::milliseconds implementationIdle { std::chrono::seconds { 15 } }; // how long clangd is idle before the rest are built (tests shorten it) std::function()> processFactory; // empty: a real clangd process }; diff --git a/src/engine/clangd/workarounds.cpp b/src/engine/clangd/workarounds.cpp index 337612a..73ee9dc 100644 --- a/src/engine/clangd/workarounds.cpp +++ b/src/engine/clangd/workarounds.cpp @@ -7,7 +7,7 @@ namespace mcppls::engine::clangd { namespace { -constexpr std::array REGISTRY { { +constexpr std::array REGISTRY { { { .id = TRAILING_DOT_MODULE_NAME, .title = "a module name ending in '.' at the end of its line spins clangd forever; clangd is given the line with ';' after the dot", @@ -85,6 +85,17 @@ constexpr std::array REGISTRY { { .canary = "", .premise = "the module is provided by a unit of the engine database, and the import is in the buffer but not on disk", }, + { + .id = BACKGROUND_INDEX_WITHOUT_MODULES, + .title = "clangd's background index compiles a module unit without building its modules, so a definition in an implementation unit is indexed apart from its declaration or not at all; the server builds implementation units through clangd's foreground", + .fixedIn = "", + .upstream = "unfiled; the symptoms of clangd/clangd#2569 (references and rename inside modules)", + .evidence = ".agents/docs/2026-09-27-qt-demo-navigation-discovery-plan.md §2.3 (BackgroundIndex::index has no ModulesBuilder); conformance fixture mcpp-partition-definition", + .added = "0.0.6", + .removeWhen = "clangd's background index builds the modules a unit imports before indexing it", + .canary = "", + .premise = "a unit clangd has built in the foreground keeps its symbols in clangd's index after it is closed", + }, } }; // "23.1.0" -> {23, 1, 0}; anything else -> nullopt. diff --git a/src/engine/clangd/workarounds.cppm b/src/engine/clangd/workarounds.cppm index bedf49e..478a65d 100644 --- a/src/engine/clangd/workarounds.cppm +++ b/src/engine/clangd/workarounds.cppm @@ -35,6 +35,7 @@ inline constexpr std::string_view MODULE_HINTS { "WA-CLANGD-004" }; inline constexpr std::string_view MSVC_STL_ALIGNED_ALLOCATION { "WA-CLANGD-005" }; inline constexpr std::string_view DIRECTIVE_SEMICOLON_POSITION { "WA-CLANGD-006" }; inline constexpr std::string_view UNSAVED_IMPORT_NOT_FOUND { "WA-CLANGD-007" }; +inline constexpr std::string_view BACKGROUND_INDEX_WITHOUT_MODULES { "WA-CLANGD-008" }; std::span workarounds(); const Workaround* find_workaround(std::string_view id); diff --git a/src/engine/engine.cppm b/src/engine/engine.cppm index e2eadb5..09e3c2c 100644 --- a/src/engine/engine.cppm +++ b/src/engine/engine.cppm @@ -37,6 +37,7 @@ struct EngineTraits { bool hangsOnUnresolvedImports { false }; // units whose imports cannot resolve stay out of its database bool needsModulePreparation { false }; // the server prepares modules in parallel for it bool needsModuleHints { false }; // its database names the unit of each module + bool indexesModuleUnitsWithoutModules { false }; // its background index cannot see a module unit's imports; implementation units are built in the foreground bool msvcStlNeedsNoAlignedAllocation { false }; // MSVC STL contexts turn aligned allocation off bool hangsOnTrailingDotModuleName { false }; // `import a.` at the end of a line spins it; it is given `import a.;` bool misplacesDirectiveSemicolon { false }; // a directive missing its `;` is reported on the next line; moved back diff --git a/tests/test_server.cpp b/tests/test_server.cpp index 25f0366..3b203c7 100644 --- a/tests/test_server.cpp +++ b/tests/test_server.cpp @@ -148,6 +148,56 @@ class DyingProcess : public cld::Process { std::shared_ptr shared_; }; +// A clangd that builds whatever it is given at once: it answers the handshake, and each file it is given +// comes back with its diagnostics, as a build that finished (the N-7 test reads what the engine opened). +class BuildingProcess : public cld::Process { +public: + struct Shared { + std::mutex mutex; + std::vector sent; + }; + explicit BuildingProcess(std::shared_ptr shared) : shared_ { std::move(shared) } {} + + mcppls::base::Result start(const cld::ProcessConfig&, MessageHandler onMessage, ClosedHandler, LogHandler) override { + onMessage_ = std::move(onMessage); + running_ = true; + return {}; + } + mcppls::base::Result send(const Json& message) override { + { + const std::lock_guard lock { shared_->mutex }; + shared_->sent.push_back(message); + } + const std::string method { message.value("method", std::string {}) }; + if (method == "initialize") { + onMessage_(Json { { "jsonrpc", "2.0" }, { "id", message["id"] }, { "result", Json { { "capabilities", Json::object() } } } }); + } else if (method == "textDocument/didOpen") { + const std::string uri { message["params"]["textDocument"]["uri"].get() }; + onMessage_(Json { { "jsonrpc", "2.0" }, { "method", "textDocument/publishDiagnostics" }, + { "params", Json { { "uri", uri }, { "diagnostics", Json::array() } } } }); + } + return {}; + } + void stop(std::chrono::milliseconds) override { running_ = false; } + bool running() const override { return running_; } + + // The files the engine gave clangd with `method` (didOpen, didClose), in order. + static std::vector files(Shared& shared, std::string_view method) { + const std::lock_guard lock { shared.mutex }; + std::vector found; + for (const auto& message : shared.sent) { + if (message.value("method", std::string {}) != method) continue; + found.push_back(mcppls::base::uri_to_path(message["params"]["textDocument"]["uri"].get()).value_or(std::string {})); + } + return found; + } + +private: + std::shared_ptr shared_; + MessageHandler onMessage_; + bool running_ { false }; +}; + // The least a Workspace offers an engine: one root, no documents, events recorded. class RecordingHost : public eng::Host { public: @@ -966,6 +1016,82 @@ int main() { expect(heavy.check(t0 + 10h).empty()); }; + "implementation units are built for clangd's index, relevant ones first, again when they change on disk (N-7)"_test = [] { + namespace fs = mcppls::platform::fs; + using mcppls::base::join_path; + const std::string root { join_path(mcppls::platform::dirs::temp_directory(), + std::format("mcppls-test-implementations-{}", std::chrono::steady_clock::now().time_since_epoch().count())) }; + auto write = [&](std::string_view relative, std::string_view text) { + const std::string path { join_path(root, relative) }; + (void)fs::create_directories(mcppls::base::parent_path(path)); + (void)fs::write_file(path, text); + return path; + }; + const std::string executable { write("clangd", "pretend-clangd") }; + const std::string main { write("src/main.cpp", "import hello.greet;\nint main() { return hello::add(1, 2); }\n") }; + const std::string interface { write("src/greet.cppm", "export module hello.greet;\nexport namespace hello { int add(int a, int b); }\n") }; + const std::string math { write("src/math.cpp", "module hello.greet;\nint hello::add(int a, int b) { return a + b; }\n") }; + const std::string greet { write("src/greet.cpp", "module hello.greet;\n") }; + const std::string other { write("src/other/other.cpp", "module other;\nint other_value() { return 1; }\n") }; + const std::string otherInterface { write("src/other/other.cppm", "export module other;\nexport int other_value();\n") }; + + auto shared = std::make_shared(); + cld::Options options; + options.executable = executable; + options.version = "23.1.0"; + options.processFactory = [shared] { return std::make_unique(shared); }; + options.implementationIdle = std::chrono::milliseconds { 300 }; + RecordingHost host { root }; + auto engine = cld::make_engine(std::move(options)); + engine->start(host); + host.pump(*engine); + + mcppls::normalize::EnginePlan plan; + auto entry = [&](const std::string& file, std::string provides, std::string module, std::vector imports) { + plan.entries.push_back(mcppls::normalize::EngineEntry { root, file, { "clang++", "-std=c++23", "-c", file }, std::move(provides), std::move(module), std::move(imports), {} }); + }; + entry(main, "", "", { "hello.greet" }); + entry(interface, "hello.greet", "hello.greet", {}); + entry(math, "", "hello.greet", {}); + entry(greet, "", "hello.greet", {}); + entry(otherInterface, "other", "other", {}); + entry(other, "", "other", {}); + engine->apply(&plan); + host.pump(*engine); + + const std::string text { "import hello.greet;\nint main() { return hello::add(1, 2); }\n" }; + engine->document(eng::DocumentEvent { eng::DocumentChange::opened, eng::DocumentView { mcppls::base::path_to_uri(main), main, "cpp", 1, text } }); + host.pump(*engine); + host.pump(*engine); + auto opened = BuildingProcess::files(*shared, "textDocument/didOpen"); + auto was_opened = [&](const std::string& path) { return std::ranges::find(opened, path) != opened.end(); }; + expect(was_opened(math) && was_opened(greet)) << "the units of the module main.cpp imports"; + expect(!was_opened(other)) << "not before clangd has been idle for a while: the rest come later"; + expect(!was_opened(interface)) << "an interface is not an implementation unit"; + auto closed = BuildingProcess::files(*shared, "textDocument/didClose"); + expect(std::ranges::find(closed, math) != closed.end()) << "closed again once built: its symbols stay in clangd's index"; + + // math.cpp changes on disk while nothing has it open: built again. + const std::size_t before { static_cast(std::ranges::count(opened, math)) }; + std::this_thread::sleep_for(std::chrono::milliseconds { 20 }); + (void)fs::write_file(math, "module hello.greet;\nint hello::add(int a, int b) { return b + a; }\n"); + engine->notify(Json { { "jsonrpc", "2.0" }, { "method", "workspace/didChangeWatchedFiles" }, + { "params", Json { { "changes", Json::array({ Json { { "uri", mcppls::base::path_to_uri(math) }, { "type", 2 } } }) } } } }); + host.pump(*engine); + opened = BuildingProcess::files(*shared, "textDocument/didOpen"); + expect(static_cast(std::ranges::count(opened, math)) == before + 1) << "built again after it changed"; + + // Idle long enough: the rest of the implementation units. + std::this_thread::sleep_for(std::chrono::milliseconds { 400 }); + engine->handle_timers(); + host.pump(*engine); + host.pump(*engine); + opened = BuildingProcess::files(*shared, "textDocument/didOpen"); + expect(was_opened(other)) << "the rest, once clangd was idle"; + engine->shut_down(); + fs::remove_all(root); + }; + "a clangd that answers nothing and uses no CPU is stuck, and a busy one is not"_test = [] { using namespace std::chrono_literals; const auto t0 = cld::GuardClock::now(); From eb475f6b776179c50b59158b4d8746f6bc769dcd Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:31:29 +0800 Subject: [PATCH 05/25] feat(workspace): a project that needs a download is served at once, asked about once, and upgrades itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With nothing cached the build tool now has 2.5 s, not 10, to describe the project (plan D5); after that the project is served from its scanned sources by both engines while the build tool goes on, and its model replaces the provisional one in one switch that is never counted against clangd's restart budget. When the offline description needs a download, the status issue carries askOnline, and mcppls.describeOnline describes the project once with the network, the person's choice from the editor; later loads are offline again. Needing a download is not the end of it: the offline description is asked again after 30 s, 1, 2 and then every 5 minutes, and at once when a watched input changes, so a build or install the person runs in their own terminal upgrades the project on its own. The VS Code extension offers the download in a non-blocking notification that may stay unanswered forever, once per workspace and set of missing things, never after Don't Ask Again, and does nothing if the answer comes after the need went away (plan 2026-09-27 B-2, B-4, §9.2). Run in Terminal knows xmake and meson too. --- editors/vscode/src/commands.ts | 6 +- editors/vscode/src/downloadAsk.ts | 21 +++++ editors/vscode/src/downloadPrompt.ts | 86 ++++++++++++++++++++ editors/vscode/src/extension.ts | 3 + editors/vscode/src/prompt.ts | 2 +- editors/vscode/src/status.ts | 11 +++ editors/vscode/test/unit/downloadAsk.test.ts | 33 ++++++++ src/orchestrator/routing.cpp | 3 +- src/orchestrator/workspace.cpp | 78 +++++++++++++++--- src/orchestrator/workspace.cppm | 3 + src/server/session.cpp | 8 ++ 11 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 editors/vscode/src/downloadAsk.ts create mode 100644 editors/vscode/src/downloadPrompt.ts create mode 100644 editors/vscode/test/unit/downloadAsk.test.ts diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 059c9ef..2fcc914 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -362,9 +362,13 @@ async function runBuildToolInTerminal(access: ServerAccess): Promise { command = 'mcpp build'; } else if (await exists('CMakeLists.txt')) { command = 'cmake -S . -B build'; + } else if (await exists('xmake.lua')) { + command = 'xmake'; + } else if (await exists('meson.build')) { + command = 'meson setup build'; } if (!command) { - void vscode.window.showWarningMessage('C++ Modules: this folder has no mcpp.toml or CMakeLists.txt to build.'); + void vscode.window.showWarningMessage('C++ Modules: this folder has no mcpp.toml, CMakeLists.txt, xmake.lua or meson.build to build.'); return; } const choice = await vscode.window.showInformationMessage( diff --git a/editors/vscode/src/downloadAsk.ts b/editors/vscode/src/downloadAsk.ts new file mode 100644 index 0000000..b5fb419 --- /dev/null +++ b/editors/vscode/src/downloadAsk.ts @@ -0,0 +1,21 @@ +// When to offer to fetch what the build description needs (plan 2026-09-27 B-2, §9.2): the decision of +// downloadPrompt.ts, in plain TypeScript so it is tested without VS Code (test/unit/downloadAsk.test.ts). + +export const NEEDS_DOWNLOAD_CODE = 'producer-needs-download'; + +export interface DownloadIssue { + code: string; + message: string; + // S3 (plan 2026-09-27 B-2): the server lets a client offer to fetch what is missing. + askOnline?: boolean; +} + +export function needsDownload(status: { issues?: { code: string; message: string }[] }): DownloadIssue | undefined { + return (status.issues ?? []).find((issue) => issue.code === NEEDS_DOWNLOAD_CODE) as DownloadIssue | undefined; +} + +// Whether to ask now: the server offers it, the person has not declined for this workspace, this set of +// missing things has not been asked about yet, and no question for this root is still open. +export function shouldAsk(issue: DownloadIssue | undefined, never: boolean, askedAbout: readonly string[], open: boolean): boolean { + return issue !== undefined && issue.askOnline === true && !never && !open && !askedAbout.includes(issue.message); +} diff --git a/editors/vscode/src/downloadPrompt.ts b/editors/vscode/src/downloadPrompt.ts new file mode 100644 index 0000000..4f1481c --- /dev/null +++ b/editors/vscode/src/downloadPrompt.ts @@ -0,0 +1,86 @@ +// The build description needs a download (plan 2026-09-27 B-2, §9.2). The server runs the build tool +// offline, and when it cannot describe the project without fetching something it says so in the status, +// with `askOnline` when a client may offer to fetch it. This is the third question the extension ever +// asks (conflicts.ts and commandLineTools.ts are the others), and it follows the rules of §9.2: +// +// - it never blocks anything: a notification in the corner, which may stay unanswered forever; nothing +// the extension or the server does waits for it, and the project is served from its sources meanwhile; +// - it is asked once per workspace for one set of missing things, and never again after "Don't Ask Again"; +// - the answer can come long after the question. By then the person may have built the project in their +// own terminal, or the server's own retries may have found everything in place; an answer to a question +// that no longer applies does nothing. + +import * as vscode from 'vscode'; +import { askOnce } from './prompt'; +import type { CxxModulesStatus } from './status'; +import { needsDownload, shouldAsk } from './downloadAsk'; + +export const DESCRIBE_ONLINE_COMMAND = 'mcppls.describeOnline'; +export const RUN_IN_TERMINAL_COMMAND = 'mcppls.runBuildToolInTerminal'; +export const NEVER_KEY = 'mcppls.downloadPrompt.never'; +export const ASKED_KEY = 'mcppls.downloadPrompt.asked'; +export const FETCH = 'Download and Continue'; +export const RUN_IN_TERMINAL = 'Run in Terminal'; +export const NEVER = "Don't Ask Again"; + +export class DownloadPromptController { + // What each root still needs, from its latest status: the answer to an old question is checked against it. + private readonly pending = new Map(); + private readonly open = new Set(); + + constructor(private readonly context: vscode.ExtensionContext, private readonly log: (line: string) => void) {} + + // Called for every cxxModules/status notification from the running server. + onStatus(status: CxxModulesStatus): void { + const root = status.project.root; + const issue = needsDownload(status); + if (issue) { + this.pending.set(root, issue.message); + } else { + this.pending.delete(root); + } + const never = this.context.workspaceState.get(NEVER_KEY, false); + const askedAbout = this.context.workspaceState.get(ASKED_KEY, []); + if (!shouldAsk(issue, never, askedAbout, this.open.has(root)) || !issue) { + return; + } + // Recorded before the answer: another status while the question is open must not ask again. + this.open.add(root); + void this.context.workspaceState.update(ASKED_KEY, [...askedAbout, issue.message].slice(-8)); + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(root))?.name ?? root; + this.log(`asking whether to fetch what the build description of ${folder} needs`); + void askOnce( + 'download', + `C++ Modules: the build description of ${folder} needs a download. The project is served from its sources ` + + 'meanwhile. Fetch it now (the build tool may reach the network), or run the build yourself?', + FETCH, + RUN_IN_TERMINAL, + NEVER, + ).then((answer) => this.answered(root, answer)); + } + + private answered(root: string, answer: string | undefined): void { + this.open.delete(root); + if (answer === NEVER) { + void this.context.workspaceState.update(NEVER_KEY, true); + this.log('the build description download will not be offered again in this workspace'); + return; + } + if (answer === undefined) { + return; + } + if (!this.pending.has(root)) { + // §9.2 rule 4: the environment was completed meanwhile (a build in a terminal, the server's own retry). + this.log('the build description no longer needs a download; nothing to do'); + return; + } + if (answer === FETCH) { + this.log('fetching what the build description needs'); + void vscode.commands.executeCommand(DESCRIBE_ONLINE_COMMAND).then(undefined, (error: unknown) => { + this.log(`could not ask the server to fetch it: ${error instanceof Error ? error.message : String(error)}`); + }); + } else if (answer === RUN_IN_TERMINAL) { + void vscode.commands.executeCommand(RUN_IN_TERMINAL_COMMAND); + } + } +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 7e5dd40..a5e2030 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -25,6 +25,7 @@ import { TransportKind, } from 'vscode-languageclient/node'; import { CommandLineToolsController, withInstallCommandFallback } from './commandLineTools'; +import { DownloadPromptController } from './downloadPrompt'; import { registerCommands, reloadBuildDescription } from './commands'; import { sendTriggeredCompletion } from './completionGate'; import { checkConflicts, ConflictCheck, watchForNewConflicts } from './conflicts'; @@ -488,6 +489,8 @@ export function activate(context: vscode.ExtensionContext): TestApi { // ServerHost instance without restructuring construction order. let host: ServerHost; const commandLineTools = new CommandLineToolsController(context, (line) => host.log(line)); + const downloadPrompt = new DownloadPromptController(context, (line) => host.log(line)); + status.onUpdate((current) => downloadPrompt.onStatus(current)); let latestConflictCheck: Promise = Promise.resolve('none-found'); // Conflicts can appear or disappear after activation (another extension diff --git a/editors/vscode/src/prompt.ts b/editors/vscode/src/prompt.ts index 4ef09df..2ed4532 100644 --- a/editors/vscode/src/prompt.ts +++ b/editors/vscode/src/prompt.ts @@ -32,7 +32,7 @@ import * as vscode from 'vscode'; // 'turnOffScope' and 'restoreScope' are the quick picks `mcppls.turnOffOtherCppFeatures` and // `mcppls.restoreOtherCppFeatures` (src/conflicts.ts) show for which settings scope to act on. -export type PromptKind = 'conflict' | 'commandLineTools' | 'turnOffScope' | 'restoreScope'; +export type PromptKind = 'conflict' | 'commandLineTools' | 'download' | 'turnOffScope' | 'restoreScope'; const TEST_MODE = process.env.MCPPLS_TEST === '1'; const SUBSTITUTION_GRACE_MS = 5000; diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index 5889cfd..9e9b27c 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -207,8 +207,19 @@ export class StatusController implements vscode.Disposable { } } + // Called with every status the server sends, after the item shows it (downloadPrompt.ts listens). + private readonly listeners: ((status: CxxModulesStatus) => void)[] = []; + onUpdate(listener: (status: CxxModulesStatus) => void): void { + this.listeners.push(listener); + } + update(status: CxxModulesStatus): void { this.current = status; + queueMicrotask(() => { + for (const listener of this.listeners) { + listener(status); + } + }); this.failure = undefined; const label = describeProfile(status.profile); this.item.text = label.length > 0 ? `C++ Modules · ${label}` : 'C++ Modules'; diff --git a/editors/vscode/test/unit/downloadAsk.test.ts b/editors/vscode/test/unit/downloadAsk.test.ts new file mode 100644 index 0000000..3712458 --- /dev/null +++ b/editors/vscode/test/unit/downloadAsk.test.ts @@ -0,0 +1,33 @@ +// When the extension offers to fetch what the build description needs (plan 2026-09-27 B-2, §9.2), in +// plain Node: no VS Code, same reason test/unit/statusText.test.ts is. +import * as assert from 'assert'; +import { needsDownload, shouldAsk } from '../../src/downloadAsk'; + +suite('the build description download offer', () => { + const issue = { code: 'producer-needs-download', message: 'xim:qt-base@6.11.1 is not installed', askOnline: true }; + + test('offered when the server lets a client offer it', () => { + assert.strictEqual(shouldAsk(issue, false, [], false), true); + }); + + test('not offered when the server does not (an older server, askBeforeDownload off, a fetch already running)', () => { + assert.strictEqual(shouldAsk({ ...issue, askOnline: false }, false, [], false), false); + assert.strictEqual(shouldAsk({ code: issue.code, message: issue.message }, false, [], false), false); + }); + + test('never again after "Don\'t Ask Again", and once per set of missing things', () => { + assert.strictEqual(shouldAsk(issue, true, [], false), false); + assert.strictEqual(shouldAsk(issue, false, [issue.message], false), false); + assert.strictEqual(shouldAsk({ ...issue, message: 'fmt is not populated' }, false, [issue.message], false), true); + }); + + test('one open question per root', () => { + assert.strictEqual(shouldAsk(issue, false, [], true), false); + }); + + test('found among the status issues, absent once the build description no longer needs it', () => { + assert.deepStrictEqual(needsDownload({ issues: [{ code: 'model-stale', message: 'x' }, issue] }), issue); + assert.strictEqual(needsDownload({ issues: [{ code: 'model-stale', message: 'x' }] }), undefined); + assert.strictEqual(needsDownload({}), undefined); + }); +}); diff --git a/src/orchestrator/routing.cpp b/src/orchestrator/routing.cpp index 786b565..8dc0513 100644 --- a/src/orchestrator/routing.cpp +++ b/src/orchestrator/routing.cpp @@ -141,7 +141,8 @@ Json merge_capabilities(const Json& engineCapabilities) { if (!capabilities.contains("executeCommandProvider") || !capabilities["executeCommandProvider"].is_object()) capabilities["executeCommandProvider"] = Json::object(); Json& commands = capabilities["executeCommandProvider"]["commands"]; if (!commands.is_array()) commands = Json::array(); - for (const std::string_view command : { "mcppls.review.run", "mcppls.review.clear", "mcppls.reloadBuildDescription", "mcppls.restartEngine", "mcppls.exportBundle" }) { + for (const std::string_view command : { "mcppls.review.run", "mcppls.review.clear", "mcppls.reloadBuildDescription", "mcppls.describeOnline", "mcppls.restartEngine", + "mcppls.exportBundle" }) { if (std::ranges::find(commands, Json(command)) == commands.end()) commands.push_back(std::string { command }); } return capabilities; diff --git a/src/orchestrator/workspace.cpp b/src/orchestrator/workspace.cpp index ace54a6..4b99658 100644 --- a/src/orchestrator/workspace.cpp +++ b/src/orchestrator/workspace.cpp @@ -300,11 +300,22 @@ struct Workspace::Impl final : engine::Host { std::string producerPath; // for the fingerprint of the next save std::string producerVersion; std::string needsDownload; // the producer, run offline, cannot go on without a download + // Plan 2026-09-27 B-2, §9.2: fetching what the build description needs is the person's decision, made once per + // workspace in their editor; `onlineOnce` makes the next load one that may reach the network, and only that one. + bool onlineOnce { false }; + bool describingOnline { false }; // the load running now is that one + // §9.2 rule 4: needing a download is not the end of it. The person may build or install in their own terminal at + // any time; the offline description is asked again after 30 s, 1, 2 and then every 5 minutes, and at once when a + // watched input changes, so the better model comes on its own. + std::size_t downloadRetries { 0 }; + std::optional downloadRetryAt; bool inferredLoadStarted { false }; - // Fix plan F4 (D1): a project whose build system was detected gets clangd with the build tool's model, not a - // provisional one scanned from its sources: until the producer answers, or CORE_WAIT_LIMIT has passed, the - // core engine is given no plan and asked nothing, and mcppls's own engine answers what it can. - static constexpr std::chrono::seconds CORE_WAIT_LIMIT { 60 }; + // Plan 2026-09-27 D5 (revising fix plan F4, D1): with nothing cached, the build tool has FIRST_MODEL_WAIT to + // describe the project; after it, the project is served from its scanned sources (L4) -- mcppls's engine and clangd + // alike -- while the build tool goes on, and its model replaces the provisional one in a single switch that is + // never counted against clangd's restart budget. CORE_WAIT_LIMIT is what clangd waits beyond that: nothing. + static constexpr std::chrono::milliseconds FIRST_MODEL_WAIT { 2500 }; + static constexpr std::chrono::milliseconds CORE_WAIT_LIMIT { 0 }; std::optional coreWaitUntil; bool coreWaitOver { false }; std::optional producerElapsed; // set while the producer is past its soft bound @@ -518,6 +529,7 @@ struct Workspace::Impl final : engine::Host { consider(replanAt); if (!coreWaitOver) consider(coreWaitUntil); consider(loadGiveUpAt); + consider(downloadRetryAt); consider(lastResortAt); consider(producerSoftAt); consider(sdkCheckAt); @@ -887,7 +899,7 @@ struct Workspace::Impl final : engine::Host { auto cached = project::load_model(cacheDirectory, detection.kind); if (!cached) { - loadGiveUpAt = Clock::now() + std::chrono::seconds { 10 }; + loadGiveUpAt = Clock::now() + FIRST_MODEL_WAIT; return; } producerPath = cached->producer; @@ -1038,7 +1050,10 @@ struct Workspace::Impl final : engine::Host { // network; `off` means the build tool is not run at all, and what is cached or scanned is // all there is. Design 4.2: the hard bound is a minute, ten when the user allowed the // network and a download may be part of the answer. - const bool online { options.buildTool == "online" }; + const bool online { options.buildTool == "online" || onlineOnce }; + describingOnline = onlineOnce && options.buildTool != "online"; + if (describingOnline) journal.add("describe-online"); + onlineOnce = false; load.offline = !online; load.runBuildTool = options.buildTool != "off"; load.producerHard = options.producerTimeout.count() > 0 @@ -1089,9 +1104,19 @@ struct Workspace::Impl final : engine::Host { } ++snapshotGeneration; needsDownload.clear(); + describingOnline = false; for (const auto& issue : loadedModel->issues) { if (issue.code == spec::NEEDS_DOWNLOAD) needsDownload = issue.message; } + if (needsDownload.empty()) { + downloadRetries = 0; + downloadRetryAt.reset(); + } else { + static constexpr std::array BACKOFF { std::chrono::seconds { 30 }, std::chrono::seconds { 60 }, + std::chrono::seconds { 120 }, std::chrono::seconds { 300 } }; + downloadRetryAt = Clock::now() + BACKOFF[std::min(downloadRetries, BACKOFF.size() - 1)]; + ++downloadRetries; + } // S2 5-9: a producer that answered before and fails now (or answers with nothing) leaves the last // model in place, and the status says it may be stale, rather than the project falling back to // scanned sources. A project that is no longer that kind of project takes the new model. @@ -1290,7 +1315,7 @@ struct Workspace::Impl final : engine::Host { const bool coreWaits { core_waits_for_producer() }; if (coreWaits && !coreWaitUntil) { coreWaitUntil = Clock::now() + CORE_WAIT_LIMIT; - log::info("clangd waits for {} to describe {} (at most {} s); mcppls's own engine answers meanwhile", project::to_string(detectedSource), root, + log::info("clangd waits for {} to describe {} (at most {} ms more); mcppls's own engine answers meanwhile", project::to_string(detectedSource), root, CORE_WAIT_LIMIT.count()); journal.add("engine-waits-for-producer", Json { { "detected", std::string { project::to_string(detectedSource) } } }); } @@ -1305,6 +1330,9 @@ struct Workspace::Impl final : engine::Host { static constexpr std::chrono::milliseconds REPLAN_DELAY { 800 }; void schedule_replan(std::chrono::milliseconds delay = REPLAN_DELAY) { replanAt = Clock::now() + delay; } + // mcppls.buildDiscovery.askBeforeDownload (plan 2026-09-27 B-7). + bool ask_before_download() const { return true; } + bool core_waits_for_producer() const { return coreEngine != nullptr && model && modelOrigin == "inferred" && loading && !coreWaitOver && detectedSource != project::SourceKind::inferred && options.trusted && options.buildTool != "off"; @@ -1522,10 +1550,21 @@ struct Workspace::Impl final : engine::Host { // model in hand stays; the user decides, in their own terminal, where their proxy and // credentials are (1.2: those live in the terminal session and nothing here can reach them). add("producer-needs-download", - std::format("the build description needs a download: {}. Run the build tool in your terminal, " - "or turn on mcppls.buildTool = online. It may also be an older build tool: updating it is worth trying", + std::format("the build description needs a download: {}. The project is served from its sources meanwhile; " + "fetch it here, or run the build tool in your terminal (the description is read again when that is done). " + "It may also be an older build tool: updating it is worth trying", needsDownload), "mcppls.runBuildToolInTerminal", "Run in Terminal", "environment"); + // Plan 2026-09-27 B-2 (S3): a client that knows `askOnline` may offer, once and without blocking anything + // (§9.2), to fetch it through mcppls.describeOnline; one that does not keeps the terminal action above. + if (!issues.empty() && issues.back().value("code", std::string {}) == "producer-needs-download") { + issues.back()["askOnline"] = ask_before_download() && !describingOnline; + } + } + if (describingOnline && loading) { + add("producer-online", std::format("fetching what the build description of {} needs; the project is served from its sources meanwhile", + base::file_name(root)), + "mcppls.showLogs", "Show Logs", "environment"); } if (producerElapsed) { add("producer-slow", std::format("reading the build description ({}, {} s)", @@ -1630,13 +1669,18 @@ struct Workspace::Impl final : engine::Host { reloadAt.reset(); start_model_load(); } + if (downloadRetryAt && *downloadRetryAt <= now) { + downloadRetryAt.reset(); + log::info("asking {} again, offline, whether it can describe {} now", project::to_string(detectedSource), root); + start_model_load(); + } if (replanAt && *replanAt <= now) replan(); if (coreWaitUntil && !coreWaitOver && *coreWaitUntil <= now) { coreWaitOver = true; if (modelOrigin == "inferred" && model) { - log::warning("{} has not described {} in {} s; clangd starts with the model scanned from its sources", project::to_string(detectedSource), root, - CORE_WAIT_LIMIT.count()); - journal.add("engine-wait-over", Json { { "seconds", CORE_WAIT_LIMIT.count() } }); + log::info("{} has not described {} yet; clangd starts with the model scanned from its sources, and the build tool's replaces it when it comes", + project::to_string(detectedSource), root); + journal.add("engine-wait-over", Json { { "milliseconds", (FIRST_MODEL_WAIT + CORE_WAIT_LIMIT).count() } }); replan(); } } @@ -2123,6 +2167,16 @@ void Workspace::handle_tool_run(const Json& record) { impl_->journal.add("tool-run", record); } +bool Workspace::describe_online() { + if (impl_->needsDownload.empty() || impl_->describingOnline) return false; + log::info("fetching what the build description of {} needs, as asked", impl_->root); + impl_->onlineOnce = true; + impl_->downloadRetryAt.reset(); + impl_->start_model_load(); + impl_->update_status(); + return true; +} + void Workspace::reload_build_description() { // Only when something is actually waiting on it. A window regaining focus is not news, and a // build tool run for every focus change is exactly the implicit work this design removed. diff --git a/src/orchestrator/workspace.cppm b/src/orchestrator/workspace.cppm index ad6ad51..bf9ff03 100644 --- a/src/orchestrator/workspace.cppm +++ b/src/orchestrator/workspace.cppm @@ -147,6 +147,9 @@ public: // Reads the build description again, offline. What the user does about a needed download // happens outside this server, so the only way to learn it worked is to look again. void reload_build_description(); + // Plan 2026-09-27 B-2: describe the project once more, this time letting the build tool reach the network + // (the person's choice, from the editor). Later loads are offline again. False: nothing needs a download. + bool describe_online(); void clear_review(); // ---- background events ------------------------------------------------------------ diff --git a/src/server/session.cpp b/src/server/session.cpp index 19fe397..dfb8ac5 100644 --- a/src/server/session.cpp +++ b/src/server/session.cpp @@ -229,6 +229,14 @@ class Session { export_bundle_(id, params.value("arguments", Json::array())); return; } + // Plan 2026-09-27 B-2: the person accepted, in their editor, to fetch what the build description needs. + // Every root that needs a download describes itself once with the network; the answer says how many did. + if (method == lsp::method::WORKSPACE_EXECUTE_COMMAND && params.value("command", std::string {}) == "mcppls.describeOnline") { + std::size_t started { 0 }; + for (auto& root : roots_) started += root->describe_online() ? 1 : 0; + reply_(id, Json { { "started", started } }); + return; + } if (method == lsp::method::WORKSPACE_EXECUTE_COMMAND && params.value("command", std::string {}) == "mcppls.reloadBuildDescription") { for (auto& root : roots_) root->reload_build_description(); reply_(id, nullptr); From 4f07d32342e9748bbdc694379f13213fbcf88606 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:34:23 +0800 Subject: [PATCH 06/25] test(conformance): a download offered, fetched on request, or completed in the person's own terminal mcpp-emit-needs-download now checks that its issue lets a client offer to fetch what is missing and that mcppls.describeOnline describes the project once with the network, after which the model is mcpp's. The new mcpp-emit-provisioned answers no question at all: the person builds in their own terminal, which writes mcpp.lock, and the server upgrades from scanned sources to mcpp's model by itself. The mock producer learns an online answer and a file that, once present, means what was missing has been fetched; the status check learns issue-ask-online. --- .github/workflows/ci.yml | 2 +- conformance/README.md | 2 + .../mcpp-emit-needs-download/mcpp-mock.json | 275 ++++++++++++++++- .../mcpp-emit-needs-download/scenario.json | 24 +- .../mcpp-emit-provisioned/mcpp-mock.json | 284 ++++++++++++++++++ .../fixtures/mcpp-emit-provisioned/mcpp.toml | 8 + .../mcpp-emit-provisioned/scenario.json | 32 ++ .../src/greet/detail.cppm | 5 + .../src/greet/greet.cppm | 7 + .../mcpp-emit-provisioned/src/main.cpp | 7 + src/bin/conformance.cpp | 4 + src/bin/mockmcpp.cpp | 11 + 12 files changed, 657 insertions(+), 4 deletions(-) create mode 100644 conformance/fixtures/mcpp-emit-provisioned/mcpp-mock.json create mode 100644 conformance/fixtures/mcpp-emit-provisioned/mcpp.toml create mode 100644 conformance/fixtures/mcpp-emit-provisioned/scenario.json create mode 100644 conformance/fixtures/mcpp-emit-provisioned/src/greet/detail.cppm create mode 100644 conformance/fixtures/mcpp-emit-provisioned/src/greet/greet.cppm create mode 100644 conformance/fixtures/mcpp-emit-provisioned/src/main.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45e16d8..52e9fc2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,7 +282,7 @@ jobs: part: 2 of 2 os: ubuntu-24.04 extras: true - fixtures: inferred inferred-bom engine-none module-faults untrusted mcpp-gcc mcpp-gcc@plain mcpp-emit-package-std mcpp-emit-broken mcpp-emit-unavailable mcpp-emit-hang mcpp-emit-needs-download mcpp-emit-watch mcpp-emit-watch@polling cmake-clang cmake-clang-bdb watch-polling payload-corrupt clangd-cannot-load generated-module generated-module@vscode generated-module-old-mcpp generated-module-old-mcpp@neovim module-faults@zed failure-at-base failure-at-base@neovim completion-keywords diagnostic-bundle clangd-crash-context mcpp-emit-wait inferred-cxx26 + fixtures: inferred inferred-bom engine-none module-faults untrusted mcpp-gcc mcpp-gcc@plain mcpp-emit-package-std mcpp-emit-broken mcpp-emit-unavailable mcpp-emit-hang mcpp-emit-needs-download mcpp-emit-provisioned mcpp-emit-watch mcpp-emit-watch@polling cmake-clang cmake-clang-bdb watch-polling payload-corrupt clangd-cannot-load generated-module generated-module@vscode generated-module-old-mcpp generated-module-old-mcpp@neovim module-faults@zed failure-at-base failure-at-base@neovim completion-keywords diagnostic-bundle clangd-crash-context mcpp-emit-wait inferred-cxx26 # generated-module{,-old-mcpp,-negotiated}'s mcpp-mock.json bakes in a POSIX driver path # (${env:HOME}/.mcpp/registry/..., no {exe}); it resolves the same way here as on Linux, so # these run on macOS but are left off win32-x64 below rather than fixed unverified. diff --git a/conformance/README.md b/conformance/README.md index 2f0f08d..750a4d6 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -36,6 +36,8 @@ checks fail at once with that reason instead of each waiting out its timeout. | `mcpp-emit-broken` | The same mcpp answering with an error: the status carries mcpp's own diagnostic, sources are scanned meanwhile, and nothing is configured in its place (the mock's `build` would leave a `compile_commands.json` that `workspace-unchanged` sees) | | `mcpp-emit-partial` | S2 0.3.0 (S2-3.4-12, S2-3.4-13; mcpp-community/mcpp#699, fix plan 2026-09-26 F10): the producer answers with every member it could plan and an `error` diagnostic whose `path` names the one it could not, exiting 1. The rest is used (the model is mcpp's, not scanned sources) and the status names the missing part (`producer-partial`) | | `mcpp-emit-unavailable` | A project whose `.xlings.json` asks for an mcpp that is not installed: xlings answers every mcpp command in its place and runs nothing, and the status carries xlings's explanation (`mcpp-no-database`) while sources are scanned | +| `mcpp-emit-needs-download` | The producer, run offline, needs a download: the status says what is missing, offers the terminal and lets a client offer to fetch it (`askOnline`, S3-4-16); `mcppls.describeOnline` then describes the project once with the network (the mock's `online` answer) and the model is mcpp's; nothing is written into the project | +| `mcpp-emit-provisioned` | Plan 2026-09-27 §9.2 rule 4: the offline description needs a download and nobody answers the offer; the person builds in their own terminal instead (the check writes `mcpp.lock`), and the server, seeing a watched build input change, asks the producer again offline and upgrades from scanned sources to mcpp's model by itself (the mock's `provisionedWhen`) | | `mcpp-emit-watch` | The inputs mcpp names in `watch` (S2 5, S2-5-1): writing one loads the model again, and an unchanged answer is recognized without rebuilding the index; when mcpp then fails the last model is kept, `degraded` with `model-stale` (S2-5-9), until it answers again. CI also runs it as `mcpp-emit-watch@polling`, with `--no-dynamic-watch` | | `mingw` | A compile database for `x86_64-windows-gnu`: MinGW-w64 GCC semantics through `--sysroot` (P2) | | `cmake-clang` | CMake 3.28+ with `FILE_SET CXX_MODULES`, Clang and Ninja: `@modmap` expansion, partitions | diff --git a/conformance/fixtures/mcpp-emit-needs-download/mcpp-mock.json b/conformance/fixtures/mcpp-emit-needs-download/mcpp-mock.json index b450e76..aec96a6 100644 --- a/conformance/fixtures/mcpp-emit-needs-download/mcpp-mock.json +++ b/conformance/fixtures/mcpp-emit-needs-download/mcpp-mock.json @@ -6,5 +6,278 @@ "source": "mcpp", "message": "offline mode: dependency 'compat.eigen' v5.0.1 is not installed and cannot be downloaded\n run without --offline (or unset MCPP_OFFLINE) to fetch it" } - ] + ], + "online": { + "database": { + "ide": { + "generator": { + "name": "mcpp", + "version": "2026.9.15.1" + }, + "profile-version": "0.2.0", + "toolchains": { + "llvm-22.1.8-x86_64-unknown-linux-gnu": { + "config-files": [], + "driver": "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "family": "clang", + "stdlib": { + "name": "libc++", + "version": "22.1.8" + }, + "target": "x86_64-unknown-linux-gnu", + "version": "22.1.8" + } + } + }, + "revision": 0, + "sets": [ + { + "baseline-arguments": [ + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include" + ], + "family-name": "hello", + "ide": { + "configuration": "dev", + "kind": "executable", + "toolchain": "llvm-22.1.8-x86_64-unknown-linux-gnu" + }, + "name": "hello", + "translation-units": [ + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-c", + "${root}/src/greet/detail.cppm", + "-o", + "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/detail.m.o" + ], + "ide": { + "role": "module-partition-interface" + }, + "local-arguments": [], + "object": "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/detail.m.o", + "private": false, + "provides": { + "hello.greet:detail": "" + }, + "requires": [ + "std" + ], + "source": "${root}/src/greet/detail.cppm", + "work-directory": "${root}" + }, + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-c", + "${root}/src/greet/greet.cppm", + "-o", + "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/greet.m.o" + ], + "ide": { + "role": "module-interface" + }, + "local-arguments": [], + "object": "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/greet.m.o", + "private": false, + "provides": { + "hello.greet": "" + }, + "requires": [ + "hello.greet:detail", + "std" + ], + "source": "${root}/src/greet/greet.cppm", + "work-directory": "${root}" + }, + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-c", + "${root}/src/main.cpp", + "-o", + "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/main.o" + ], + "ide": { + "role": "non-module" + }, + "local-arguments": [], + "object": "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/main.o", + "private": false, + "provides": {}, + "requires": [ + "std", + "hello.greet" + ], + "source": "${root}/src/main.cpp", + "work-directory": "${root}" + } + ], + "visible-sets": [ + "mcpp:std" + ] + }, + { + "baseline-arguments": [ + "-std=c++23", + "-Wno-reserved-module-identifier", + "--no-default-config", + "-nostdinc++", + "-stdlib=libc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include" + ], + "family-name": "mcpp:std", + "ide": { + "configuration": "dev", + "kind": "library", + "toolchain": "llvm-22.1.8-x86_64-unknown-linux-gnu" + }, + "name": "mcpp:std", + "translation-units": [ + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-Wno-reserved-module-identifier", + "--no-default-config", + "-nostdinc++", + "-stdlib=libc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.cppm", + "-o", + "pcm.cache/std.pcm" + ], + "ide": { + "role": "module-interface" + }, + "local-arguments": [ + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.cppm", + "-o", + "pcm.cache/std.pcm" + ], + "object": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/std.o", + "private": false, + "provides": { + "std": "" + }, + "requires": [], + "source": "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.cppm", + "work-directory": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73" + }, + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-Wno-reserved-module-identifier", + "--no-default-config", + "-nostdinc++", + "-stdlib=libc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-fmodule-file=std=${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.pcm", + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm", + "-o", + "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.compat.pcm" + ], + "ide": { + "role": "module-interface" + }, + "local-arguments": [ + "-fmodule-file=std=${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.pcm", + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm", + "-o", + "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.compat.pcm" + ], + "object": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/std.compat.o", + "private": false, + "provides": { + "std.compat": "" + }, + "requires": [ + "std" + ], + "source": "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm", + "work-directory": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73" + } + ], + "visible-sets": [ + "hello" + ] + } + ], + "version": 1 + }, + "watch": [ + "${env:HOME}/.mcpp/config.toml", + "mcpp.lock", + "mcpp.toml", + "src/**/*.S", + "src/**/*.asm", + "src/**/*.c", + "src/**/*.cc", + "src/**/*.cpp", + "src/**/*.cppm", + "src/**/*.s", + "tests/**/*.cpp" + ] + } } diff --git a/conformance/fixtures/mcpp-emit-needs-download/scenario.json b/conformance/fixtures/mcpp-emit-needs-download/scenario.json index a41436f..437c6a2 100644 --- a/conformance/fixtures/mcpp-emit-needs-download/scenario.json +++ b/conformance/fixtures/mcpp-emit-needs-download/scenario.json @@ -1,6 +1,6 @@ { "name": "mcpp-emit-needs-download", - "description": "Build description design 4.4: the producer ran offline, as an implicit run must, and cannot describe the build without fetching a dependency. That is not a failure of the producer and not a reason to go quiet: the status says what is missing and offers to run the build tool where the user's own proxy and credentials are, the sources are scanned meanwhile, and nothing is written into the project.", + "description": "Build description design 4.4: the producer ran offline, as an implicit run must, and cannot describe the build without fetching a dependency. That is not a failure of the producer and not a reason to go quiet: the status says what is missing and offers to run the build tool where the user's own proxy and credentials are, the sources are scanned meanwhile, and nothing is written into the project. Plan 2026-09-27 B-2: the issue lets a client offer to fetch what is missing (askOnline), and when the person accepts, mcppls.describeOnline describes the project once with the network: the model is mcpp's, and nothing is written into the project either.", "server-arguments": [ "--mcpp", "{runner-dir}/mcppls-mock-mcpp{exe}" @@ -25,10 +25,30 @@ "id": "D3-navigation-works-from-scanned-sources-meanwhile", "kind": "definition", "file": "src/main.cpp", - "at": [4, 31], + "at": [ + 4, + 31 + ], "expect": "src/greet/greet.cppm", "timeout": 60 }, + { + "id": "D5-a-client-may-offer-to-fetch-it", + "kind": "status", + "issue-code": "producer-needs-download", + "issue-ask-online": true + }, + { + "id": "D6-the-person-accepts", + "kind": "execute-command", + "command": "mcppls.describeOnline" + }, + { + "id": "D7-the-model-is-mcpp-s-once-fetched", + "kind": "status", + "source": "mcpp", + "timeout": 60 + }, { "id": "D4-nothing-was-written-into-the-project", "kind": "workspace-unchanged" diff --git a/conformance/fixtures/mcpp-emit-provisioned/mcpp-mock.json b/conformance/fixtures/mcpp-emit-provisioned/mcpp-mock.json new file mode 100644 index 0000000..722a636 --- /dev/null +++ b/conformance/fixtures/mcpp-emit-provisioned/mcpp-mock.json @@ -0,0 +1,284 @@ +{ + "diagnostics": [ + { + "code": "MCPP_OFFLINE_DOWNLOAD_REQUIRED", + "severity": "error", + "source": "mcpp", + "message": "offline mode: dependency 'compat.eigen' v5.0.1 is not installed and cannot be downloaded\n run without --offline (or unset MCPP_OFFLINE) to fetch it" + } + ], + "online": { + "database": { + "ide": { + "generator": { + "name": "mcpp", + "version": "2026.9.15.1" + }, + "profile-version": "0.2.0", + "toolchains": { + "llvm-22.1.8-x86_64-unknown-linux-gnu": { + "config-files": [], + "driver": "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "family": "clang", + "stdlib": { + "name": "libc++", + "version": "22.1.8" + }, + "target": "x86_64-unknown-linux-gnu", + "version": "22.1.8" + } + } + }, + "revision": 0, + "sets": [ + { + "baseline-arguments": [ + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include" + ], + "family-name": "hello", + "ide": { + "configuration": "dev", + "kind": "executable", + "toolchain": "llvm-22.1.8-x86_64-unknown-linux-gnu" + }, + "name": "hello", + "translation-units": [ + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-c", + "${root}/src/greet/detail.cppm", + "-o", + "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/detail.m.o" + ], + "ide": { + "role": "module-partition-interface" + }, + "local-arguments": [], + "object": "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/detail.m.o", + "private": false, + "provides": { + "hello.greet:detail": "" + }, + "requires": [ + "std" + ], + "source": "${root}/src/greet/detail.cppm", + "work-directory": "${root}" + }, + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-c", + "${root}/src/greet/greet.cppm", + "-o", + "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/greet.m.o" + ], + "ide": { + "role": "module-interface" + }, + "local-arguments": [], + "object": "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/greet.m.o", + "private": false, + "provides": { + "hello.greet": "" + }, + "requires": [ + "hello.greet:detail", + "std" + ], + "source": "${root}/src/greet/greet.cppm", + "work-directory": "${root}" + }, + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-fmodule-file=std=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.pcm", + "-fmodule-file=std.compat=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache/std.compat.pcm", + "-fprebuilt-module-path=${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/pcm.cache", + "-O0", + "-g", + "--no-default-config", + "-nostdinc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-c", + "${root}/src/main.cpp", + "-o", + "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/main.o" + ], + "ide": { + "role": "non-module" + }, + "local-arguments": [], + "object": "${env:HOME}/.mcpp/cache/build-database/00ee0ef06d348b25/target/x86_64-linux-gnu/0ba60b7727c1b097/obj/main.o", + "private": false, + "provides": {}, + "requires": [ + "std", + "hello.greet" + ], + "source": "${root}/src/main.cpp", + "work-directory": "${root}" + } + ], + "visible-sets": [ + "mcpp:std" + ] + }, + { + "baseline-arguments": [ + "-std=c++23", + "-Wno-reserved-module-identifier", + "--no-default-config", + "-nostdinc++", + "-stdlib=libc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include" + ], + "family-name": "mcpp:std", + "ide": { + "configuration": "dev", + "kind": "library", + "toolchain": "llvm-22.1.8-x86_64-unknown-linux-gnu" + }, + "name": "mcpp:std", + "translation-units": [ + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-Wno-reserved-module-identifier", + "--no-default-config", + "-nostdinc++", + "-stdlib=libc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.cppm", + "-o", + "pcm.cache/std.pcm" + ], + "ide": { + "role": "module-interface" + }, + "local-arguments": [ + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.cppm", + "-o", + "pcm.cache/std.pcm" + ], + "object": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/std.o", + "private": false, + "provides": { + "std": "" + }, + "requires": [], + "source": "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.cppm", + "work-directory": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73" + }, + { + "arguments": [ + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", + "-std=c++23", + "-Wno-reserved-module-identifier", + "--no-default-config", + "-nostdinc++", + "-stdlib=libc++", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/include/x86_64-unknown-linux-gnu/c++/v1", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-glibc/2.44/include", + "-isystem${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-linux-headers/5.11.1/include", + "-fmodule-file=std=${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.pcm", + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm", + "-o", + "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.compat.pcm" + ], + "ide": { + "role": "module-interface" + }, + "local-arguments": [ + "-fmodule-file=std=${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.pcm", + "--precompile", + "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm", + "-o", + "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/pcm.cache/std.compat.pcm" + ], + "object": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73/std.compat.o", + "private": false, + "provides": { + "std.compat": "" + }, + "requires": [ + "std" + ], + "source": "${env:HOME}/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/share/libc++/v1/std.compat.cppm", + "work-directory": "${env:HOME}/.mcpp/build-cache/v1/std/c6af27b46156bf73" + } + ], + "visible-sets": [ + "hello" + ] + } + ], + "version": 1 + }, + "watch": [ + "${env:HOME}/.mcpp/config.toml", + "mcpp.lock", + "mcpp.toml", + "src/**/*.S", + "src/**/*.asm", + "src/**/*.c", + "src/**/*.cc", + "src/**/*.cpp", + "src/**/*.cppm", + "src/**/*.s", + "tests/**/*.cpp" + ] + }, + "provisionedWhen": "mcpp.lock" +} diff --git a/conformance/fixtures/mcpp-emit-provisioned/mcpp.toml b/conformance/fixtures/mcpp-emit-provisioned/mcpp.toml new file mode 100644 index 0000000..cd9aefc --- /dev/null +++ b/conformance/fixtures/mcpp-emit-provisioned/mcpp.toml @@ -0,0 +1,8 @@ +[package] +name = "hello" +version = "0.1.0" +description = "Conformance fixture: mcpp-emit-provisioned" +license = "Apache-2.0" + +[toolchain] +default = "llvm@22.1.8" diff --git a/conformance/fixtures/mcpp-emit-provisioned/scenario.json b/conformance/fixtures/mcpp-emit-provisioned/scenario.json new file mode 100644 index 0000000..91d9467 --- /dev/null +++ b/conformance/fixtures/mcpp-emit-provisioned/scenario.json @@ -0,0 +1,32 @@ +{ + "name": "mcpp-emit-provisioned", + "description": "Plan 2026-09-27 \u00a79.2 rule 4: the offline description needs a download, the person answers no question and instead builds the project in their own terminal, which fetches what was missing and writes mcpp.lock. The server notices by itself -- a watched build input changed -- asks the producer again, still offline, and the project upgrades from its scanned sources to mcpp's model with nothing clicked.", + "server-arguments": [ + "--mcpp", + "{runner-dir}/mcppls-mock-mcpp{exe}" + ], + "checks": [ + { + "id": "P1-a-download-is-needed", + "kind": "status", + "source": "inferred", + "issue-code": "producer-needs-download" + }, + { + "id": "P2-nothing-was-written-into-the-project", + "kind": "workspace-unchanged" + }, + { + "id": "P3-the-person-builds-in-their-terminal", + "kind": "write-file", + "file": "mcpp.lock", + "content": "# written by the person's own mcpp build\nversion = 2\n" + }, + { + "id": "P4-the-project-upgrades-by-itself", + "kind": "status", + "source": "mcpp", + "timeout": 60 + } + ] +} diff --git a/conformance/fixtures/mcpp-emit-provisioned/src/greet/detail.cppm b/conformance/fixtures/mcpp-emit-provisioned/src/greet/detail.cppm new file mode 100644 index 0000000..56cb780 --- /dev/null +++ b/conformance/fixtures/mcpp-emit-provisioned/src/greet/detail.cppm @@ -0,0 +1,5 @@ +export module hello.greet:detail; +import std; +export namespace hello::detail { + std::string prefix() { return "Hello, "; } +} diff --git a/conformance/fixtures/mcpp-emit-provisioned/src/greet/greet.cppm b/conformance/fixtures/mcpp-emit-provisioned/src/greet/greet.cppm new file mode 100644 index 0000000..21a6536 --- /dev/null +++ b/conformance/fixtures/mcpp-emit-provisioned/src/greet/greet.cppm @@ -0,0 +1,7 @@ +export module hello.greet; +export import :detail; +import std; + +export namespace hello { + std::string greet(std::string_view who) { return detail::prefix() + std::string(who); } +} diff --git a/conformance/fixtures/mcpp-emit-provisioned/src/main.cpp b/conformance/fixtures/mcpp-emit-provisioned/src/main.cpp new file mode 100644 index 0000000..71169db --- /dev/null +++ b/conformance/fixtures/mcpp-emit-provisioned/src/main.cpp @@ -0,0 +1,7 @@ +import std; +import hello.greet; + +int main(int argc, char* argv[]) { + std::println("{}", hello::greet("mcpp")); + return 0; +} diff --git a/src/bin/conformance.cpp b/src/bin/conformance.cpp index 0a126af..a464816 100644 --- a/src/bin/conformance.cpp +++ b/src/bin/conformance.cpp @@ -1113,8 +1113,12 @@ class Scenario { const std::string wantedCommand { check.value("issue-command", std::string {}) }; const std::string wantedMessage { check.value("issue-message", std::string {}) }; // a part of the message const std::string wantedCategory { check.value("issue-category", std::string {}) }; // S3: code | engine | environment | project + // S3-4-16 (plan 2026-09-27 B-2): whether the issue lets a client offer to fetch what is missing. + const std::optional wantedAskOnline { check.contains("issue-ask-online") ? std::optional { check.value("issue-ask-online", false) } + : std::nullopt }; matched = matched && std::ranges::any_of(snapshot.value("issues", Json::array()), [&](const Json& issue) { if (issue.value("code", std::string {}) != issueCode->get()) return false; + if (wantedAskOnline && issue.value("askOnline", false) != *wantedAskOnline) return false; if (!wantedMessage.empty() && !issue.value("message", std::string {}).contains(wantedMessage)) return false; if (!wantedCategory.empty() && issue.value("category", std::string {}) != wantedCategory) return false; return wantedCommand.empty() || issue.value("command", Json::object()).value("command", std::string {}) == wantedCommand; diff --git a/src/bin/mockmcpp.cpp b/src/bin/mockmcpp.cpp index 94e810f..5bb57c1 100644 --- a/src/bin/mockmcpp.cpp +++ b/src/bin/mockmcpp.cpp @@ -181,6 +181,17 @@ int emit_build_database(std::span arguments) { return 1; } } + // Plan 2026-09-27 B-2: {"online": {...}} is what the producer answers when it may reach the network (no + // MCPP_OFFLINE): a fixture whose offline answer needs a download describes the project once the person lets it. + // {"provisionedWhen": ""}: once exists in the project (a build the person ran in their own terminal + // wrote it), the offline answer is the "online" one too -- what they fetched is there now (§9.2 rule 4). + const bool provisioned { recorded.contains("provisionedWhen") && recorded["provisionedWhen"].is_string() + && fs::exists(base::join_path(root, recorded["provisionedWhen"].get())) }; + if (const auto offline = mcppls::platform::env::get("MCPP_OFFLINE"); (provisioned || !offline || offline->empty() || *offline == "0") + && recorded.contains("online") && recorded["online"].is_object()) { + Json online = recorded["online"]; + recorded = std::move(online); + } expand_all(recorded, root); if (!recorded.contains("database")) { std::println("{}", envelope("mcpp.build-database", nullptr, recorded.value("diagnostics", Json::array()), Json::array({ "read-project" })).dump(2)); From 55c7135092a180c45e605a7b6a914248276e26c0 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 27 Sep 2026 19:40:11 +0800 Subject: [PATCH 07/25] feat(config): every configurable behaviour of mcppls has exactly one definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.0.6 plan §9 T1/T1a/T1b: a new module mcppls.config.settings (src/config/settings.cppm + .cpp) holds one registry row per setting -- key, kind, allowed values, default, command-line spelling, surface, how a change applies, category, version, an English and a Chinese summary, and aliases -- and a Settings type that resolves the command line, initializationOptions and workspace/didChangeConfiguration over it in that precedence, falling back to the default and recording a problem for anything outside a row's own vocabulary rather than ever letting an unrecognized value take effect. mcppls.cli.commands now declares its global options by iterating the registry instead of listing them by hand, and forwards them to a daemon the same way (which also fixes --producer-timeout never reaching one); cli::options::session_options fills SessionOptions from the registry's command-line layer; server::Session layers initializationOptions onto the very same Settings object at handle_initialize_, and a new handle_configuration_change_ applies workspace/didChangeConfiguration, reloading every root's model (Workspace::reload_with_options, a small addition alongside the existing schedule_reload) for a `reload`-applies setting and logging that a restart is needed for a `restart` one. SessionOptions gains fields for two settings new in 0.0.6 that other tasks implement the behaviour of: buildDiscovery (+ .providers, .askBeforeDownload) and index.primeImplementationUnits, plus compiler and semanticKit now that they have a command-line spelling too (--compiler, --semantic-kit). mcppls report gains a `settings` object (value, origin and problems per key), and a new `mcppls settings [--format markdown|json] [--lang en|zh-CN]` subcommand prints the registry as the reference table or its machine form. docs/30-settings.md and its zh-CN mirror replace their hand-written tables with the exact output of `mcppls settings --format markdown`, between `` markers, with new prose on precedence, what `applies` means, and how a renamed setting would keep working. editors/vscode/package.json gets the four new settings (mcppls.buildDiscovery and its two children, mcppls.index.primeImplementationUnits); extension.ts passes them in initializationOptions and restarts the server when any of them changes, same as the existing ones. tests/test_settings.cpp proves the registry's own invariants (unique keys and aliases, defaults inside their vocabulary), precedence and origins across all three layers, every accepted initializationOptions/didChangeConfiguration shape (nested, dotted, mcppls-wrapped), an unknown enumeration value falling back to the default, an alias still working, the generated docs matching the renderer byte for byte, and every mcppls.* property of package.json matching a registry row that expects to be there (and no server-only row that does not). Decided: which server rows are command-line only rather than a VS Code setting -- payload/clangd/kit/mcpp/database/untrusted/discoverCompilers/logLevel/disableWorkaround/ producerTimeout/requestTimeout, plus semanticTokens.moduleType (VS Code's own extension always declares it, fixed) -- is recorded on each row (Setting::clientConfigurable) and proven by the same test. Zed's and CLion's docs mention no settings today, so neither needed a change. Not done here: the actual behaviour behind buildDiscovery and primeImplementationUnits belongs to other 0.0.6 tasks (T5, T7-T10); this only carries the registry, the plumbing, and the fields they will read. --- docs/30-settings.md | 115 +++-- docs/zh-CN/30-settings.md | 110 +++-- editors/vscode/package.json | 52 +++ editors/vscode/src/extension.ts | 19 +- src/cli/commands.cpp | 59 ++- src/cli/options.cpp | 57 +-- src/cli/settings.cpp | 40 ++ src/cli/settings.cppm | 16 + src/config/settings.cpp | 744 ++++++++++++++++++++++++++++++++ src/config/settings.cppm | 163 +++++++ src/orchestrator/report.cpp | 3 +- src/orchestrator/report.cppm | 5 +- src/orchestrator/workspace.cpp | 14 + src/orchestrator/workspace.cppm | 32 +- src/server/session.cpp | 85 ++-- tests/test_settings.cpp | 234 ++++++++++ 16 files changed, 1618 insertions(+), 130 deletions(-) create mode 100644 src/cli/settings.cpp create mode 100644 src/cli/settings.cppm create mode 100644 src/config/settings.cpp create mode 100644 src/config/settings.cppm create mode 100644 tests/test_settings.cpp diff --git a/docs/30-settings.md b/docs/30-settings.md index e9fcda4..cb7befc 100644 --- a/docs/30-settings.md +++ b/docs/30-settings.md @@ -1,19 +1,91 @@ # Settings and the command line -## VS Code settings - -| Setting | Values | What it does | -|---|---|---| -| `mcppls.buildTool` | `offline` (default), `online`, `off` | How the project's build tool may be run. `offline`: run it without the network — if it then cannot describe the build without downloading something, the status says what is missing and offers to run it in your terminal. `online`: let it reach the network, with ten minutes instead of one. `off`: never run it; use the cached description or scanned sources | -| `mcppls.toolEnvironment` | `auto` (default), `editor` | Which environment build tools are started in. `auto` reads your login shell's environment once, in the background, on POSIX — an editor started from a desktop entry or a Dock icon carries none of your shell configuration, so without this the build tool it finds may not be the one your terminal finds. On Windows the editor's environment already matches the terminal's. `editor` always uses the editor process's environment | -| `mcppls.compiler` | a driver path, or `kit` | Use this compiler for module semantics instead of what was detected. `kit` forces the bundled semantic kit | -| `mcppls.semanticKit` | `auto` (default), `off` | Whether the bundled kit may be used at all | -| `mcppls.engine` | `clangd` (default), `none` | The core engine. mcppls's own module engine runs either way; `none` means module features only | -| `mcppls.ai.enabled` | `false` (default) | Whether the model-backed half of change review may be used. Off means the server makes no model calls | -| `mcppls.detectConflicts` | `true` (default) | Offer once to turn off another C++ extension's language features in this workspace, and say so when one becomes active later | -| `mcppls.semanticTokens.modules` | `true` (default) | Color `import`, `module`, `export` and module names from the server's semantic tokens. Off: only the grammar's colors | -| `mcppls.completion.triggerOnSpace` | `true` (default) | Show the module list as soon as a space is typed after `import` or `export import`. A space anywhere else never reaches the server. Other editors ask for the same with `initializationOptions.completion.triggerOnSpace` | -| `mcppls.trace.server` | `off` (default), `messages`, `verbose` | Log the LSP traffic to the C++ Modules output channel (at Trace level); `verbose` adds the server's debug log (at Debug level). Set the channel's log level to see them | +Every configurable behaviour of mcppls has exactly one definition: a row of the registry in +`src/config/settings.cppm` (0.0.6 plan §9 T1). The table below -- the VS Code settings, the +command-line options, and the environment variables that configure something -- is generated from +that registry (`mcppls settings --format markdown`), and `tests/test_settings.cpp` holds it, the +zh-CN mirror and `editors/vscode/package.json` to the registry so the three cannot drift apart. + +**Precedence.** The command line beats `initializationOptions` beats the default; a later +`workspace/didChangeConfiguration` updates a value `initializationOptions` (or an earlier +`didChangeConfiguration`) set, but never one the command line set. A value outside its own +vocabulary (an unknown enumeration member, say) is never applied -- it falls back to the default +and is recorded as a problem (`mcppls report`'s `settings.problems`, and `mcppls settings` itself +carries none since it only ever prints the registry). + +**Applies.** How a change to a setting already running takes effect: `restart` needs mcppls +restarted (the VS Code extension already does this for the settings below that need it); +`reload` only reloads the project's model, which happens without a restart; `immediately` needs +neither -- the next time it is read is the next time it matters. + +**A renamed setting keeps working.** A setting the registry gives an alias for is still accepted +under its earlier name (dotted key or `initializationOptions`/`didChangeConfiguration` form alike); +none of the settings below have been renamed yet, so none carry one today. + +`initializationOptions` and `didChangeConfiguration` both accept a nested object +(`{"semanticTokens": {"modules": false}}`) or a dotted key (`{"semanticTokens.modules": false}`), +either wrapped in a top-level `mcppls` object or not. + + +### Project and build tools + +| Setting | Values | Default | Command line | Applies | What it does | +|---|---|---|---|---|---| +| `mcppls.buildTool` | `offline`, `online`, `off` | `offline` | `--build-tool` | reload | How the project's build tool may be run. `offline`: run it without the network -- if it then cannot describe the build without downloading something, the status says what is missing and offers to run it in your terminal. `online`: let it reach the network, with ten minutes instead of one. `off`: never run it; the build system is still detected and its own generated files are still read (see `buildDiscovery` for turning that off too). | +| `mcppls.toolEnvironment` | `auto`, `editor` | `auto` | `--tool-environment` | restart | Which environment build tools are started in. `auto` reads your login shell's environment once, in the background, on POSIX -- an editor started from a desktop entry or a Dock icon carries none of your shell configuration, so without this the build tool it finds may not be the one your terminal finds. On Windows the editor's environment already matches the terminal's. `editor` always uses the editor process's environment. | +| `mcppls.producerTimeout` | a non-negative number of seconds | `0` | `--producer-timeout` | reload | How long a build tool may take to describe the project. `0`, the default, uses the design's own bound (a minute offline, ten minutes once `buildTool` is `online`); set it to watch that bound work, or longer for a genuinely slower build. | +| `mcppls.untrusted` | `true`, `false` | `false` | `--untrusted` | restart | Run no build tool and no compiler; an untrusted workspace is also read as though `buildDiscovery` were `off`. | +| `mcppls.discoverCompilers` | `true`, `false` | `true` | `--no-discover` | reload | Look for a compiler on the machine for a source the build description does not cover. Off: such a source uses the semantic kit instead. | +| `mcppls.buildDiscovery` | `auto`, `off` | `auto` | `--build-discovery` | reload | Whether the project's build system is detected at all (0.0.6 plan §3.7 B-7). `off`: nothing is read or run implicitly -- only an explicitly configured `database`, else sources are scanned. `buildTool` still governs whether a detected build tool may be *run*; this governs whether it is looked for in the first place. | +| `mcppls.buildDiscovery.providers` | `mcpp`, `cmake`, `xmake`, `meson`, `compile-commands` (comma-separated) | `mcpp`, `cmake`, `xmake`, `meson`, `compile-commands` | `--build-discovery-providers` | reload | Which build system providers `buildDiscovery` may use; leave one out to stop mcppls from detecting it (for example, to use only a CMake build directory that already exists and never let xmake run). | +| `mcppls.buildDiscovery.askBeforeDownload` | `true`, `false` | `true` | — | immediately | When the build tool needs a download to finish describing the project, a client may offer to fetch it. Off: the status says a download is needed, and nothing asks. | + +### Engines + +| Setting | Values | Default | Command line | Applies | What it does | +|---|---|---|---|---|---| +| `mcppls.engine` | `clangd`, `none` | `clangd` | `--engine` | restart | The core semantic engine. mcppls's own module engine always runs beside it; `none` means module-level features only. | +| `mcppls.compiler` | a string | *(empty)* | `--compiler` | reload | Use this compiler for module semantics instead of what was detected: an absolute path, a name on `PATH`, or `kit` to force the bundled semantic kit. Empty means discovered automatically. | +| `mcppls.semanticKit` | `auto`, `off` | `auto` | `--semantic-kit` | reload | Whether the bundled standard library kit may be used at all: `auto`, when no compiler is found; `off`, never (without a compiler, only module-level features remain). | +| `mcppls.requestTimeout` | a non-negative number of seconds | `60` | `--request-timeout` | restart | How long an engine request may take before it is answered without the engine. A request a person waits for (hover, definition, completion and the like) waits at most 30s in all, including while clangd starts or prepares its modules, and is then answered by mcppls's own engine. | +| `MCPPLS_ENGINE_ARGUMENTS` | a string | *(empty)* | — | restart | Extra arguments appended to clangd's own command line, for troubleshooting (e.g. `-j=8 --background-index-priority=background`). | + +### Editor experience + +| Setting | Values | Default | Command line | Applies | What it does | +|---|---|---|---|---|---| +| `mcppls.semanticTokens.modules` | `true`, `false` | `true` | — | restart | Color `import`, `module`, `export` and module names from the server's semantic tokens. Off: only the grammar's colors. | +| `mcppls.semanticTokens.moduleType` | `true`, `false` | `false` | — | restart | A client declares it knows the custom `module` semantic token type and the `partition` modifier (design 2026-09-25 K/§7); off is every client but this one, since none else advertises it. Not a package.json setting: VS Code's own extension always declares it, fixed, because it contributes that token type itself. | +| `mcppls.completion.triggerOnSpace` | `true`, `false` | `true` | — | restart | Show the module list as soon as a space is typed after `import` or `export import`. A space anywhere else never reaches the server. A client that says nothing gets this only when it identifies itself as VS Code or a fork of it; every other client opts in with `initializationOptions.completion.triggerOnSpace: true`. | +| `mcppls.index.primeImplementationUnits` | `auto`, `off` | `auto` | `--prime-implementation-units` | immediately | mcppls opens a module's implementation units in clangd in the background (0.0.6 plan §2.6, §9 T5), so go-to-definition reaches a definition that only an implementation unit has. `off`: only what a client opens itself is ever indexed for this. | +| `mcppls.detectConflicts` | `true`, `false` | `true` | — | immediately | Offer once to turn off another C++ extension's language features in this workspace, and say so when one becomes active later. VS Code only: no other client arbitrates between language servers. | + +### Diagnostics and logging + +| Setting | Values | Default | Command line | Applies | What it does | +|---|---|---|---|---|---| +| `mcppls.logLevel` | `debug`, `info`, `warning`, `error` | `info` | `--log-level` | restart | The server's own log level. | +| `mcppls.disableWorkaround` | `WA-CLANGD-` (repeatable) | *(none)* | `--disable-workaround` (repeatable) | restart | Turn off a registered clangd workaround (`WA-CLANGD-`, see the SKILL.md upstream-defects register), to see whether it is still needed; repeatable. `mcppls report` lists every registered workaround under `engines[].details.workarounds`. | +| `mcppls.trace.server` | `off`, `messages`, `verbose` | `off` | — | immediately | Log the LSP traffic to the C++ Modules output channel (at Trace level); `verbose` adds the server's debug log (at Debug level, by also passing `--log-level debug`). Set the channel's own log level to see them. | +| `MCPPLS_LOG_LEVEL` | `debug`, `info`, `warning`, `error` | *(empty)* | — | restart | Overrides the log level the VS Code extension starts the server with, ahead of `trace.server`. | + +### AI review + +| Setting | Values | Default | Command line | Applies | What it does | +|---|---|---|---|---|---| +| `mcppls.ai.enabled` | `true`, `false` | `false` | — | immediately | Show the AI-era features: Review Changes reviews the workspace's changes against `HEAD` with mcppls's rules and shows the findings, with their evidence, as problems. Nothing is sent to a model unless this is on and a model source is separately configured. | + +### Paths + +| Setting | Values | Default | Command line | Applies | What it does | +|---|---|---|---|---|---| +| `mcppls.database` | a path | *(empty)* | `--database` | reload | A workspace's own S1 build database, relative to its root, used instead of detecting one. | +| `mcppls.mcpp` | a path | *(empty)* | `--mcpp` | reload | The `mcpp` executable for mcpp projects; empty means found on `PATH`. | +| `mcppls.payload` | a path | *(empty)* | `--payload` | restart | Payload directory with clangd and the semantic kit; overridden per-file by `clangd` and `kit` below. | +| `mcppls.clangd` | a path | *(empty)* | `--clangd` | restart | clangd executable, overriding the one the payload carries. | +| `mcppls.kit` | a path | *(empty)* | `--kit` | restart | Semantic kit directory, overriding the one the payload carries. | +| `MCPPLS_CACHE_DIR` | a path | *(empty)* | — | restart | Overrides the whole cache directory mcppls otherwise picks under the user's cache home (workspace models, toolchain probes, logs, diagnostic bundles). | + ## Commands @@ -38,22 +110,13 @@ mcppls daemon run|start|status|stop the shared workspace daemon mcppls check the model, the profile, module diagnostics, then clangd --check mcppls report [--root DIR] [--settle SECONDS] what a bug report needs, as JSON mcppls model [--root DIR] [--export s1|compile-commands|engine] +mcppls settings [--format markdown|json] [--lang en|zh-CN] the table above, or its machine form mcppls print-environment prints this process's environment between two markers mcppls version ``` -Options that apply to every subcommand: - -| Option | Default | What it does | -|---|---|---| -| `--build-tool offline\|online\|off` | `offline` | The command-line spelling of `mcppls.buildTool` | -| `--tool-environment auto\|editor` | `auto` | The command-line spelling of `mcppls.toolEnvironment` | -| `--producer-timeout SECONDS` | 60, or 600 when online | How long the build tool may take to describe the project. Longer for a genuinely slow build; shorter to watch the bound work | -| `--request-timeout SECONDS` | 60 | How long an engine request may take before it is answered without the engine. A request a person waits for (hover, definition, completion and the like) waits at most 30 s in all, including while clangd starts or prepares its modules, and is then answered by mcppls's own engine | -| `--untrusted` | — | Run no build tool and no compiler | -| `--no-discover` | — | Do not look for compilers; loose sources use the semantic kit | -| `--log-level debug\|info\|warning\|error` | `info` | | -| `--disable-workaround WA-CLANGD-` | — | Turn off one of the registered workarounds for clangd's defects (repeatable), to see whether it is still needed; `mcppls report` lists them under `engines[].details.workarounds` | +Every global option above (`mcppls --help`) is a registered setting's command-line spelling; see the +table above for what each does, its default, and how a change takes effect. `print-environment` exists for the server itself: it is what the login shell is asked to run when `mcppls.toolEnvironment` is `auto`. diff --git a/docs/zh-CN/30-settings.md b/docs/zh-CN/30-settings.md index a554f1e..4fa0258 100644 --- a/docs/zh-CN/30-settings.md +++ b/docs/zh-CN/30-settings.md @@ -2,20 +2,88 @@ [English](../30-settings.md) | **简体中文** -## VS Code 设置 - -| 设置 | 取值 | 作用 | -|---|---|---| -| `mcppls.buildTool` | `offline`(默认), `online`, `off` | 项目构建工具的运行方式。`offline`:不联网运行——如果构建工具因此无法在不下载东西的情况下描述构建,状态栏会说明缺什么,并提议在你的终端里运行它。`online`:允许联网,超时时间从一分钟延长到十分钟。`off`:从不运行构建工具,使用缓存的描述或扫描到的源码 | -| `mcppls.toolEnvironment` | `auto`(默认), `editor` | 构建工具在哪个环境中启动。`auto` 会在后台读取一次你登录 shell 的环境(仅限 POSIX 系统)——从桌面项或 Dock 图标启动的编辑器不带任何 shell 配置,没有这个选项,它找到的构建工具可能就不是你终端里找到的那个。在 Windows 上,编辑器的环境本就和终端一致。`editor` 始终使用编辑器进程自身的环境 | -| `mcppls.compiler` | 编译器驱动的路径,或 `kit` | 为模块语义使用这个编译器,而不是检测到的那个。`kit` 强制使用内置的语义工具包 | -| `mcppls.semanticKit` | `auto`(默认), `off` | 内置工具包是否可以被使用 | -| `mcppls.engine` | `clangd`(默认), `none` | 核心引擎。无论如何,mcppls 自己的模块引擎都会运行;`none` 表示只提供模块相关功能 | -| `mcppls.ai.enabled` | `false`(默认) | 是否启用变更审查里依赖模型的那部分。关闭时服务端不发起任何模型调用 | -| `mcppls.detectConflicts` | `true`(默认) | 在此工作区中提议关闭另一个 C++ 扩展的语言功能(只提议一次),之后又有冲突扩展启用时会提示 | -| `mcppls.semanticTokens.modules` | `true`(默认) | 用服务端的语义 token 给 `import`、`module`、`export` 和模块名上色。关闭后只用语法文件的颜色 | -| `mcppls.completion.triggerOnSpace` | `true`(默认) | 在 `import` 或 `export import` 后输入空格时立即弹出模块列表;其他位置的空格不会发给服务端。其他编辑器用 `initializationOptions.completion.triggerOnSpace` 开启同样的行为 | -| `mcppls.trace.server` | `off`(默认), `messages`, `verbose` | 把 LSP 通信记录到 C++ Modules 输出通道(Trace 级别);`verbose` 还会打开服务端的 debug 日志(Debug 级别)。要看到它们,需把该输出通道的日志级别调到对应级别 | +mcppls 的每一个可配置行为都只有一处定义:`src/config/settings.cppm` 里注册表的一行(0.0.6 计划 §9 +T1)。下面这张表——VS Code 设置、命令行选项,以及会影响行为的环境变量——是从这份注册表生成的 +(`mcppls settings --format markdown --lang zh-CN`),`tests/test_settings.cpp` 把它、英文版和 +`editors/vscode/package.json` 都与注册表互相校验,三者不会走样。 + +**优先级。** 命令行 > `initializationOptions` > 默认值;之后的 `workspace/didChangeConfiguration` +会更新 `initializationOptions`(或更早一次 `didChangeConfiguration`)设置的值,但绝不会更新命令行设置 +的值。取值超出该设置自己的取值范围(比如一个未知的枚举值)时从不生效——回落到默认值,并记为一个问题 +(`mcppls report` 的 `settings.problems`;`mcppls settings` 本身不会带问题,因为它只打印注册表)。 + +**生效方式。** 一个已经在运行的设置改变之后如何生效:`重启` 需要重启 mcppls(下面需要重启的设置, +VS Code 扩展已经会这样做);`重新加载模型` 只重新加载项目模型,不需要重启;`立即生效` 两者都不需要—— +下次被读取时就是它生效的时候。 + +**改名后的设置照常能用。** 注册表给一个设置登记了旧名时,用旧名(不管是点号写法还是 +`initializationOptions`/`didChangeConfiguration` 里的写法)依然有效;下面这些设置目前还没有改过名, +所以都没有登记旧名。 + +`initializationOptions` 和 `didChangeConfiguration` 都同时接受嵌套对象 +(`{"semanticTokens": {"modules": false}}`)和点号写法的键(`{"semanticTokens.modules": false}`), +外面套不套一层 `mcppls` 都可以。 + + +### 项目与构建工具 + +| 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | +|---|---|---|---|---|---| +| `mcppls.buildTool` | `offline`, `online`, `off` | `offline` | `--build-tool` | 重新加载模型 | 项目构建工具的运行方式。`offline`:不联网运行——如果构建工具因此无法在不下载东西的情况下描述构建,状态栏会说明缺什么,并提议在终端里运行它。`online`:允许联网,超时时间从一分钟延长到十分钟。`off`:从不运行构建工具;仍会探测构建系统、仍读取它已有的产物(要连探测也关掉,见 `buildDiscovery`)。 | +| `mcppls.toolEnvironment` | `auto`, `editor` | `auto` | `--tool-environment` | 重启 | 构建工具在哪个环境中启动。`auto` 会在后台读取一次你登录 shell 的环境(仅限 POSIX 系统)——从桌面项或 Dock 图标启动的编辑器不带任何 shell 配置,没有这个选项,它找到的构建工具可能就不是你终端里找到的那个。在 Windows 上,编辑器的环境本就和终端一致。`editor` 始终使用编辑器进程自身的环境。 | +| `mcppls.producerTimeout` | 非负整数(秒) | `0` | `--producer-timeout` | 重新加载模型 | 构建工具描述项目最多可以花多长时间。默认 `0` 使用设计本身的限制(离线一分钟,`buildTool` 为 `online` 时十分钟);调短可以观察限制是否生效,构建确实慢就调长。 | +| `mcppls.untrusted` | `true`, `false` | `false` | `--untrusted` | 重启 | 不运行任何构建工具,也不运行编译器;一个不受信任的工作区也等同于 `buildDiscovery` 为 `off`。 | +| `mcppls.discoverCompilers` | `true`, `false` | `true` | `--no-discover` | 重新加载模型 | 为构建描述没有覆盖到的源码在本机查找编译器。关闭后,这类源码改用语义工具包。 | +| `mcppls.buildDiscovery` | `auto`, `off` | `auto` | `--build-discovery` | 重新加载模型 | 是否探测项目的构建系统(0.0.6 计划 §3.7 B-7)。`off`:不隐式读取或执行任何东西——只用明确配置的 `database`,否则扫描源码。`buildTool` 管的是探测到的构建工具能不能*执行*;这个开关管的是要不要去探测它。 | +| `mcppls.buildDiscovery.providers` | `mcpp`, `cmake`, `xmake`, `meson`, `compile-commands`(逗号分隔) | `mcpp`, `cmake`, `xmake`, `meson`, `compile-commands` | `--build-discovery-providers` | 重新加载模型 | `buildDiscovery` 可以使用哪些构建系统提供者;从中去掉某个提供者即停用它的探测(例如只想用已有的 CMake 构建目录,不要 xmake)。 | +| `mcppls.buildDiscovery.askBeforeDownload` | `true`, `false` | `true` | — | 立即生效 | 当构建工具需要下载才能完成描述项目时,客户端可以提议去获取它。关闭后,状态栏说明需要下载,但不会再询问。 | + +### 引擎 + +| 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | +|---|---|---|---|---|---| +| `mcppls.engine` | `clangd`, `none` | `clangd` | `--engine` | 重启 | 核心引擎。无论如何,mcppls 自己的模块引擎都会运行;`none` 表示只提供模块相关功能。 | +| `mcppls.compiler` | 字符串 | (空) | `--compiler` | 重新加载模型 | 为模块语义使用这个编译器,而不是检测到的那个:可以是绝对路径、`PATH` 上的名字,或 `kit`(强制使用内置的语义工具包)。空表示自动检测。 | +| `mcppls.semanticKit` | `auto`, `off` | `auto` | `--semantic-kit` | 重新加载模型 | 内置的标准库工具包是否可以被使用:`auto` 在没有找到编译器时使用;`off` 从不使用(没有编译器时只剩模块相关功能)。 | +| `mcppls.requestTimeout` | 非负整数(秒) | `60` | `--request-timeout` | 重启 | 一个引擎请求最多等待多久,超时后不经该引擎就给出答复。用户在等的请求(悬停、跳转、补全等)总共最多等 30 秒,clangd 启动或准备模块期间也算在内,之后由 mcppls 自己的引擎答复。 | +| `MCPPLS_ENGINE_ARGUMENTS` | 字符串 | (空) | — | 重启 | 追加到 clangd 自身命令行末尾的额外参数,用于排查问题(例如 `-j=8 --background-index-priority=background`)。 | + +### 编辑器体验 + +| 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | +|---|---|---|---|---|---| +| `mcppls.semanticTokens.modules` | `true`, `false` | `true` | — | 重启 | 用服务端的语义 token 给 `import`、`module`、`export` 和模块名上色。关闭后只用语法文件的颜色。 | +| `mcppls.semanticTokens.moduleType` | `true`, `false` | `false` | — | 重启 | 客户端声明自己认得自定义的 `module` 语义 token 类型和 `partition` 修饰符(设计 2026-09-25 K/§7);除本仓库的 VS Code 扩展外都关闭,因为没有别的客户端会声明它。不是 package.json 里的设置:VS Code 扩展自己贡献了这个 token 类型,因此固定声明为开。 | +| `mcppls.completion.triggerOnSpace` | `true`, `false` | `true` | — | 重启 | 在 `import` 或 `export import` 后输入空格时立即弹出模块列表;其他位置的空格不会发给服务端。什么都不说的客户端只有在自证是 VS Code 或其分支时才会得到这个行为;其他客户端需要用 `initializationOptions.completion.triggerOnSpace: true` 主动开启。 | +| `mcppls.index.primeImplementationUnits` | `auto`, `off` | `auto` | `--prime-implementation-units` | 立即生效 | mcppls 在后台把一个模块的实现单元打开给 clangd(0.0.6 计划 §2.6、§9 T5),这样跳到定义能到达只存在于实现单元里的定义。`off`:只有客户端自己打开的文件才会被索引。 | +| `mcppls.detectConflicts` | `true`, `false` | `true` | — | 立即生效 | 在此工作区中提议关闭另一个 C++ 扩展的语言功能(只提议一次),之后又有冲突扩展启用时会提示。仅限 VS Code:其他客户端不会在多个语言服务端之间做取舍。 | + +### 诊断与日志 + +| 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | +|---|---|---|---|---|---| +| `mcppls.logLevel` | `debug`, `info`, `warning`, `error` | `info` | `--log-level` | 重启 | 服务端自身的日志级别。 | +| `mcppls.disableWorkaround` | `WA-CLANGD-`(可重复) | (无) | `--disable-workaround`(可重复) | 重启 | 关掉一个针对 clangd 缺陷登记的规避措施(`WA-CLANGD-`,见 SKILL.md 的上游缺陷登记),用来确认它是否还有必要;可重复。`mcppls report` 在 `engines[].details.workarounds` 下列出所有登记过的规避措施。 | +| `mcppls.trace.server` | `off`, `messages`, `verbose` | `off` | — | 立即生效 | 把 LSP 通信记录到 C++ Modules 输出通道(Trace 级别);`verbose` 还会打开服务端的 debug 日志(通过附加 `--log-level debug`,Debug 级别)。要看到它们,需把该输出通道的日志级别调到对应级别。 | +| `MCPPLS_LOG_LEVEL` | `debug`, `info`, `warning`, `error` | (空) | — | 重启 | 覆盖 VS Code 扩展启动服务端时使用的日志级别,优先于 `trace.server`。 | + +### AI 评审 + +| 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | +|---|---|---|---|---|---| +| `mcppls.ai.enabled` | `true`, `false` | `false` | — | 立即生效 | 显示 AI 时代的功能:Review Changes 用 mcppls 的规则对照 `HEAD` 审查工作区的变更,并把发现连同证据以问题的形式展示。除非这里开启并且另行配置了模型来源,否则不会向任何模型发送内容。 | + +### 路径 + +| 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | +|---|---|---|---|---|---| +| `mcppls.database` | 路径 | (空) | `--database` | 重新加载模型 | 工作区自己的 S1 构建数据库,相对于其根目录,用它代替探测。 | +| `mcppls.mcpp` | 路径 | (空) | `--mcpp` | 重新加载模型 | mcpp 项目所用的 `mcpp` 可执行文件;空表示在 `PATH` 上查找。 | +| `mcppls.payload` | 路径 | (空) | `--payload` | 重启 | 包含 clangd 和语义工具包的 payload 目录;下面的 `clangd` 和 `kit` 可以分别覆盖其中一项。 | +| `mcppls.clangd` | 路径 | (空) | `--clangd` | 重启 | clangd 可执行文件,覆盖 payload 自带的那一份。 | +| `mcppls.kit` | 路径 | (空) | `--kit` | 重启 | 语义工具包目录,覆盖 payload 自带的那一份。 | +| `MCPPLS_CACHE_DIR` | 路径 | (空) | — | 重启 | 覆盖 mcppls 原本在用户缓存目录下选定的整个缓存目录(工作区模型、工具链探测结果、日志、诊断包)。 | + ## 命令 @@ -40,21 +108,11 @@ mcppls daemon run|start|status|stop 共享工作区的守护进程 mcppls check 模型、语义配置、模块诊断,然后运行 clangd --check mcppls report [--root DIR] [--settle SECONDS] bug 报告需要的全部信息,JSON 格式 mcppls model [--root DIR] [--export s1|compile-commands|engine] +mcppls settings [--format markdown|json] [--lang en|zh-CN] 上面这张表,或它的机器可读形式 mcppls print-environment 在两个标记之间打印本进程的环境 mcppls version ``` -对每个子命令都生效的选项: - -| 选项 | 默认值 | 作用 | -|---|---|---| -| `--build-tool offline\|online\|off` | `offline` | `mcppls.buildTool` 的命令行写法 | -| `--tool-environment auto\|editor` | `auto` | `mcppls.toolEnvironment` 的命令行写法 | -| `--producer-timeout SECONDS` | 60,联网时 600 | 构建工具描述项目最多可以花多长时间。构建确实慢就调长;想观察超时限制是否生效就调短 | -| `--request-timeout SECONDS` | 60 | 一个引擎请求最多等待多久,超时后不经该引擎就给出答复。用户在等的请求(悬停、跳转、补全等)总共最多等 30 秒,clangd 启动或准备模块期间也算在内,之后由 mcppls 自己的引擎答复 | -| `--untrusted` | — | 不运行任何构建工具,也不运行编译器 | -| `--no-discover` | — | 不查找编译器;零散源码使用语义工具包 | -| `--log-level debug\|info\|warning\|error` | `info` | | -| `--disable-workaround WA-CLANGD-` | — | 关掉一个针对 clangd 缺陷登记的规避措施(可重复),用来确认它是否还有必要;`mcppls report` 在 `engines[].details.workarounds` 下列出它们 | +上面的每一个全局选项(`mcppls --help`)都是某个注册设置的命令行写法;它的作用、默认值和生效方式见上表。 `print-environment` 是给服务端自己用的:`mcppls.toolEnvironment` 为 `auto` 时,服务端让登录 shell 运行的就是这个命令。 diff --git a/editors/vscode/package.json b/editors/vscode/package.json index db0c7bb..305451a 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -273,6 +273,58 @@ "Always start build tools in the editor process's environment." ], "description": "Which environment mcppls starts your build tools in. An editor started from a desktop entry, a Dock icon or a launcher does not carry your shell configuration, so the tool it finds may not be the one your terminal finds." + }, + "mcppls.buildDiscovery": { + "type": "string", + "enum": [ + "auto", + "off" + ], + "enumDescriptions": [ + "Detect the project's build system: mcpp, CMake, xmake, meson, or an existing compile_commands.json.", + "Detect nothing and run nothing implicitly: use an explicitly configured mcppls.database, else scanned sources." + ], + "default": "auto", + "description": "Whether the project's build system is detected at all. mcppls.buildTool separately governs whether a detected build tool may be run; this governs whether it is looked for in the first place." + }, + "mcppls.buildDiscovery.providers": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "mcpp", + "cmake", + "xmake", + "meson", + "compile-commands" + ] + }, + "default": [ + "mcpp", + "cmake", + "xmake", + "meson", + "compile-commands" + ], + "description": "Which build system providers mcppls.buildDiscovery may use; leave one out to stop mcppls from detecting it." + }, + "mcppls.buildDiscovery.askBeforeDownload": { + "type": "boolean", + "default": true, + "description": "When the build tool needs a download to finish describing the project, offer to fetch it. Off: the status bar says a download is needed, and nothing asks." + }, + "mcppls.index.primeImplementationUnits": { + "type": "string", + "enum": [ + "auto", + "off" + ], + "enumDescriptions": [ + "Open a module's implementation units in clangd in the background, so go-to-definition reaches a definition that only one has.", + "Only index what is opened in the editor." + ], + "default": "auto", + "description": "Whether mcppls opens a module's implementation units in the background so go-to-definition can reach a definition that only an implementation unit has." } } }, diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 7e5dd40..c3d8193 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -39,6 +39,9 @@ function buildToolSetting(value: string | undefined): string { return value === 'online' || value === 'off' ? value : 'offline'; } +// mcppls.buildDiscovery.providers' own default (config registry, settings §9 T1): every provider. +const BUILD_DISCOVERY_PROVIDERS = ['mcpp', 'cmake', 'xmake', 'meson', 'compile-commands']; + const CLIENT_ID = 'mcppls'; const CLIENT_NAME = 'C++ Modules'; const RESTART_WINDOW_MS = 3 * 60 * 1000; @@ -278,6 +281,16 @@ class ServerHost implements vscode.Disposable { completion: { triggerOnSpace: configuration.get('completion.triggerOnSpace', true), }, + // 0.0.6 plan §3.7 B-7: whether the project's build system is detected at all, which + // providers may be used, and whether a needed download is ever offered. Dotted keys, + // not a nested `buildDiscovery` object: the setting `buildDiscovery` is itself a leaf + // (`auto`/`off`), so it cannot also be the object `buildDiscovery.providers` nests + // under -- the config registry's own dotted-key form (settings §9 T1) sidesteps that. + 'buildDiscovery': configuration.get('buildDiscovery') === 'off' ? 'off' : 'auto', + 'buildDiscovery.providers': configuration.get('buildDiscovery.providers', BUILD_DISCOVERY_PROVIDERS), + 'buildDiscovery.askBeforeDownload': configuration.get('buildDiscovery.askBeforeDownload', true), + // 0.0.6 plan §2.6, §9 T5: implementation units opened in the background. + 'index.primeImplementationUnits': configuration.get('index.primeImplementationUnits') === 'off' ? 'off' : 'auto', }, middleware: { // Fix plan 2026-09-26 F9 (D4 layer 1): of the completions a typed space asks for, only @@ -526,7 +539,11 @@ export function activate(context: vscode.ExtensionContext): TestApi { if (event.affectsConfiguration('mcppls.compiler') || event.affectsConfiguration('mcppls.semanticKit') || event.affectsConfiguration('mcppls.engine') || event.affectsConfiguration('mcppls.buildTool') || event.affectsConfiguration('mcppls.toolEnvironment') || event.affectsConfiguration('mcppls.semanticTokens.modules') - || event.affectsConfiguration('mcppls.completion.triggerOnSpace')) { + || event.affectsConfiguration('mcppls.completion.triggerOnSpace') + // 0.0.6 plan §3.7 B-7, §2.6/§9 T5: new settings, same treatment as the ones above. + || event.affectsConfiguration('mcppls.buildDiscovery') || event.affectsConfiguration('mcppls.buildDiscovery.providers') + || event.affectsConfiguration('mcppls.buildDiscovery.askBeforeDownload') + || event.affectsConfiguration('mcppls.index.primeImplementationUnits')) { void host.restart(); } }), diff --git a/src/cli/commands.cpp b/src/cli/commands.cpp index 89c8c60..c0809c3 100644 --- a/src/cli/commands.cpp +++ b/src/cli/commands.cpp @@ -31,6 +31,8 @@ import mcppls.server.session; import mcppls.cli.options; import mcppls.cli.query; import mcppls.cli.cache; +import mcppls.cli.settings; +import mcppls.config.settings; import mcppls.orchestrator.report; import mcppls.orchestrator.kernel; import mcppls.bundle.writer; @@ -177,18 +179,30 @@ int command_check(const cmdline::ParsedArgs& args) { return result->exitCode == 0 && !result->timedOut ? 0 : 1; } -// The options a daemon started for an entry is started with: the entry's own. +// The options a daemon started for an entry is started with: the entry's own. Every registered +// server setting with a command-line spelling (config::settings registry, 0.0.6 plan §9 T1) is +// forwarded from whatever `args` itself carries; `model-*`, `idle-minutes` and `tool-timeout` +// configure this `mcp`/`daemon` command rather than the server the daemon starts, so they are not +// registry rows, and are forwarded by name here instead. std::vector daemon_arguments(const cmdline::ParsedArgs& args) { std::vector forwarded; - for (const std::string_view name : { "payload", "clangd", "kit", "mcpp", "database", "engine", "request-timeout", "log-level", "tool-timeout", - "model-source", "model-gateway", "model-name", "model-budget", "idle-minutes" }) { + for (const auto& row : config::settings::registry()) { + if (row.surface != config::settings::Surface::server || row.commandLine.empty()) continue; + const std::string flag { row.commandLine.substr(2) }; + if (row.kind == config::settings::Kind::boolean) { + if (args.is_flag_set(flag)) forwarded.push_back(row.commandLine); + continue; + } + if (row.kind == config::settings::Kind::list && row.commandLineRepeatable) { + for (const auto& value : args.option_or_empty(flag).values) forwarded.insert(forwarded.end(), { row.commandLine, value }); + continue; + } + if (auto value = args.value(flag)) forwarded.insert(forwarded.end(), { row.commandLine, *value }); + } + for (const std::string_view name : { "model-source", "model-gateway", "model-name", "model-budget", "idle-minutes", "tool-timeout" }) { if (auto value = args.value(name)) forwarded.insert(forwarded.end(), { std::format("--{}", name), *value }); } for (const auto& pattern : args.option_or_empty("model-exclude").values) forwarded.insert(forwarded.end(), { "--model-exclude", pattern }); - for (const auto& id : args.option_or_empty("disable-workaround").values) forwarded.insert(forwarded.end(), { "--disable-workaround", id }); - for (const std::string_view flag : { "untrusted", "no-discover" }) { - if (args.is_flag_set(flag)) forwarded.push_back(std::format("--{}", flag)); - } return forwarded; } @@ -210,21 +224,21 @@ int run(int argc, char* argv[]) { cmdline::App app { "mcppls" }; (void)app.version(std::string { base::VERSION }); (void)app.description("Compiler-agnostic C++ modules language server"); - (void)app.option("payload").takes_value().global(true).help("Payload directory with clangd and the semantic kit"); - (void)app.option("clangd").takes_value().global(true).help("clangd executable (overrides the payload)"); - (void)app.option("kit").takes_value().global(true).help("Semantic kit directory (overrides the payload)"); - (void)app.option("mcpp").takes_value().global(true).help("The mcpp executable for mcpp projects (default: found on PATH)"); - (void)app.option("database").takes_value().global(true).help("A workspace's own S1 build database, relative to its root"); - (void)app.option("untrusted").global(true).help("Do not run build tools or compilers"); - (void)app.option("no-discover").global(true).help("Do not look for compilers; loose sources use the semantic kit"); - (void)app.option("log-level").takes_value().global(true).help("debug | info | warning | error"); - (void)app.option("request-timeout").takes_value().global(true).help("Seconds before an engine request is answered without it"); - (void)app.option("build-tool").takes_value().global(true).help("How the project's build tool may be run: offline (default), online, off"); - (void)app.option("tool-environment").takes_value().global(true).help("Which environment build tools run in: auto (the login shell on POSIX) or editor"); - (void)app.option("producer-timeout").takes_value().global(true).help("Seconds a build tool may take to describe the project (default 60, or 600 when online)"); - (void)app.option("engine").takes_value().global(true).help("The core semantic engine: clangd (default) or none, mcppls's own module features only"); - (void)app.option("disable-workaround").takes_value().multiple().global(true).help("Turn off a registered clangd workaround (WA-CLANGD-, see mcppls report); repeatable"); + // Every global option below a registered server setting's command-line spelling (config::settings + // registry, 0.0.6 plan §9 T1: one definition, everything else -- `session_options`, this help + // text, `mcppls settings`, `mcppls report`, the docs -- derived from it). + for (const auto& row : config::settings::registry()) { + if (row.surface != config::settings::Surface::server || row.commandLine.empty()) continue; + const std::string flag { row.commandLine.substr(2) }; + (void)app.option(flag) + .global(true) + .help(row.summary) + .takes_value(row.kind != config::settings::Kind::boolean) + .multiple(row.kind == config::settings::Kind::list && row.commandLineRepeatable); + } // Language clients pass these by convention; this server always speaks over its standard streams. + // They are not settings (nothing about mcppls's own behaviour follows from either), so they are + // not registry rows. (void)app.option("stdio").global(true).help("Accepted for language clients; standard input and output are always used"); (void)app.option("clientProcessId").takes_value().global(true).help("Accepted for language clients; not used"); (void)app.action(serve); @@ -282,7 +296,7 @@ int run(int argc, char* argv[]) { const engine::PayloadPaths payload { engine::resolve_payload(engine::PayloadRequest { options.session.payloadDirectory, options.session.clangd, options.session.kit, options.session.engine }) }; Json report = orchestrator::make_report(std::move(roots), Json { { "name", "mcppls report" } }, options.session.engine, payload, false, - std::chrono::steady_clock::now() - started); + std::chrono::steady_clock::now() - started, options.session.settings.to_json()); const bool redact { !args.is_flag_set("no-redact") }; if (auto output = args.value("bundle")) { // Before the kernel shuts down: a second instance's private cache, with its engine database, goes with it. @@ -441,6 +455,7 @@ int run(int argc, char* argv[]) { (void)app.subcommand(impact_command(handled, status)); (void)app.subcommand(review_command(handled, status)); (void)app.subcommand(cache_command(handled, status)); + (void)app.subcommand(settings_command(handled, status)); cmdline::App versionCommand { "version" }; (void)versionCommand.description("Print the version"); diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 3f61f71..e95f1cc 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -4,6 +4,7 @@ import std; import mcpplibs.cmdline; import mcppls.base.log; import mcppls.base.path; +import mcppls.config.settings; import mcppls.platform.env; import mcppls.platform.fs; import mcppls.engine; @@ -38,40 +39,46 @@ orchestrator::EngineFactories engine_factories(const orchestrator::SessionOption return factories; } +// Every field below is read out of `options.settings` after its command-line layer (config settings +// §9 T1): the one place a flag's default, its validation and its precedence over a client are +// defined is the registry, not this function. `handle_initialize_` and `workspace/didChangeConfiguration` +// (`mcppls.server.session`) layer over the very same `Settings` object and re-derive these same +// fields the same way, through `orchestrator::Workspace::reload_with_options`. orchestrator::SessionOptions session_options(const cmdline::ParsedArgs& args) { orchestrator::SessionOptions options; - options.payloadDirectory = args.value("payload").value_or(""); - options.clangd = args.value("clangd").value_or(""); - options.kit = args.value("kit").value_or(""); - options.mcpp = args.value("mcpp").value_or(""); - options.database = args.value("database").value_or(""); - options.trusted = !args.is_flag_set("untrusted"); - options.discoverCompilers = !args.is_flag_set("no-discover"); - options.verboseEngineLog = args.value("log-level").value_or("") == "debug"; - if (auto chosen = args.value("engine")) { - options.engine = *chosen; - options.engineFromCommandLine = true; - } + options.settings.apply_command_line(args); + const auto& settings = options.settings; + options.payloadDirectory = settings.string_value("payload"); + options.clangd = settings.string_value("clangd"); + options.kit = settings.string_value("kit"); + options.mcpp = settings.string_value("mcpp"); + options.database = settings.string_value("database"); + options.compiler = settings.string_value("compiler"); + options.semanticKit = settings.string_value("semanticKit"); + options.trusted = !settings.bool_value("untrusted"); + options.discoverCompilers = settings.bool_value("discoverCompilers"); + options.verboseEngineLog = settings.string_value("logLevel") == "debug"; + options.engine = settings.string_value("engine"); options.engineFactories = engine_factories; - options.disabledWorkarounds = args.option_or_empty("disable-workaround").values; + options.disabledWorkarounds = settings.list_value("disableWorkaround"); + options.buildTool = settings.string_value("buildTool"); + options.toolEnvironment = settings.string_value("toolEnvironment"); + options.semanticTokensModules = settings.bool_value("semanticTokens.modules"); + options.semanticTokensModuleType = settings.bool_value("semanticTokens.moduleType"); + options.buildDiscovery = settings.string_value("buildDiscovery"); + options.buildDiscoveryProviders = settings.list_value("buildDiscovery.providers"); + options.buildDiscoveryAskBeforeDownload = settings.bool_value("buildDiscovery.askBeforeDownload"); + options.primeImplementationUnits = settings.string_value("index.primeImplementationUnits"); + // Design 4.2 sets this at a minute. A machine whose build tool is honestly slower needs it + // longer, and a test that means to watch the bound fire needs it much shorter. + options.producerTimeout = settings.seconds_value("producerTimeout"); + options.requestTimeout = std::chrono::duration_cast(settings.seconds_value("requestTimeout")); // This very program, for the reviews an editor asks for: named as the process started it, else found on PATH. if (const auto arguments = platform::env::arguments(); !arguments.empty()) { const std::string started { arguments.front() }; const bool hasDirectory { started.find('/') != std::string::npos || started.find('\\') != std::string::npos }; options.serverExecutable = hasDirectory ? absolute(started) : platform::env::find_executable(started).value_or(""); } - if (auto buildTool = args.value("build-tool"); buildTool && (*buildTool == "offline" || *buildTool == "online" || *buildTool == "off")) { - options.buildTool = *buildTool; - } - if (auto environment = args.value("tool-environment"); environment && (*environment == "auto" || *environment == "editor")) { - options.toolEnvironment = *environment; - } - // Design 4.2 sets this at a minute. A machine whose build tool is honestly slower needs it - // longer, and a test that means to watch the bound fire needs it much shorter. - options.producerTimeout = seconds_option(args, "producer-timeout", std::chrono::seconds { 0 }); - options.requestTimeout = seconds_option(args, "request-timeout", options.requestTimeout.count() > 0 - ? std::chrono::duration_cast(options.requestTimeout) - : std::chrono::seconds { 60 }); return options; } diff --git a/src/cli/settings.cpp b/src/cli/settings.cpp new file mode 100644 index 0000000..479c6db --- /dev/null +++ b/src/cli/settings.cpp @@ -0,0 +1,40 @@ +module mcppls.cli.settings; + +import std; +import nlohmann.json; +import mcpplibs.cmdline; +import mcppls.config.settings; + +namespace mcppls::cli { + +namespace { + +namespace cmdline = mcpplibs::cmdline; +namespace settings = mcppls::config::settings; + +} // namespace + +cmdline::App settings_command(bool& handled, int& status) { + cmdline::App command { "settings" }; + (void)command.description("Print the configuration registry: every setting mcppls understands, its default, and how to spell it"); + (void)command.option("format").takes_value().help("markdown (default) | json"); + (void)command.option("lang").takes_value().help("en (default) | zh-CN; markdown only"); + (void)command.action([&handled, &status](const cmdline::ParsedArgs& args) { + handled = true; + const std::string format { args.value("format").value_or("markdown") }; + const auto rows = settings::registry(); + if (format == "json") { + std::println("{}", settings::registry_to_json(rows).dump(2)); + } else if (format == "markdown") { + std::println("{}", settings::to_markdown(rows, args.value("lang").value_or("en"))); + } else { + std::println(std::cerr, "settings: unknown format {}; use markdown or json", format); + status = 2; + return; + } + status = 0; + }); + return command; +} + +} // namespace mcppls::cli diff --git a/src/cli/settings.cppm b/src/cli/settings.cppm new file mode 100644 index 0000000..a8f916c --- /dev/null +++ b/src/cli/settings.cppm @@ -0,0 +1,16 @@ +// mcppls settings [--format markdown|json] [--lang en|zh-CN] +// +// Prints the configuration registry (0.0.6 plan §9 T1): the same rows `docs/30-settings.md` and its +// zh-CN mirror embed between their `` / `` markers +// (`--format markdown`, the default, `--lang` choosing which of a row's two summaries to print), or +// every field of every row for a machine reader (`--format json`). +export module mcppls.cli.settings; + +import std; +import mcpplibs.cmdline; + +export namespace mcppls::cli { + +mcpplibs::cmdline::App settings_command(bool& handled, int& status); + +} // namespace mcppls::cli diff --git a/src/config/settings.cpp b/src/config/settings.cpp new file mode 100644 index 0000000..7843284 --- /dev/null +++ b/src/config/settings.cpp @@ -0,0 +1,744 @@ +module mcppls.config.settings; + +import std; +import nlohmann.json; +import mcpplibs.cmdline; +import mcppls.base.text; +import mcppls.platform.env; + +namespace mcppls::config::settings { + +using Json = nlohmann::json; +namespace cmdline = mcpplibs::cmdline; + +namespace { + +// A category is grouped and headed by this table, in this order, in both the generated docs and +// `mcppls settings --format markdown`; a row's `category` is one of these keys, never displayed +// directly. Keeping the key and the two headings together is what makes adding a category (rather +// than misspelling an existing one) the only way a row's table silently stops rendering. +struct CategoryHeading { + std::string_view key; + std::string_view en; + std::string_view zh; +}; + +constexpr std::array CATEGORIES { { + { "build", "Project and build tools", "项目与构建工具" }, + { "engines", "Engines", "引擎" }, + { "editor", "Editor experience", "编辑器体验" }, + { "diagnostics", "Diagnostics and logging", "诊断与日志" }, + { "ai", "AI review", "AI 评审" }, + { "paths", "Paths", "路径" }, +} }; + +bool is_zh(std::string_view lang) { return lang.starts_with("zh"); } + +// The registry itself (0.0.6 plan §9 T1). One row per configurable behaviour; see `Setting` in +// `settings.cppm` for what each field means. Ordered as the generated docs list it: by category in +// `CATEGORIES`' order, each category's own rows in the order below. +const std::vector& shipped_registry() { + static const std::vector rows { + // ---- Project and build tools ------------------------------------------------------ + Setting { + .key = "buildTool", .kind = Kind::enumeration, .values = { "offline", "online", "off" }, .defaultValue = "offline", + .commandLine = "--build-tool", .surface = Surface::server, .applies = Applies::reload, .category = "build", .since = "0.0.1", + .summary = "How the project's build tool may be run. `offline`: run it without the network -- if it then cannot describe " + "the build without downloading something, the status says what is missing and offers to run it in your terminal. " + "`online`: let it reach the network, with ten minutes instead of one. `off`: never run it; the build system is " + "still detected and its own generated files are still read (see `buildDiscovery` for turning that off too).", + .summaryZh = "项目构建工具的运行方式。`offline`:不联网运行——如果构建工具因此无法在不下载东西的情况下描述构建,状态栏会说明缺什么," + "并提议在终端里运行它。`online`:允许联网,超时时间从一分钟延长到十分钟。`off`:从不运行构建工具;仍会探测构建系统、" + "仍读取它已有的产物(要连探测也关掉,见 `buildDiscovery`)。", + .clientConfigurable = true, + }, + Setting { + .key = "toolEnvironment", .kind = Kind::enumeration, .values = { "auto", "editor" }, .defaultValue = "auto", + .commandLine = "--tool-environment", .surface = Surface::server, .applies = Applies::restart, .category = "build", .since = "0.0.1", + .summary = "Which environment build tools are started in. `auto` reads your login shell's environment once, in the " + "background, on POSIX -- an editor started from a desktop entry or a Dock icon carries none of your shell " + "configuration, so without this the build tool it finds may not be the one your terminal finds. On Windows the " + "editor's environment already matches the terminal's. `editor` always uses the editor process's environment.", + .summaryZh = "构建工具在哪个环境中启动。`auto` 会在后台读取一次你登录 shell 的环境(仅限 POSIX 系统)——从桌面项或 Dock 图标启动的" + "编辑器不带任何 shell 配置,没有这个选项,它找到的构建工具可能就不是你终端里找到的那个。在 Windows 上,编辑器的环境本就" + "和终端一致。`editor` 始终使用编辑器进程自身的环境。", + .clientConfigurable = true, + }, + Setting { + .key = "producerTimeout", .kind = Kind::seconds, .defaultValue = "0", .commandLine = "--producer-timeout", + .surface = Surface::server, .applies = Applies::reload, .category = "build", .since = "0.0.1", + .summary = "How long a build tool may take to describe the project. `0`, the default, uses the design's own bound (a " + "minute offline, ten minutes once `buildTool` is `online`); set it to watch that bound work, or longer for a " + "genuinely slower build.", + .summaryZh = "构建工具描述项目最多可以花多长时间。默认 `0` 使用设计本身的限制(离线一分钟,`buildTool` 为 `online` 时十分钟);" + "调短可以观察限制是否生效,构建确实慢就调长。", + }, + Setting { + .key = "untrusted", .kind = Kind::boolean, .defaultValue = "false", .commandLine = "--untrusted", .surface = Surface::server, + .applies = Applies::restart, .category = "build", .since = "0.0.1", + .summary = "Run no build tool and no compiler; an untrusted workspace is also read as though `buildDiscovery` were `off`.", + .summaryZh = "不运行任何构建工具,也不运行编译器;一个不受信任的工作区也等同于 `buildDiscovery` 为 `off`。", + }, + Setting { + .key = "discoverCompilers", .kind = Kind::boolean, .defaultValue = "true", .commandLine = "--no-discover", + .commandLineNegated = true, .surface = Surface::server, .applies = Applies::reload, .category = "build", .since = "0.0.1", + .summary = "Look for a compiler on the machine for a source the build description does not cover. Off: such a source " + "uses the semantic kit instead.", + .summaryZh = "为构建描述没有覆盖到的源码在本机查找编译器。关闭后,这类源码改用语义工具包。", + }, + Setting { + .key = "buildDiscovery", .kind = Kind::enumeration, .values = { "auto", "off" }, .defaultValue = "auto", + .commandLine = "--build-discovery", .surface = Surface::server, .applies = Applies::reload, .category = "build", + .since = "0.0.6", + .summary = "Whether the project's build system is detected at all (0.0.6 plan §3.7 B-7). `off`: nothing is read or run " + "implicitly -- only an explicitly configured `database`, else sources are scanned. `buildTool` still governs " + "whether a detected build tool may be *run*; this governs whether it is looked for in the first place.", + .summaryZh = "是否探测项目的构建系统(0.0.6 计划 §3.7 B-7)。`off`:不隐式读取或执行任何东西——只用明确配置的 `database`,否则" + "扫描源码。`buildTool` 管的是探测到的构建工具能不能*执行*;这个开关管的是要不要去探测它。", + .clientConfigurable = true, + }, + Setting { + .key = "buildDiscovery.providers", .kind = Kind::list, + .values = { "mcpp", "cmake", "xmake", "meson", "compile-commands" }, + .defaultValue = "mcpp,cmake,xmake,meson,compile-commands", .commandLine = "--build-discovery-providers", + .surface = Surface::server, .applies = Applies::reload, .category = "build", .since = "0.0.6", + .summary = "Which build system providers `buildDiscovery` may use; leave one out to stop mcppls from detecting it (for " + "example, to use only a CMake build directory that already exists and never let xmake run).", + .summaryZh = "`buildDiscovery` 可以使用哪些构建系统提供者;从中去掉某个提供者即停用它的探测(例如只想用已有的 CMake 构建目录," + "不要 xmake)。", + .clientConfigurable = true, + }, + Setting { + .key = "buildDiscovery.askBeforeDownload", .kind = Kind::boolean, .defaultValue = "true", .surface = Surface::server, + .applies = Applies::immediately, .category = "build", .since = "0.0.6", + .summary = "When the build tool needs a download to finish describing the project, a client may offer to fetch it. " + "Off: the status says a download is needed, and nothing asks.", + .summaryZh = "当构建工具需要下载才能完成描述项目时,客户端可以提议去获取它。关闭后,状态栏说明需要下载,但不会再询问。", + .clientConfigurable = true, + }, + // ---- Engines ------------------------------------------------------------------------ + Setting { + .key = "engine", .kind = Kind::enumeration, .values = { "clangd", "none" }, .defaultValue = "clangd", + .commandLine = "--engine", .surface = Surface::server, .applies = Applies::restart, .category = "engines", .since = "0.0.1", + .summary = "The core semantic engine. mcppls's own module engine always runs beside it; `none` means module-level " + "features only.", + .summaryZh = "核心引擎。无论如何,mcppls 自己的模块引擎都会运行;`none` 表示只提供模块相关功能。", + .clientConfigurable = true, + }, + Setting { + .key = "compiler", .kind = Kind::string, .defaultValue = "", .commandLine = "--compiler", .surface = Surface::server, + .applies = Applies::reload, .category = "engines", .since = "0.0.1", + .summary = "Use this compiler for module semantics instead of what was detected: an absolute path, a name on `PATH`, or " + "`kit` to force the bundled semantic kit. Empty means discovered automatically.", + .summaryZh = "为模块语义使用这个编译器,而不是检测到的那个:可以是绝对路径、`PATH` 上的名字,或 `kit`(强制使用内置的语义工具" + "包)。空表示自动检测。", + .clientConfigurable = true, + }, + Setting { + .key = "semanticKit", .kind = Kind::enumeration, .values = { "auto", "off" }, .defaultValue = "auto", + .commandLine = "--semantic-kit", .surface = Surface::server, .applies = Applies::reload, .category = "engines", + .since = "0.0.1", + .summary = "Whether the bundled standard library kit may be used at all: `auto`, when no compiler is found; `off`, " + "never (without a compiler, only module-level features remain).", + .summaryZh = "内置的标准库工具包是否可以被使用:`auto` 在没有找到编译器时使用;`off` 从不使用(没有编译器时只剩模块相关功能)。", + .clientConfigurable = true, + }, + Setting { + .key = "requestTimeout", .kind = Kind::seconds, .defaultValue = "60", .commandLine = "--request-timeout", + .surface = Surface::server, .applies = Applies::restart, .category = "engines", .since = "0.0.1", + .summary = "How long an engine request may take before it is answered without the engine. A request a person waits " + "for (hover, definition, completion and the like) waits at most 30s in all, including while clangd starts or " + "prepares its modules, and is then answered by mcppls's own engine.", + .summaryZh = "一个引擎请求最多等待多久,超时后不经该引擎就给出答复。用户在等的请求(悬停、跳转、补全等)总共最多等 30 秒," + "clangd 启动或准备模块期间也算在内,之后由 mcppls 自己的引擎答复。", + }, + Setting { + .key = "MCPPLS_ENGINE_ARGUMENTS", .kind = Kind::string, .defaultValue = "", .surface = Surface::environment, + .applies = Applies::restart, .category = "engines", .since = "0.0.1", + .summary = "Extra arguments appended to clangd's own command line, for troubleshooting " + "(e.g. `-j=8 --background-index-priority=background`).", + .summaryZh = "追加到 clangd 自身命令行末尾的额外参数,用于排查问题(例如 `-j=8 --background-index-priority=background`)。", + }, + // ---- Editor experience ---------------------------------------------------------------- + Setting { + .key = "semanticTokens.modules", .kind = Kind::boolean, .defaultValue = "true", .surface = Surface::server, + .applies = Applies::restart, .category = "editor", .since = "0.0.4", + .summary = "Color `import`, `module`, `export` and module names from the server's semantic tokens. Off: only the " + "grammar's colors.", + .summaryZh = "用服务端的语义 token 给 `import`、`module`、`export` 和模块名上色。关闭后只用语法文件的颜色。", + .clientConfigurable = true, + }, + Setting { + .key = "semanticTokens.moduleType", .kind = Kind::boolean, .defaultValue = "false", .surface = Surface::server, + .applies = Applies::restart, .category = "editor", .since = "0.0.4", + .summary = "A client declares it knows the custom `module` semantic token type and the `partition` modifier (design " + "2026-09-25 K/§7); off is every client but this one, since none else advertises it. Not a package.json " + "setting: VS Code's own extension always declares it, fixed, because it contributes that token type itself.", + .summaryZh = "客户端声明自己认得自定义的 `module` 语义 token 类型和 `partition` 修饰符(设计 2026-09-25 K/§7);除本仓库的 " + "VS Code 扩展外都关闭,因为没有别的客户端会声明它。不是 package.json 里的设置:VS Code 扩展自己贡献了这个 token " + "类型,因此固定声明为开。", + }, + Setting { + .key = "completion.triggerOnSpace", .kind = Kind::boolean, .defaultValue = "true", .surface = Surface::server, + .applies = Applies::restart, .category = "editor", .since = "0.0.5", + .summary = "Show the module list as soon as a space is typed after `import` or `export import`. A space anywhere else " + "never reaches the server. A client that says nothing gets this only when it identifies itself as VS Code or " + "a fork of it; every other client opts in with `initializationOptions.completion.triggerOnSpace: true`.", + .summaryZh = "在 `import` 或 `export import` 后输入空格时立即弹出模块列表;其他位置的空格不会发给服务端。什么都不说的客户端" + "只有在自证是 VS Code 或其分支时才会得到这个行为;其他客户端需要用 " + "`initializationOptions.completion.triggerOnSpace: true` 主动开启。", + .clientConfigurable = true, + }, + Setting { + .key = "index.primeImplementationUnits", .kind = Kind::enumeration, .values = { "auto", "off" }, .defaultValue = "auto", + .commandLine = "--prime-implementation-units", .surface = Surface::server, .applies = Applies::immediately, + .category = "editor", .since = "0.0.6", + .summary = "mcppls opens a module's implementation units in clangd in the background (0.0.6 plan §2.6, §9 T5), so " + "go-to-definition reaches a definition that only an implementation unit has. `off`: only what a client opens " + "itself is ever indexed for this.", + .summaryZh = "mcppls 在后台把一个模块的实现单元打开给 clangd(0.0.6 计划 §2.6、§9 T5),这样跳到定义能到达只存在于实现单元里" + "的定义。`off`:只有客户端自己打开的文件才会被索引。", + .clientConfigurable = true, + }, + Setting { + .key = "detectConflicts", .kind = Kind::boolean, .defaultValue = "true", .surface = Surface::client, + .applies = Applies::immediately, .category = "editor", .since = "0.0.1", + .summary = "Offer once to turn off another C++ extension's language features in this workspace, and say so when one " + "becomes active later. VS Code only: no other client arbitrates between language servers.", + .summaryZh = "在此工作区中提议关闭另一个 C++ 扩展的语言功能(只提议一次),之后又有冲突扩展启用时会提示。仅限 VS Code:其他" + "客户端不会在多个语言服务端之间做取舍。", + .clientConfigurable = true, + }, + // ---- Diagnostics and logging ---------------------------------------------------------- + Setting { + .key = "logLevel", .kind = Kind::enumeration, .values = { "debug", "info", "warning", "error" }, .defaultValue = "info", + .commandLine = "--log-level", .surface = Surface::server, .applies = Applies::restart, .category = "diagnostics", + .since = "0.0.1", + .summary = "The server's own log level.", + .summaryZh = "服务端自身的日志级别。", + }, + Setting { + .key = "disableWorkaround", .kind = Kind::list, .defaultValue = "", .commandLine = "--disable-workaround", + .commandLineRepeatable = true, .surface = Surface::server, .applies = Applies::restart, .category = "diagnostics", + .since = "0.0.4", + .summary = "Turn off a registered clangd workaround (`WA-CLANGD-`, see the SKILL.md upstream-defects register), to " + "see whether it is still needed; repeatable. `mcppls report` lists every registered workaround under " + "`engines[].details.workarounds`.", + .summaryZh = "关掉一个针对 clangd 缺陷登记的规避措施(`WA-CLANGD-`,见 SKILL.md 的上游缺陷登记),用来确认它是否还有" + "必要;可重复。`mcppls report` 在 `engines[].details.workarounds` 下列出所有登记过的规避措施。", + }, + Setting { + .key = "trace.server", .kind = Kind::enumeration, .values = { "off", "messages", "verbose" }, .defaultValue = "off", + .surface = Surface::client, .applies = Applies::immediately, .category = "diagnostics", .since = "0.0.1", + .summary = "Log the LSP traffic to the C++ Modules output channel (at Trace level); `verbose` adds the server's debug " + "log (at Debug level, by also passing `--log-level debug`). Set the channel's own log level to see them.", + .summaryZh = "把 LSP 通信记录到 C++ Modules 输出通道(Trace 级别);`verbose` 还会打开服务端的 debug 日志(通过附加 " + "`--log-level debug`,Debug 级别)。要看到它们,需把该输出通道的日志级别调到对应级别。", + .clientConfigurable = true, + }, + Setting { + .key = "MCPPLS_LOG_LEVEL", .kind = Kind::enumeration, .values = { "debug", "info", "warning", "error" }, .defaultValue = "", + .surface = Surface::environment, .applies = Applies::restart, .category = "diagnostics", .since = "0.0.1", + .summary = "Overrides the log level the VS Code extension starts the server with, ahead of `trace.server`.", + .summaryZh = "覆盖 VS Code 扩展启动服务端时使用的日志级别,优先于 `trace.server`。", + }, + // ---- AI review -------------------------------------------------------------------- + Setting { + .key = "ai.enabled", .kind = Kind::boolean, .defaultValue = "false", .surface = Surface::client, + .applies = Applies::immediately, .category = "ai", .since = "0.0.1", + .summary = "Show the AI-era features: Review Changes reviews the workspace's changes against `HEAD` with mcppls's " + "rules and shows the findings, with their evidence, as problems. Nothing is sent to a model unless this is " + "on and a model source is separately configured.", + .summaryZh = "显示 AI 时代的功能:Review Changes 用 mcppls 的规则对照 `HEAD` 审查工作区的变更,并把发现连同证据以问题的形式" + "展示。除非这里开启并且另行配置了模型来源,否则不会向任何模型发送内容。", + .clientConfigurable = true, + }, + // ---- Paths -------------------------------------------------------------------------- + Setting { + .key = "database", .kind = Kind::path, .defaultValue = "", .commandLine = "--database", .surface = Surface::server, + .applies = Applies::reload, .category = "paths", .since = "0.0.1", + .summary = "A workspace's own S1 build database, relative to its root, used instead of detecting one.", + .summaryZh = "工作区自己的 S1 构建数据库,相对于其根目录,用它代替探测。", + }, + Setting { + .key = "mcpp", .kind = Kind::path, .defaultValue = "", .commandLine = "--mcpp", .surface = Surface::server, + .applies = Applies::reload, .category = "paths", .since = "0.0.1", + .summary = "The `mcpp` executable for mcpp projects; empty means found on `PATH`.", + .summaryZh = "mcpp 项目所用的 `mcpp` 可执行文件;空表示在 `PATH` 上查找。", + }, + Setting { + .key = "payload", .kind = Kind::path, .defaultValue = "", .commandLine = "--payload", .surface = Surface::server, + .applies = Applies::restart, .category = "paths", .since = "0.0.1", + .summary = "Payload directory with clangd and the semantic kit; overridden per-file by `clangd` and `kit` below.", + .summaryZh = "包含 clangd 和语义工具包的 payload 目录;下面的 `clangd` 和 `kit` 可以分别覆盖其中一项。", + }, + Setting { + .key = "clangd", .kind = Kind::path, .defaultValue = "", .commandLine = "--clangd", .surface = Surface::server, + .applies = Applies::restart, .category = "paths", .since = "0.0.1", + .summary = "clangd executable, overriding the one the payload carries.", + .summaryZh = "clangd 可执行文件,覆盖 payload 自带的那一份。", + }, + Setting { + .key = "kit", .kind = Kind::path, .defaultValue = "", .commandLine = "--kit", .surface = Surface::server, + .applies = Applies::restart, .category = "paths", .since = "0.0.1", + .summary = "Semantic kit directory, overriding the one the payload carries.", + .summaryZh = "语义工具包目录,覆盖 payload 自带的那一份。", + }, + Setting { + .key = "MCPPLS_CACHE_DIR", .kind = Kind::path, .defaultValue = "", .surface = Surface::environment, + .applies = Applies::restart, .category = "paths", .since = "0.0.1", + .summary = "Overrides the whole cache directory mcppls otherwise picks under the user's cache home (workspace models, " + "toolchain probes, logs, diagnostic bundles).", + .summaryZh = "覆盖 mcppls 原本在用户缓存目录下选定的整个缓存目录(工作区模型、工具链探测结果、日志、诊断包)。", + }, + }; + return rows; +} + +// A value's string form as read out of `object`, per the row's `Kind` (T1: `initializationOptions` +// and `didChangeConfiguration` may write a boolean, a number, a string or an array of strings, +// depending on the row). Null when `object`'s JSON type does not fit the row's kind at all. +std::optional json_to_text(const Setting& row, const Json& object) { + switch (row.kind) { + case Kind::boolean: + return object.is_boolean() ? std::optional { std::string { object.get() ? "true" : "false" } } : std::nullopt; + case Kind::seconds: + if (object.is_number_integer()) return std::to_string(object.get()); + if (object.is_string()) return object.get(); + return std::nullopt; + case Kind::list: { + if (object.is_string()) return object.get(); + if (!object.is_array()) return std::nullopt; + std::vector parts; + for (const auto& item : object) { + if (!item.is_string()) return std::nullopt; + parts.push_back(item.get()); + } + return base::join(parts, ","); + } + case Kind::enumeration: + case Kind::string: + case Kind::path: + return object.is_string() ? std::optional { object.get() } : std::nullopt; + } + return std::nullopt; +} + +// Validates `text` (already in the row's own string form) against its `Kind`'s vocabulary, +// returning the canonical stored text, or null with a `Problem` appended to `problems` when it is +// outside that vocabulary -- the caller then keeps the row at whatever it already was (T1: an +// unknown value never silently takes effect). +std::optional validate(const Setting& row, std::string_view text, std::vector& problems) { + switch (row.kind) { + case Kind::boolean: + if (text == "true" || text == "false") return std::string { text }; + problems.push_back({ row.key, std::format("{} is not true or false; keeping the default", text) }); + return std::nullopt; + case Kind::enumeration: + if (std::ranges::find(row.values, text) != row.values.end()) return std::string { text }; + problems.push_back( + { row.key, std::format("{} is not one of {}; keeping the default", text, base::join(row.values, ", ")) }); + return std::nullopt; + case Kind::seconds: { + // Full consumption, no sign: `stoll` alone would silently accept "10 minutes please". + if (!text.empty() && std::ranges::all_of(text, [](char c) { return c >= '0' && c <= '9'; })) { + try { + return std::to_string(std::stoll(std::string { text })); + } catch (...) { + } + } + problems.push_back({ row.key, std::format("{} is not a non-negative number of seconds; keeping the default", text) }); + return std::nullopt; + } + case Kind::list: { + std::vector members; + for (auto piece : base::split(text, ',')) { + const auto trimmed = base::trim(piece); + if (trimmed.empty()) continue; + if (!row.values.empty() && std::ranges::find(row.values, trimmed) == row.values.end()) { + problems.push_back({ row.key, + std::format("{} is not one of {}; keeping the default", trimmed, base::join(row.values, ", ")) }); + return std::nullopt; + } + members.emplace_back(trimmed); + } + return base::join(members, ","); + } + case Kind::string: + case Kind::path: + return std::string { text }; + } + return std::nullopt; +} + +// An object's own key `dottedKey` when it has one literally (covers a plain key and the dotted-key +// form, `{"semanticTokens.modules": false}`), else the same path walked as nested objects +// (`{"semanticTokens": {"modules": false}}`) -- T1 accepts either from a client. +const Json* find_dotted_or_nested(const Json& root, std::string_view dottedKey) { + if (!root.is_object()) return nullptr; + if (auto it = root.find(std::string { dottedKey }); it != root.end()) return &*it; + const Json* cursor { &root }; + std::size_t start { 0 }; + while (true) { + const auto dot = dottedKey.find('.', start); + const std::string_view segment { dottedKey.substr(start, dot == std::string_view::npos ? std::string_view::npos : dot - start) }; + if (!cursor->is_object()) return nullptr; + auto it = cursor->find(std::string { segment }); + if (it == cursor->end()) return nullptr; + if (dot == std::string_view::npos) return &*it; + cursor = &*it; + start = dot + 1; + } +} + +// `object`, or its nested `mcppls` object when it has one: both `initializationOptions` and +// `didChangeConfiguration.settings` may or may not carry that wrapper (T1). +const Json& unwrap_mcppls(const Json& object) { + if (object.is_object()) { + if (auto it = object.find("mcppls"); it != object.end() && it->is_object()) return *it; + } + return object; +} + +const Json* find_setting_json(const Json& scope, const Setting& row) { + if (const Json* found = find_dotted_or_nested(scope, row.key)) return found; + for (const auto& alias : row.aliases) { + if (const Json* found = find_dotted_or_nested(scope, alias)) return found; + } + return nullptr; +} + +Json typed_json(const Setting& row, const std::string& text) { + switch (row.kind) { + case Kind::boolean: + return text == "true"; + case Kind::seconds: + try { + return std::stoll(text); + } catch (...) { + return text; + } + case Kind::list: { + Json array = Json::array(); + for (auto piece : base::split(text, ',')) { + const auto trimmed = base::trim(piece); + if (!trimmed.empty()) array.push_back(std::string { trimmed }); + } + return array; + } + case Kind::enumeration: + case Kind::string: + case Kind::path: + return text; + } + return text; +} + +} // namespace + +std::string_view to_string(Kind kind) { + switch (kind) { + case Kind::boolean: return "boolean"; + case Kind::enumeration: return "enumeration"; + case Kind::string: return "string"; + case Kind::path: return "path"; + case Kind::seconds: return "seconds"; + case Kind::list: return "list"; + } + return "?"; +} + +std::string_view to_string(Surface surface) { + switch (surface) { + case Surface::server: return "server"; + case Surface::client: return "client"; + case Surface::environment: return "environment"; + } + return "?"; +} + +std::string_view to_string(Applies applies) { + switch (applies) { + case Applies::restart: return "restart"; + case Applies::reload: return "reload"; + case Applies::immediately: return "immediately"; + } + return "?"; +} + +std::string_view to_string(Origin origin) { + switch (origin) { + case Origin::defaulted: return "default"; + case Origin::environment: return "environment"; + case Origin::commandLine: return "command-line"; + case Origin::client: return "client"; + case Origin::clientUpdated: return "client-updated"; + } + return "?"; +} + +std::span registry() { return shipped_registry(); } + +const Setting* find(std::span rows, std::string_view key) { + for (const auto& row : rows) { + if (row.key == key) return &row; + if (std::ranges::find(row.aliases, key) != row.aliases.end()) return &row; + } + return nullptr; +} + +Settings::Settings(std::span rows) : rows_(rows) { + for (const auto& row : rows_) { + if (row.surface == Surface::environment) { + if (auto value = platform::env::get(row.key)) { + values_[row.key] = { *value, Origin::environment }; + continue; + } + } + values_[row.key] = { row.defaultValue, Origin::defaulted }; + } +} + +std::span Settings::rows() const { return rows_; } + +const Value& Settings::value(std::string_view key) const { + static const Value fallback {}; + const auto it = values_.find(key); + return it != values_.end() ? it->second : fallback; +} + +std::string Settings::string_value(std::string_view key) const { return value(key).text; } + +bool Settings::bool_value(std::string_view key) const { return value(key).text == "true"; } + +std::chrono::seconds Settings::seconds_value(std::string_view key) const { + try { + return std::chrono::seconds { std::stoll(value(key).text) }; + } catch (...) { + return std::chrono::seconds { 0 }; + } +} + +std::vector Settings::list_value(std::string_view key) const { + std::vector members; + for (auto piece : base::split(value(key).text, ',')) { + const auto trimmed = base::trim(piece); + if (!trimmed.empty()) members.emplace_back(trimmed); + } + return members; +} + +Origin Settings::origin(std::string_view key) const { return value(key).origin; } + +const std::vector& Settings::problems() const { return problems_; } + +void Settings::apply_command_line(const cmdline::ParsedArgs& args) { + for (const auto& row : rows_) { + if (row.surface == Surface::environment || row.commandLine.empty()) continue; + // Every command-line spelling here is "--name"; cmdline itself is asked about "name". + const std::string flag { row.commandLine.substr(2) }; + if (row.kind == Kind::boolean) { + if (!args.is_flag_set(flag)) continue; + values_[row.key] = { row.commandLineNegated ? "false" : "true", Origin::commandLine }; + continue; + } + if (row.kind == Kind::list && row.commandLineRepeatable) { + const auto given = args.option_or_empty(flag).values; + if (given.empty()) continue; + if (auto validated = validate(row, base::join(given, ","), problems_)) { + values_[row.key] = { *validated, Origin::commandLine }; + } + continue; + } + if (auto raw = args.value(flag)) { + if (auto validated = validate(row, *raw, problems_)) values_[row.key] = { *validated, Origin::commandLine }; + } + } +} + +void Settings::apply_initialization_options(const Json& initializationOptionsOrParams) { + const Json* init { &initializationOptionsOrParams }; + if (initializationOptionsOrParams.is_object()) { + if (auto it = initializationOptionsOrParams.find("initializationOptions"); + it != initializationOptionsOrParams.end() && it->is_object()) { + init = &*it; + } + } + if (!init->is_object()) return; + const Json& scope { unwrap_mcppls(*init) }; + for (const auto& row : rows_) { + if (row.surface == Surface::environment) continue; + if (values_[row.key].origin == Origin::commandLine) continue; + const Json* found { find_setting_json(scope, row) }; + if (found == nullptr) continue; + auto text { json_to_text(row, *found) }; + if (!text) { + problems_.push_back({ row.key, std::format("initializationOptions carries {} as the wrong kind of value; keeping {}", + row.key, values_[row.key].text) }); + continue; + } + if (auto validated = validate(row, *text, problems_)) values_[row.key] = { *validated, Origin::client }; + } +} + +ChangeResult Settings::apply_configuration_change(const Json& params) { + ChangeResult result; + const Json* settingsObject { ¶ms }; + if (params.is_object()) { + if (auto it = params.find("settings"); it != params.end()) settingsObject = &*it; + } + if (!settingsObject->is_object()) return result; + const Json& scope { unwrap_mcppls(*settingsObject) }; + for (const auto& row : rows_) { + if (row.surface == Surface::environment) continue; + auto it = values_.find(row.key); + if (it == values_.end() || it->second.origin == Origin::commandLine) continue; + const Json* found { find_setting_json(scope, row) }; + if (found == nullptr) continue; + auto text { json_to_text(row, *found) }; + if (!text) { + problems_.push_back( + { row.key, std::format("didChangeConfiguration carries {} as the wrong kind of value; keeping {}", row.key, it->second.text) }); + continue; + } + auto validated { validate(row, *text, problems_) }; + const std::string newValue { validated.value_or(row.defaultValue) }; + const bool changed { newValue != it->second.text }; + it->second = { newValue, Origin::clientUpdated }; + if (!changed) continue; + result.changedKeys.push_back(row.key); + if (row.applies == Applies::restart) result.restartKeys.push_back(row.key); + else if (row.applies == Applies::reload) result.reloadKeys.push_back(row.key); + } + return result; +} + +Json Settings::to_json() const { + Json result = Json::object(); + for (const auto& row : rows_) { + const auto& current = value(row.key); + result[row.key] = Json { { "value", typed_json(row, current.text) }, { "origin", std::string { to_string(current.origin) } } }; + } + Json problems = Json::array(); + for (const auto& problem : problems_) problems.push_back(Json { { "key", problem.key }, { "message", problem.message } }); + result["problems"] = std::move(problems); + return result; +} + +namespace { + +std::string setting_cell(const Setting& row) { + return row.surface == Surface::environment ? std::format("`{}`", row.key) : std::format("`mcppls.{}`", row.key); +} + +std::string backticked_join(std::span values, std::string_view separator) { + std::vector quoted; + quoted.reserve(values.size()); + for (const auto& value : values) quoted.push_back(std::format("`{}`", value)); + return base::join(quoted, separator); +} + +std::string values_cell(const Setting& row, bool zh) { + switch (row.kind) { + case Kind::boolean: return "`true`, `false`"; + case Kind::enumeration: return backticked_join(row.values, ", "); + case Kind::list: + if (row.values.empty()) return zh ? "`WA-CLANGD-`(可重复)" : "`WA-CLANGD-` (repeatable)"; + return backticked_join(row.values, ", ") + (row.commandLineRepeatable ? "" : (zh ? "(逗号分隔)" : " (comma-separated)")); + case Kind::seconds: return zh ? "非负整数(秒)" : "a non-negative number of seconds"; + case Kind::string: return zh ? "字符串" : "a string"; + case Kind::path: return zh ? "路径" : "a path"; + } + return "?"; +} + +std::string default_cell(const Setting& row, bool zh) { + switch (row.kind) { + case Kind::boolean: + case Kind::seconds: return std::format("`{}`", row.defaultValue); + case Kind::enumeration: + case Kind::string: + case Kind::path: return row.defaultValue.empty() ? (zh ? "(空)" : "*(empty)*") : std::format("`{}`", row.defaultValue); + case Kind::list: { + if (row.defaultValue.empty()) return zh ? "(无)" : "*(none)*"; + std::vector members; + for (auto piece : base::split(row.defaultValue, ',')) members.emplace_back(piece); + return backticked_join(members, ", "); + } + } + return "?"; +} + +std::string command_cell(const Setting& row, bool zh) { + if (row.commandLine.empty()) return "—"; + std::string cell { std::format("`{}`", row.commandLine) }; + if (row.kind == Kind::list && row.commandLineRepeatable) cell += zh ? "(可重复)" : " (repeatable)"; + return cell; +} + +std::string applies_cell(Applies applies, bool zh) { + if (!zh) return std::string { to_string(applies) }; + switch (applies) { + case Applies::restart: return "重启"; + case Applies::reload: return "重新加载模型"; + case Applies::immediately: return "立即生效"; + } + return "?"; +} + +} // namespace + +std::string to_markdown(std::span rows, std::string_view lang) { + const bool zh { is_zh(lang) }; + const std::string_view headSetting { zh ? "设置" : "Setting" }; + const std::string_view headValues { zh ? "取值" : "Values" }; + const std::string_view headDefault { zh ? "默认值" : "Default" }; + const std::string_view headCommand { zh ? "命令行" : "Command line" }; + const std::string_view headApplies { zh ? "生效方式" : "Applies" }; + const std::string_view headWhat { zh ? "作用" : "What it does" }; + + std::vector blocks; + for (const auto& info : CATEGORIES) { + std::vector members; + for (const auto& row : rows) { + if (row.category == info.key) members.push_back(&row); + } + if (members.empty()) continue; + std::string block { std::format("### {}\n\n", zh ? info.zh : info.en) }; + block += std::format("| {} | {} | {} | {} | {} | {} |\n", headSetting, headValues, headDefault, headCommand, headApplies, headWhat); + block += "|---|---|---|---|---|---|\n"; + for (const auto* row : members) { + block += std::format("| {} | {} | {} | {} | {} | {} |\n", setting_cell(*row), values_cell(*row, zh), default_cell(*row, zh), + command_cell(*row, zh), applies_cell(row->applies, zh), zh ? row->summaryZh : row->summary); + } + block.pop_back(); // the loop above leaves one trailing '\n'; blocks are joined by "\n\n" instead + blocks.push_back(std::move(block)); + } + return base::join(blocks, "\n\n"); +} + +Json registry_to_json(std::span rows) { + Json array = Json::array(); + for (const auto& row : rows) { + array.push_back(Json { + { "key", row.key }, + { "kind", std::string { to_string(row.kind) } }, + { "values", row.values }, + { "default", row.defaultValue }, + { "commandLine", row.commandLine }, + { "commandLineNegated", row.commandLineNegated }, + { "commandLineRepeatable", row.commandLineRepeatable }, + { "surface", std::string { to_string(row.surface) } }, + { "applies", std::string { to_string(row.applies) } }, + { "category", row.category }, + { "since", row.since }, + { "summary", row.summary }, + { "summaryZh", row.summaryZh }, + { "aliases", row.aliases }, + { "clientConfigurable", row.clientConfigurable }, + }); + } + return array; +} + +} // namespace mcppls::config::settings diff --git a/src/config/settings.cppm b/src/config/settings.cppm new file mode 100644 index 0000000..9242fc7 --- /dev/null +++ b/src/config/settings.cppm @@ -0,0 +1,163 @@ +// Every configurable behaviour of mcppls, in one place (0.0.6 plan §9 T1). Before this module, the +// same fact -- the default of `mcppls.buildTool`, say -- lived separately in the command line's +// `--build-tool` help text, `handle_initialize_`'s reading of `initializationOptions`, the VS Code +// extension's `package.json`, and the hand-written table in `docs/30-settings.md`; keeping them in +// step was a matter of remembering to. Here it lives once, as a row of `registry()`, and every one +// of those sites is derived from it: `mcppls.cli.commands` builds the global options from it, +// `session_options` and `handle_initialize_` fill a `Settings` from it, `mcppls settings` and +// `mcppls report` render it, and `tests/test_settings.cpp` holds the rendered docs and +// `editors/vscode/package.json` to it. +export module mcppls.config.settings; + +import std; +import nlohmann.json; +import mcpplibs.cmdline; + +export namespace mcppls::config::settings { + +using Json = nlohmann::json; + +// What kind of value a row takes. Purely descriptive for `string` and `path` (either accepts any +// text; `path` says so in the generated docs) -- the difference is real for the rest: `boolean` and +// `enumeration` are validated against a closed vocabulary, `seconds` against a non-negative +// integer, and `list` against zero or more comma-separated (or, on the command line, repeated) +// members. +enum class Kind { boolean, enumeration, string, path, seconds, list }; + +// Where a row is read: `server` is mcppls's own behaviour; `client` is read only by an editor +// plugin (kept here so the docs and `package.json` stay one table); `environment` is a variable of +// the process environment rather than a setting at all. +enum class Surface { server, client, environment }; + +// How a change to a row's value takes effect once mcppls is already running. +enum class Applies { restart, reload, immediately }; + +// Where a row's current value came from, in ascending precedence (T1's rule: command line beats +// initializationOptions beats the default; a later didChangeConfiguration updates a `client` or +// `clientUpdated` value, never one the command line set). +enum class Origin { defaulted, environment, commandLine, client, clientUpdated }; + +std::string_view to_string(Kind kind); +std::string_view to_string(Surface surface); +std::string_view to_string(Applies applies); +std::string_view to_string(Origin origin); + +// One row: everything about one configurable behaviour of mcppls. `key` is dotted, under the +// `mcppls.` namespace for a `server` or `client` row (`buildTool`, `semanticTokens.modules`); for +// an `environment` row it is the bare variable name (`MCPPLS_CACHE_DIR`). `values` is the closed +// vocabulary for `enumeration` and (when it has one) `list`; a `list` row with none accepts any +// non-empty member. `defaultValue` is the row's own string form of its default -- for `boolean`, +// `"true"` or `"false"`; for `list`, its members joined with `,`. `commandLine` is the flag's +// spelling (`"--build-tool"`), empty when there is none. `commandLineNegated` is for a boolean +// whose flag's presence means false against a true default (`--no-discover`). +// `commandLineRepeatable` is for a `list` row taken as one value per occurrence of the flag +// (`--disable-workaround`, repeated) rather than one occurrence with a comma-separated value +// (`--build-discovery-providers`). `clientConfigurable` says whether an editor's own settings UI +// is expected to expose this row at all: true for every `client` row and for a `server` row VS +// Code's `package.json` should carry a matching property for; false for one that is command-line +// only (a path to something on this machine, a workaround id, a timeout) or fixed by what a +// client's own capabilities declare (`semanticTokens.moduleType`) -- `tests/test_settings.cpp` +// reads this to know which rows to expect in `package.json` and which to expect absent. +struct Setting { + std::string key; + Kind kind { Kind::string }; + std::vector values; + std::string defaultValue; + std::string commandLine; + bool commandLineNegated { false }; + bool commandLineRepeatable { false }; + Surface surface { Surface::server }; + Applies applies { Applies::restart }; + std::string category; + std::string since; + std::string summary; + std::string summaryZh; + std::vector aliases; + bool clientConfigurable { false }; +}; + +// The shipped registry (0.0.6 plan §9 T1), in the order the generated docs list it: grouped by +// category, each category's rows in a fixed, meaningful order. +std::span registry(); + +// `key` (bare, dotted, no `mcppls.` prefix) matched against `rows`' own key or any of its +// `aliases`; null when none of `rows` answers to it. +const Setting* find(std::span rows, std::string_view key); + +// A row's effective value, in its own string form (see `Setting::defaultValue` above for what that +// form is per `Kind`), and where it came from. +struct Value { + std::string text; + Origin origin { Origin::defaulted }; +}; + +// Something wrong with one layer's attempt to set a row: an unknown key (`key` empty) or a value +// outside its kind's vocabulary (`key` names the row; the row's value was left at what it already +// was, never silently changed by a value nobody here recognizes). +struct Problem { + std::string key; + std::string message; +}; + +// What a `workspace/didChangeConfiguration` (or an equivalent later layer) actually changed, split +// by what taking it needs: a caller reloads each workspace's model for `reloadKeys`, and tells the +// person `restartKeys` needs a restart to take effect (`immediately` rows need neither: this +// module's caller reads the new value straight from `Settings` the next time it looks). +struct ChangeResult { + std::vector changedKeys; + std::vector restartKeys; + std::vector reloadKeys; +}; + +// The registry resolved against however many layers have been applied: every row's effective value, +// its origin, and the problems every layer applied so far ran into. Constructed at the row +// defaults (and an `environment` row's value read from the process environment, once); each +// `apply_*` layers a source of values over what is there, per T1's precedence. +class Settings { +public: + explicit Settings(std::span rows = registry()); + + std::span rows() const; + const Value& value(std::string_view key) const; + std::string string_value(std::string_view key) const; + bool bool_value(std::string_view key) const; + std::chrono::seconds seconds_value(std::string_view key) const; + // A `list` row's members, split from its stored comma-joined text; empty when the row's value is empty. + std::vector list_value(std::string_view key) const; + Origin origin(std::string_view key) const; + + // The command-line layer (T1a): every row with a `commandLine` spelling that `args` gives, + // validated and recorded at `Origin::commandLine`. Wins over every layer after it. + void apply_command_line(const mcpplibs::cmdline::ParsedArgs& args); + // `initializationOptions` (or the whole `initialize` params -- only that key is read): nested + // objects, dotted keys, or either wrapped in a top-level `mcppls` object, all accepted (T1). + // A row the command line already set is left alone. + void apply_initialization_options(const Json& initializationOptionsOrParams); + // `workspace/didChangeConfiguration`'s params: `params.settings.mcppls` (VS Code's own shape), + // or the same nested/dotted/wrapped forms `apply_initialization_options` accepts, directly on + // `params.settings` or `params` itself. Updates every row the command line did not set, and + // says what actually changed. + ChangeResult apply_configuration_change(const Json& params); + + const std::vector& problems() const; + + // `{"": {"value": ..., "origin": "..."}, ..., "problems": [...]}` (`mcppls report`'s + // `settings` object, T1). `value` is typed by the row's `Kind`: a JSON boolean, number or + // array of strings where that fits, a string otherwise. + Json to_json() const; + +private: + std::span rows_; + std::map> values_; + std::vector problems_; +}; + +// `mcppls settings --format markdown [--lang en|zh-CN]`: the reference table grouped by category +// (`docs/30-settings.md` and its zh-CN mirror embed this verbatim between two markers, so +// `tests/test_settings.cpp` can hold the file to the renderer byte for byte). +std::string to_markdown(std::span rows, std::string_view lang = "en"); + +// `mcppls settings --format json`: every field of every row, for a machine reader. +Json registry_to_json(std::span rows); + +} // namespace mcppls::config::settings diff --git a/src/orchestrator/report.cpp b/src/orchestrator/report.cpp index 5abe078..4c693c5 100644 --- a/src/orchestrator/report.cpp +++ b/src/orchestrator/report.cpp @@ -37,7 +37,7 @@ std::string utc_now(std::string_view format) { } // namespace Json make_report(Json roots, Json client, std::string_view engine, const engine::PayloadPaths& payload, bool payloadCorrupt, - std::chrono::steady_clock::duration uptime) { + std::chrono::steady_clock::duration uptime, Json settings) { return Json { { "generatedAt", utc_now("{:%FT%TZ}") }, { "server", Json { { "name", "mcppls" }, { "version", std::string { base::VERSION } }, { "platform", std::string { mcppls::os::PLATFORM } }, @@ -48,6 +48,7 @@ Json make_report(Json roots, Json client, std::string_view engine, const engine: { "payload", Json { { "directory", payload.directory }, { "clangd", payload.clangd }, { "clangdVersion", payload.clangdVersion }, { "kit", payload.kit }, { "kitNotice", payload.kitNotice }, { "platform", payload.platform }, { "corrupt", payloadCorrupt } } }, { "roots", std::move(roots) }, + { "settings", std::move(settings) }, { "logTail", log::recent(300) }, }; } diff --git a/src/orchestrator/report.cppm b/src/orchestrator/report.cppm index e1b172b..5527445 100644 --- a/src/orchestrator/report.cppm +++ b/src/orchestrator/report.cppm @@ -9,9 +9,10 @@ import mcppls.engine.payload; export namespace mcppls::orchestrator { // The report around the roots' own (Workspace::report): when, which server and client, which payload, -// and the latest lines of the log. +// the latest lines of the log, and `settings` (config settings §9 T1: every resolved value, its +// origin, and the problems every layer applied so far ran into -- `config::settings::Settings::to_json`). nlohmann::json make_report(nlohmann::json roots, nlohmann::json client, std::string_view engine, const engine::PayloadPaths& payload, - bool payloadCorrupt, std::chrono::steady_clock::duration uptime); + bool payloadCorrupt, std::chrono::steady_clock::duration uptime, nlohmann::json settings); // Opens `/logs/-