diff --git a/.agents/docs/2026-10-01-0.0.9-plan.md b/.agents/docs/2026-10-01-0.0.9-plan.md new file mode 100644 index 00000000..75ba8323 --- /dev/null +++ b/.agents/docs/2026-10-01-0.0.9-plan.md @@ -0,0 +1,468 @@ +# mcppls 0.0.9 方案:非模块项目的补全提速、设置键冲突、xmake 下载体验、冷启动(issue #37) + +- 状态:方案第 2 版,已定(§7),实现中,见 §10 实现记录。分支 `release/0.0.9`,单个 PR,版本 0.0.9。 +- 日期:2026-10-01。 +- 基于:`origin/main` 9d9f034(0.0.8)。 + +## 依据 + +- **分析报告**:`.agents/docs/reviews/2026-10-01-issue-37-review.md`。报告里有证据、复现方法和数字,本文只引用编号,不再重复。 +- **用户诊断包**:`mcppls-bundle-20261001T094207.379Z.zip`。项目是 vulkan-rt(Stehsaer),xmake 构建,Debian testing,VS Code 1.138。 +- **本机实验**: + - payload 自带的 clangd 23.1.0 与 llvm-tools 22.1.8; + - 用 LSP 驱动做 completion 计时和 tidy 诊断抓取; + - xmake 3.1.1,在隔离的 `XMAKE_GLOBALDIR` 下运行。 +- **上游登记**:issue #24 的 UP-22(clang-tidy const 误报)和 UP-23(modules 支持导致每次请求都扫描)。 + +## 编号与证据等级 + +- 编号: + - **W-\***:clangd workaround,已实现; + - **S-\***:设置; + - **D-\***:xmake 下载与离线; + - **P-\***:启动和冷启动; + - **M-\***:度量和测试; + - **R-\***:发布与回复。 +- 证据等级沿用 0.0.8 方案: + - **已验证**:复现过或实测过; + - **代码确认**:读代码可以证明,没有运行; + - **已观察**:在现场出现过,机制没有完全确认; + - **推断**:待验证。 + +## 0. 摘要 + +| 编号 | 做什么 | 解决 | 状态 | +|---|---|---|---| +| W-1 | WA-CLANGD-009:不用模块的项目启动 clangd 时不带 `--experimental-modules-support` | 补全慢约 3 倍(用户说的 850 ms 对 250 ms) | **已实现**;补全中位数 259 → 97 ms | +| W-2 | WA-CLANGD-010:丢弃 clang-tidy 23.1 对"没有 const begin() 的 view"给出的 const 建议 | issue #37 正文的误报 | **已实现** | +| S-1 | 消除设置键的父子冲突:`engine` / `engine.workers`,`buildDiscovery` / `.providers` / `.askBeforeDownload` | workers 显示 undefined 并报 regex 错 | 设计(Q1) | +| S-2 | 扩展把 `engine.workers` 发给 server | 在 VS Code 里设置 workers 不生效 | 设计 | +| S-3 | `null` 视为未设置 | `compiler: null` 被报成类型错误 | 设计 | +| S-4 | 设置注册表的一致性测试 | 防止 S-1 到 S-3 再次出现 | 设计 | +| D-1 | 读 xmake.conf 时只传 `xmake f` 接受的选项 | 每次启动第一次 configure 必然失败,并弹出错误的 notice | 设计 | +| D-2 | "需要下载"的提示区分离线和在线,在线失败要说出真正原因 | "stayed offline" 是硬编码的;online 模式下下载按钮等于空转 | 设计 | +| D-3 | 提示里说明这些是"构建工具或传递依赖",并给出系统包管理器的路径 | 用户只能进日志自己查 | 设计 | +| D-4 | 下载授权提供"仅这次"和"本工作区始终允许" | 用户感觉"只对当前窗口有效" | 设计(Q2) | +| D-5 | 在线运行结束后报告结果 | 失败时没有任何提示,只写进日志 | 设计 | +| D-6 | `xmake project` 阶段的缺包也能识别 | 现在归为一般的 configure-failed | 设计 | +| P-1 | 次实例只读复用主实例的模型缓存,并排查旧 server 没有退出的原因 | 第二实例冷启动,前 3–4.4 s 很慢 | 设计 + 调查 | +| P-2 | 没有缓存时多等一会儿 xmake,而不是先用推断模型启动 clangd 再重启 | preamble 建两次(每次约 3 s) | 设计(Q3) | +| P-3 | "a loaded model" 让事件循环阻塞 446 / 1803 ms,需要找出原因 | 冷启动卡顿 | 调查 | +| M-1 | 诊断包的请求统计拆成 clangd 耗时和 mcppls 开销 | 以后一份诊断包就能分清谁慢 | 设计 | +| M-2 | 新增一个非模块、头文件很重的 UX fixture,与普通 clangd 对比 | UX 场景测试只覆盖模块项目 | 设计 | +| M-3 | W-1、W-2 的 canary 和 conformance | workaround 的退出条件 | 设计 | +| R-1 | 向 LLVM 提交 UP-22 和 UP-23 | 上游修复 | 待办 | +| R-2 | 回复 #37;核查 0.0.8 payload 的 `dirty: true` | 用户沟通;发布流水线 | 待办 | + +--- + +## 1. 已实现:两个 clangd workaround + +两条都在 workaround 注册表 `src/engine/clangd/workarounds.cpp` 里登记,代码调用处都标了编号。可以用 `mcppls.disableWorkaround` 关掉,`mcppls report` 的 `workarounds` 里能看到它们。 + +### W-1 WA-CLANGD-009:没有模块的项目不开 clangd 的 modules 支持(已验证) + +**问题**(UP-23): + +- 带上 `--experimental-modules-support` 后,clangd **每次补全**都要重新扫描一遍文件的模块依赖,整个 TU 连同 vulkan.hpp 都要预处理一遍。 + - verbose 日志里 driver 调用次数是 25 对 13。 + - 补全中位数从 82 ms 升到 254 ms,最慢 869 ms;`.cpp` 和头文件都一样。 +- mcppls 0.0.8 无条件带这个参数(`process.cpp`)。 + +**做法**: + +- 判定:`plan_uses_modules(plan)`(`clangd.cpp`)。满足以下任一条,就认为项目使用了模块: + - 有 provides 或 module 的单元; + - 有 import(包括 `import std;`); + - stdUnits > 0; + - 有替身单元; + - 有带模块名的 issue。 +- 开关:`ProcessConfig::modulesSupport` 决定 `clangd_arguments` 是否带这个参数。 +- 什么时候切换,在 `apply(plan)` 里决定: + - **关**:只在**第一个 plan** 时关。这时 clangd 还没有拿到任何文档,重启没有代价。 + - **开**:任何一个用到模块的 plan 都会打开它,并立即重启 clangd(cause=user,不计入重启预算)。 + - 打开后本次会话里不再关回去,避免模块来来去去时反复重启。 +- 记忆:判定结果写在 `/contexts/default/modules-support`(内容为 `on` 或 `off`)。下次启动直接按它启动 clangd,不需要再重启;重置缓存会把它一并清掉。 + +**实测**(vulkan-hpp 用例,经过 mcppls): + +- 补全中位数 259 ms → **97 ms**,最慢 299 → 116 ms。 +- 第一次会话在第一个 plan 时重启一次 clangd,发生在打开文件之前。 +- 第二次会话 0 次重启,判定结果为 `off`。 + +**回归**: + +- 单元测试 33/33 通过。 +- conformance 15 个 fixture 全部通过(见 §8 的实现记录)。其中 cmake-fetchcontent-offline 是非模块项目,走的正是关闭 modules 支持、提前重启这条路径。 + +**风险**: + +- 在一个原本没有模块的项目里新写 `import`:要等 plan 里出现这个 import 才会重启 clangd 并打开 modules 支持。这期间 clangd 会报 "module not found"。这种情况每个会话最多发生一次,代价是几秒。 +- 判定依据是 plan,所以被 left out 的文件不算。但它们一定会以 issue 或替身单元的形式出现,判定能覆盖到。 +- 将来如果上游修了 UP-23,判定条件就是这条 workaround 的 `removeWhen`。 + +### W-2 WA-CLANGD-010:view 的 const 误报(已验证) + +**问题**(UP-22): + +- clang-tidy 23.1 的 `misc-const-correctness` 会对保存以下视图的变量说"可以声明为 const": + - `filter_view`、`drop_while_view`、`chunk_by_view`、`split_view`; + - 以及建立在它们之上的 view。 +- 这类 view 会缓存 `begin()`,没有 const 版本,加上 const 就编译不过。 +- 22.1.8 对"由 adaptor 返回的 view"不报。 +- 前提是用户的 clangd 配置写了 `FastCheckFilter: None`,否则这项检查根本不会运行。 + +**做法**: + +- 在 `rewrite_diagnostics_` 中,对 code 为 `misc-const-correctness` 的诊断用 `const_correctness_on_non_const_view(message)` 判断,命中就丢弃。 +- 判断规则: + - 类型取 `aka` 后面的规范类型;没有 `aka` 就取拼写出来的类型。 + - 类型必须是 std::ranges 的 view,即 `std::ranges::`、`ranges::`、`std::__1::ranges::` 前缀,或 libc++ 在 aka 里打印的不带命名空间的名字。 + - 沿着"第一个模板实参",也就是 view 的 base 链往下走,遇到上面 4 种 view 就算命中。 + - 遇到 `ref_view` 就停:`ref_view` 的 const `begin()` 能直接访问它引用的 range,所以对它的 const 建议是对的。 +- 测试覆盖: + - 应当丢弃:libstdc++ 和 libc++ 的真实消息、显式写出的 `filter_view`、transform 套在 filter 上、drop_while、chunk_by、split; + - 应当保留:`int`、数组上的 transform(真阳性,g++ 已确认加 const 能编译)、经过 ref_view 的情况、lazy_split、`vector`、非 std 的同名类型、其他检查的消息。 + +**实测**(std 用例,经过 mcppls):0.0.8 报 8 条;新版本报 2 条,剩下的 transform 和 int 两条都是真阳性。 + +**风险**: + +- 一个 filter view 如果从来没被迭代过,const 建议本来是对的,现在会被丢掉,损失的只是一条风格提示。 +- 匹配依赖消息格式。如果上游改了格式,这条 workaround 会自动失效,误报重新出现,不会误删其他诊断,失效方向是安全的。 + +--- + +## 2. 设置(S-\*) + +### S-1 消除父子键冲突(代码确认 + VS Code 源码确认;Q1) + +**现状**: + +- `mcppls.engine` 是 string,`mcppls.engine.workers` 是它的子键;`mcppls.buildDiscovery` 是 string,`.providers` 和 `.askBeforeDownload` 是它的子键。 +- VS Code 构建配置树时,碰到父节点已经是标量就会执行 `Ignoring as is ""`,或者用父键覆盖整个对象。 +- 结果是设置界面拿到 `undefined`: + - workers 校验 pattern 失败,显示 "Value must match regex"; + - askBeforeDownload 的复选框显示为未勾选,与实际生效的 true 不一致。 +- 扩展里的 `get(key, default)` 兜住了读取,但兜不住界面显示。 +- server 端 `didChangeConfiguration` 的嵌套 JSON 也有同样的冲突:`{"engine": "clangd"}` 里放不下 `engine.workers`。 + +**方案 A(推荐):父键改名为 `.mode` / `.name`,保留分组** + +| 旧 | 新 | +|---|---| +| `mcppls.engine` | `mcppls.engine.name`(`clangd` / `none`) | +| `mcppls.engine.workers` | 不变 | +| `mcppls.buildDiscovery` | `mcppls.buildDiscovery.mode`(`auto` / `off`) | +| `mcppls.buildDiscovery.providers` / `.askBeforeDownload` | 不变 | + +- 设置界面里显示为 "Engine › Name"、"Engine › Workers"、"Build Discovery › Mode / Providers / Ask Before Download"。 +- 迁移: + - 扩展用 `inspect('engine')` 读旧键的用户值和工作区值;有旧值、没有新值时按旧值生效,并提示一次"已改名"。旧键保留一个版本,标记为 `deprecationMessage`。 + - server 的设置注册表同时接受旧键和新键;CLI 参数 `--engine` 不变。 + - 文档 `30-settings.md`(中英文)、CLion 和 Zed 的设置映射一起改。 + +**方案 B:子键改成扁平名** + +- 例如 `mcppls.engineWorkers`、`mcppls.buildDiscoveryProviders`、`mcppls.askBeforeDownload`。 +- 改动更小,但失去分组,命名也和其他设置不一致。 + +### S-2 workers 真的传给 server(代码确认) + +- `extension.ts` 构造 initializationOptions 时加上 `engine.workers`。 +- `package.json` 加 `patternErrorMessage`:"auto,或 1–99 的整数"。 +- 空字符串按 auto 处理:pattern 改为 `^(auto|[1-9][0-9]?)?$`,server 端本来就把空串当作 auto。 +- 修改后需要重启 server,现有逻辑里 `affectsConfiguration('mcppls.engine')` 已经会触发重启,S-1 改名后要同步更新这个判断。 + +### S-3 `null` 等于未设置(代码确认) + +- `settings.cpp` 的 `apply_initialization_options` 和 `apply_configuration_change` 改为: + - 遇到 `found->is_null()` 时,前者跳过; + - 后者恢复默认值,origin 改为 default。 +- 扩展端可以继续发 null,不需要改。 + +### S-4 一致性测试 + +在 `tests/test_settings.cpp` 的 package.json 检查中新增: + +1. 任何设置键都不能是另一个**非 object 类型**设置键的前缀。S-1 改完之后,这条测试保证冲突不会再出现。 +2. 有 `pattern` 的设置,默认值必须匹配自己的 pattern,并且要有 `patternErrorMessage`。 +3. 注册表里每个可由客户端配置的键,都要出现在扩展构造的 initializationOptions 里。可以在扩展单测里断言,也可以用 grep 方式检查。 +4. 传入 `null` 时不产生 problems,origin 保持 default。 + +--- + +## 3. xmake 下载与离线(D-\*) + +### 背景(已验证) + +- vulkan-rt 对 `libsdl3`、`vulkan-hpp`、`vulkan-headers` 用了 `system = false`,强制从源码构建。 +- libsdl3 在 Linux 上会拉入 libx11、libxcb、wayland,进一步拉入 libpthread-stubs,以及 libtool、meson 等构建工具。 +- 这些包在 xmake-repo 里都声明了 `add_extsources("apt::...")`,**系统里装了就直接用系统的**。这就是用户"系统装上就好了"的原因。 +- 本机按 mcppls 的离线参数运行,xmake 报出的缺失列表中,构建工具和真正的库混在一起: + ``` + libassert, util-macros, ca-certificates, python, xcb-proto, libtool, libsdl3, imgui, vulkan-hpp + ``` + 其中 util-macros、python、xcb-proto 已经在包目录里,仍被报告缺失。 + +### D-1 只传 `xmake f` 接受的选项(已验证) + +**现状**: + +- `.xmake///xmake.conf` 里有 xmake 自己写进去的键,比如 `proxy`、`dotnet`、`dotnet_sdkver`,以及各种工具链探测结果。 +- 这些选项 `xmake f` 一律报 `Invalid option`(xmake 3.1.1 本机实测,exit 255)。 +- mcppls 把它们当成用户选项传回去: + - 每次启动第一次 configure 必然失败; + - 降级重试要多花约 100 ms; + - 然后给出 notice "run `xmake f -c` to renew"。这个 notice 是错的,用户照做之后这些键依然会在。 + +**方案**: + +- 在项目目录里运行一次 `xmake f --help`,解析出选项列表。列表里包括项目自己用 `option()` 声明的选项。 +- 列表按 `xmake` 可执行文件的路径和 mtime,加上 `xmake.lua` 的 stamp 缓存。 +- 只传 xmake.conf 中同时出现在这个列表里的键。不在列表里的键只记一行 info 日志,不弹 notice。 +- 降级重试保留,作为兜底:例如 `--help` 解析失败时。 + +**收益**:每次启动少一次失败的 xmake 运行和一条错误提示。另外,过去降级重试会丢掉所有非标准选项,项目 `option()` 的值也会一起丢;改完之后能保住。 + +**备选**:在 `INTERNAL` 里静态追加 `proxy`、`dotnet*`、`*_sdkver`。改动最小,但会随 xmake 版本漂移。 + +### D-2 提示区分离线和在线(代码确认) + +- `xmake.cpp:175` 按 `context.offline` 生成提示文字: + - **离线**:沿用现在的文字,加上 D-3 的说明。 + - **在线失败**:写成"xmake 安装 `<包>` 失败",附上 xmake 输出最后几行里的 `error:`,以及它给出的 `installdir.failed/logs/install.txt` 路径。code 改为 `producer-install-failed`。 +- `workspace.cpp:1933`:如果这次运行本来就是在线的(`buildTool=online`,或 describeOnline),`askOnline` 设为 false,不再提供空转的"下载并继续"。 +- cmake 和 meson 中写死的同类文字一并修改,即 `cmake.cpp:455` 和 `meson.cpp:124`。 + +### D-3 说清楚是什么、怎么办(推断 + 代码确认) + +提示文字的结构: + +> xmake needs libpthread-stubs, libtool downloaded. They are what xmake builds `libsdl3` from (a requirement of this project that is set to build from source); this run stayed offline. Download them now, run xmake in your terminal, or install them with your system package manager — xmake uses the system's when it finds them. + +- 要指出"由哪个直接依赖带进来",需要知道依赖链: + - 第一版:只列出 `xmake.lua` 里 `add_requires` 中的直接依赖名,因为 xmake 的输出里没有依赖链; + - 第二版:用 `xmake require --info ` 拿到 deps 和 extsources,给出 apt、pacman、brew 的包名。这需要在线或本地仓库,**只在用户点击"查看建议"时运行**。 +- status issue 的 detail 字段带上完整的包列表,扩展通知里只显示前 3 个,后面写"等 N 个"。 + +### D-4 下载授权:仅这次 / 本工作区始终允许(代码确认;Q2) + +- **现状**: + - "Download and Continue" 只对一次加载生效(`describe_online()` → `onlineOnce`),下一次加载又是离线; + - "Don't Ask Again" 存在 workspaceState 里,是持久的,结果是"不再询问,也不会下载"。 +- **方案**:通知按钮改为: + - **下载一次**:现有行为; + - **始终允许本工作区下载**:扩展在 workspaceState 中记下这个工作区,以后 initializationOptions 里对这个根带上 `buildTool: online`; + - **在终端运行**; + - **不再询问**。 +- 状态栏里提供"撤销始终允许"。 +- **原则**:不写项目里的 `.vscode/settings.json`,遵守"mcppls 不往项目里写东西"。 + +### D-5 在线运行结束后报告结果(代码确认) + +- describeOnline 或 online 加载结束后,向客户端发一条一次性的结果: + - 成功:说明装了什么、装到了哪里(`~/.xmake/packages`),并说明项目现在由 xmake 描述; + - 失败:给出 D-2 的原因,并提供 "Open Log" 和 "Run in Terminal" 两个按钮。 +- 机制:在状态的 issue 上加一个 `notify: once` 标记,扩展看到后弹通知。Zed 和 CLion 没有按钮,就把内容写进 status 文本。 + +### D-6 `xmake project` 阶段也识别缺包(代码确认) + +- 现在只有 `xmake f` 失败时才会解析 `xmake_missing_packages`。 +- 改为第二阶段失败时也解析,命中后同样返回 `needs_download`。 + +### D 的测试 + +conformance 里新增 `xmake-needs-download`,用一个假的 xmake 脚本模拟,就像现有的 mcpp mock: + +1. 离线缺包:提示文字、askOnline,并且不写项目目录; +2. 在线失败:显示 `producer-install-failed` 和原因,没有 askOnline; +3. xmake.conf 里有 proxy 和 dotnet:一次 configure 成功,没有 notice; +4. 选择"始终允许"后重启:加载直接在线进行。 + +--- + +## 4. 启动与冷启动(P-\*) + +### P-1 第二实例(已观察;先调查) + +**现象**: + +- 5 次 vulkan-rt 会话中有 2 次出现 "another instance serves …"。旧会话(0.0.7 的 3f16,0.0.8 的 807a)的日志都没有 "client closed its input"。 +- 新实例用了新的缓存目录,拿不到已缓存的 xmake 模型,于是: + - 先用推断模型启动 clangd:libc++ kit、`-std=c++26`、没有 include 路径; + - 出现 scan-failed 和 IncludeCleaner 报错; + - xmake 的模型到达后重启 clangd,preamble 要建两次; + - 首屏请求 3–4.4 s。 + +**调查**: + +- 在 Reload Window 和"扩展升级后重载"两种情况下,看旧 server 是否按时退出:stdin EOF → exit。 +- 看 lease 的 `owner_gone` 判定在这两种情况下是否正确。 +- 复现方法:VS Code 本机 E2E,连续两次 Reload Window,看 `instances/` 下是否出现新目录。 + +**方案(调查之后再定)**: + +- 次实例**只读**复用主实例的模型缓存:模型 JSON 加上它的 key,key 匹配时直接用来做 plan。 +- 写操作仍然只在自己的实例目录里进行。 +- 如果确认旧 server 是孤儿进程:在 server 端加上"父进程消失就退出"的检测,Linux 上用 `prctl(PR_SET_PDEATHSIG)` 或轮询 ppid。 + +### P-2 没有缓存时等 xmake 多一会儿(推断;Q3) + +- 现状:clangd 最多等 producer 2.5 s,超时后先用推断模型启动。对 xmake 项目来说,推断模型的参数几乎必然与真实模型不同,于是一定会重启一次,preamble 建两次,vulkan-hpp 上每次约 3 s。 +- 方案:当 producer 是 xmake 或 cmake,并且上次运行耗时有记录时,等待时间改为 `min(上次耗时 × 1.5, 8 s)`。 +- D-1 之后,xmake 少了一次失败的运行,2.5 s 的预算本身可能就够了。所以 P-2 等 D-1 完成后,用实测决定是否还需要。 + +### P-3 "a loaded model" 阻塞事件循环(已观察) + +- 0.0.8 阻塞了 446 ms,0.0.7 阻塞了 1803 ms,只涉及 111 个条目。需要定位开销在哪:JSON、normalize、plan diff 还是写数据库。 +- 做法:用本机 vulkan-rt 的模型重放(`mcppls` 的 dev 命令,或单元测试里的计时),拿到火焰图再定。 + +--- + +## 5. 度量(M-\*) + +### M-1 诊断包拆分耗时 + +- 当前 `report.requests[method]` 只有总耗时。 +- 每个 job 记录 4 个时间点:到达、发给 clangd、clangd 回复、写给客户端。由此输出 `engineMs` 和 `overheadMs` 的 p50 和 p95。 +- 这份诊断包里 completion 计数为 0,因为抓包时会话才 48 s。issue 模板要提示用户"复现之后马上保存诊断包"。 + +### M-2 非模块、头文件很重的 UX fixture + +- fixture `ux-heavy-headers`:一个合成的 2–3 万行模板头文件加 10 个 `.cpp`,不依赖网络和许可证。 +- 场景:反复编辑并请求补全。 +- 预算:补全 p95 ≤ 普通 clangd 的 p95 × 1.3 + 30 ms(参照 memory "budgets = 1.3 × p95")。 +- 基线:fixture 的 runner 直接驱动同一个 clangd,作为对照组。 +- 这条 fixture 会锁住 W-1,同时也能抓住将来其他把非模块项目拖慢的改动。 + +### M-3 workaround 的 canary 与 conformance + +- **W-1**: + - canary 是一个直接驱动 clangd 的 LSP 检查:同一个文件带和不带这个参数,补全中位数之比 > 2 就说明缺陷还在; + - conformance:一个非模块项目,断言 `engine-start.arguments` 里没有 `--experimental-modules-support`;向文件写入 `import std;` 之后,出现一次 `engine-restart`,并且带上了这个参数。 +- **W-2**: + - canary 也用 LSP 方式抓 tidy 诊断(`clangd --check` 不输出 tidy 诊断,本机已确认); + - conformance:一个 std-only 用例,开 `FastCheckFilter: None`,诊断里只剩下 transform 和 int 两条。 +- 现有 `workaround-canaries` 只支持 `clangd --check`,需要扩展出一种 LSP check 类型。 + +--- + +## 6. 上游与发布(R-\*) + +- **R-1**:把 UP-22(reduced case 已经在 #24 的评论里)和 UP-23(需要一个可复现的小项目)提交到 llvm-project,然后把链接回填到 #24 和注册表的 `upstream` 字段。 +- **R-2**: + - 回复 #37,内容是报告末尾的要点: + - const 误报的原因与绕过方法; + - 补全慢的原因与修复版本; + - workers 设置的问题; + - xmake 缺包的解释。 + - 核查 0.0.8 发布 payload 的 `build.dirty: true, source: from-source`:发布流水线标记版本信息的时机是否晚于某一步生成文件。 + +--- + +## 7. 已定 + +| # | 问题 | 定法 | +|---|---|---| +| Q1 | 设置键冲突怎么改 | 方案 A:`mcppls.engine` → `mcppls.engine.name`,`mcppls.buildDiscovery` → `mcppls.buildDiscovery.mode`,旧键继续可读(见 S-1) | +| Q2 | "始终允许下载"存在哪里 | 扩展的 workspaceState,不写项目文件 | +| Q3 | P-2 是否等 D-1 之后再定 | 这次一并做完(P-2 按 §4 的计时方案实现) | +| Q4 | 发布节奏 | 一个 PR 全部实现,版本 0.0.9 | + +### 7.1 定法带来的细化 + +- **S-1 迁移(无感升级)**: + - package.json **删掉**旧的 `mcppls.engine` 和 `mcppls.buildDiscovery`。只要它们还带默认值留在贡献点里,父子冲突就会继续存在。 + - 扩展读取时,用户显式设置的新键优先;没有新键时,用旧键的字符串值;两者都没有才用默认值。 + - 旧键只要有用户值或工作区值,就提示一次"已改名",并提供"更新设置"按钮,用户点了才写入。扩展从不擅自改动用户的 settings.json。 + - server 注册表中把旧键作为新键的别名(alias),nvim 和 Zed 用户的 `init_options` 不需要改。 + - 别名命中一个对象时(例如嵌套写法 `{"engine": {"workers": 4}}`),不能报"类型不对"。 +- **D-4 "始终允许"的含义**: + - 不把 `buildTool` 改成 online:日常仍然离线运行,快,也不连网。 + - 只有当 server 报告需要下载(`askOnline`)时,扩展才**自动**执行一次 `mcppls.describeOnline`,不再弹窗询问。 + - 状态栏菜单和命令面板里可以撤销。 +- **P-2 计时**: + - producer 每次运行的耗时,无论成败,都写进工作区缓存,与模型缓存分开存放(`producer-timing.json`)。 + - 没有模型缓存时,clangd 的等待改为 `clamp(上次耗时 × 1.2, 2.5 s, 8 s)`;没有计时记录时仍是 2.5 s。 + - P-1 让次实例也能读到主实例的模型缓存和计时。 + +## 8. 多角度检查 + +| 角度 | 检查 | 结论或要求 | +|---|---|---| +| 架构 | 每个改动落在已有的层上,不新建并行机制 | W-1/W-2 用 workaround 注册表;S-1 用已有的别名;D-5 用 status 通道;P-2 用已有的模型缓存目录;M-1 记在已有的请求统计里 | +| 稳定性 | 新行为在失败时退回到旧行为 | W-1 判定不确定时开着 modules 支持;W-2 消息格式变了就不再过滤;D-1 `--help` 解析失败时退回降级重试;P-1 进程检测拿不到 pid 时不退出 | +| 优雅简洁 | 少加概念,多删分支 | D-1 用 `--help` 取代内部键表和降级后的 notice;D-4 不新增设置,只是自动点了已有的按钮 | +| 用户体验 | 以用户的感受定(memory:UX-first) | 补全 259 → 97 ms;不再弹误导提示;下载结果会告诉用户;设置界面不再显示 undefined | +| 兼容性 | 旧设置、旧客户端、旧缓存都能用 | 旧键作为别名可读;新的 status 字段是可选的(旧扩展忽略);没有 `modules-support` 判定文件时按开着处理;没有 timing 记录时按 2.5 s | +| 跨平台 | Linux / macOS / Windows / Termux | P-1 父进程检测:Linux 读 `/proc`,macOS 用 `ps`,Windows 不检测(stdin EOF 足够);pid 在启动时不可见(容器、PRoot)时不检测;D-1 的 `--help` 解析与平台无关 | +| 一致性 | 三个编辑器和文档一致 | VS Code 有按钮;Zed 和 CLion 在 status 文本里写结果;设置文档表格由注册表生成 | +| 无感升级 | 从 0.0.8 升上来什么都不用做 | 旧设置照常生效,只提示一次;缓存格式不变,只新增文件;第一次会话中,非模块项目在第一个 plan 时提前重启一次 clangd,这时还没有打开任何文件 | + +## 9. 任务与依赖 + +### 9.1 任务 + +| 线 | 任务 | 依赖 | 主要文件 | +|---|---|---|---| +| — | W-1、W-2、版本号 0.0.9 | — | 已完成(5118667) | +| A 设置 | S-1、S-2、S-3、S-4;设置文档重新生成;nvim、VS Code README | — | `editors/vscode/{package.json,src/*}`、`src/config/settings.*`、`src/cli/options.cpp`、`tests/test_settings.cpp`、`docs/30-settings.md`(中英文) | +| B 构建工具 | D-1、D-2、D-3、D-6;cmake 和 meson 的提示文字;conformance `xmake-needs-download`(模拟 xmake) | — | `src/project/{xmake,cmake,meson}.cpp`、`src/orchestrator/workspace.cpp`(askOnline 条件)、`tests/test_project.cpp`、`conformance/` | +| C 下载体验 | D-4、D-5(status 字段 `onlineRun`,以及扩展的通知) | B 的 D-2(失败原因文字);与 A 共享 `extension.ts` | `src/orchestrator/workspace.cpp`、`editors/vscode/src/{downloadPrompt,downloadAsk,status}.ts`、`docs/specs/s3-*.md` | +| D 冷启动 | P-1(父进程退出检测 + 次实例读取主实例缓存)、P-2、P-3 | P-2 用到 P-1 的缓存读取 | `src/server/session.cpp`、`src/orchestrator/{instance,workspace}.cpp`、`src/project/modelcache.*` | +| E 度量 | M-1、M-2(`ux-heavy-headers` 与 clangd 基线)、M-3(LSP canary 与 `no-modules`、`tidy-const-views` fixture);CI 列表 | W-1、W-2 | `src/bin/conformance.cpp`、`conformance/fixtures/*`、`.github/workflows/ci.yml`、`src/orchestrator/workspace.cpp`(请求统计) | +| R | CHANGELOG、troubleshooting、design.md、specs 汇总;回复 #37;LLVM issue 草稿;核查 payload 的 dirty 标记 | 全部 | — | + +### 9.2 顺序与并行 + +``` +W-1/W-2 (done) ──┬─ A 设置 ───────────────┐ + ├─ B 构建工具 ─── C 下载体验 ─┤ + ├─ D 冷启动 ───────────────┤── 合并 → 全量测试 → PR → CI → 自我 review → squash → Release → 本地验证 + └─ E 度量 ─────────────────┘ +``` + +- A、B、E 各在一个 worktree 里并行开发;C 和 D 在主分支上做。C 在 B 合入之后收尾。 +- 共享文件的约定: + - `CHANGELOG.md` 和本文只在主分支上改; + - `workspace.cpp` 各线只动自己的函数; + - `extension.ts` 由 A 负责改 initializationOptions,C 只改 downloadPrompt 的接入。 + +## 10. 实现记录 + +### 10.1 各项落地 + +- **W-1 / W-2**(5118667): + - 补全中位数 259 → 97 ms;std 用例的 const 诊断从 8 条降到 2 条,剩下的都是真阳性。 +- **A 设置**(d84e22c): + - `mcppls.engine.name` 和 `mcppls.buildDiscovery.mode` 取代原来的父键;旧键作为别名继续可读,嵌套对象命中别名时也不报错。 + - 扩展只提示一次,用户点了才迁移(`settingsRead.ts`、`settingsMigration.ts`)。 + - workers 发给 server,空串按 auto;null 视为未设置。 + - 设置文档表格重新生成;`test_settings` 增加四条一致性检查;扩展单测覆盖改名解析和 initializationOptions。 +- **B 构建工具**(95c195c): + - D-1:`xmake_help_options` 解析 `xmake f --help`,结果缓存在 `/xmake-help`;降级重试只作为 `--help` 失败时的兜底。 + - D-2:离线时报 needs-download;在线失败时报 `producer-install-failed`(`spec::INSTALL_FAILED`)。只有离线运行得到的 needs-download 才会 askOnline(`needsDownloadFromOfflineRun`)。 + - D-3:提示文字说明构建工具和系统包管理器这条路。 + - D-6:`xmake project` 阶段同样识别缺包。 + - fixture `xmake-needs-download`:模拟 xmake,仅 Linux;runner 新增 `server-path-prepend` 和 `write-file` 的 `notify`。 +- **C 下载体验**: + - D-4:扩展提供"Always Download in This Workspace",之后自动执行 `mcppls.describeOnline`;`mcppls.askBeforeDownloading` 撤销。 + - D-5:server 在 status 中给出 `onlineRun`(S3-4-26 到 S3-4-28);扩展对每次运行只提示一次;runner 的 status 检查新增 `online-run`。 +- **D 冷启动**: + - P-1:用 initialize 的 processId 检测父进程(Linux 读 `/proc`,macOS 用 `ps`,Windows 不检测;只在启动时就能看到这个进程时才检测);次实例只读主实例的模型缓存。 + - P-2:`producer-timing.json`;clangd 额外等待 `clamp(1.2 × 上次耗时 − 2.5 s, 0, 5.5 s)`。 + - P-3:重启时在单独的线程里停掉旧 clangd,停完后才启动新的,以保证 C-4 的锁清理前提。实测 clangd 构建 preamble 时,无论 EOF 还是 SIGTERM,退出都要 2.4–3.9 s。 +- **E 度量**(91820fe、801a631): + - M-1:`engineP50Ms`/`engineP95Ms` 和 `overheadP50Ms`/`overheadP95Ms`。 + - M-2:`ux-heavy-headers`,本机 p95 16 ms,对照 clangd 自身的 13 ms(关掉 WA-009 时 530 ms,超出预算)。 + - M-3:runner 新增 `clangd-lsp` 和 `completion-baseline` 两种检查,以及 `no-modules`、`tidy-const-views` 两个 fixture;canary 实测 4.0–4.2 倍。 +- **R**: + - R-1:LLVM issue 草稿,`2026-10-01-0.0.9-upstream-drafts.md`。 + - R-2:payload 的 dirty 只计已跟踪的文件。 + - 回复 #37 放在发版之后。 + +### 10.2 验证中发现并纠正的 + +- P-3 的初测用错了二进制:dev 构建冷启动时 initialize 会停顿 1.25 s,这是未优化的 SHA-256 在校验 payload,release 构建不存在这个问题。 +- worktree 是从 origin/main 建出来的,三条线各自先 fast-forward 到 `release/0.0.9` 再开工。 diff --git a/.agents/docs/2026-10-01-0.0.9-upstream-drafts.md b/.agents/docs/2026-10-01-0.0.9-upstream-drafts.md new file mode 100644 index 00000000..4c5edcf6 --- /dev/null +++ b/.agents/docs/2026-10-01-0.0.9-upstream-drafts.md @@ -0,0 +1,78 @@ +# LLVM issue drafts for UP-22 and UP-23 (plan 0.0.9, R-1) + +These are the two upstream defects behind WA-CLANGD-009 and WA-CLANGD-010, written for +llvm/llvm-project. Neither is filed yet. Once one is filed, its link goes into issue #24 (UP-22, UP-23) +and into the `upstream` field of the workaround in `src/engine/clangd/workarounds.cpp`. + +--- + +## UP-22 · [clang-tidy] misc-const-correctness suggests `const` for a view that has no const `begin()` + +Labels: `clang-tidy`, `false-positive` + +`misc-const-correctness` (clang-tidy 23.1.0, as bundled with clangd 23.1.0) reports that a variable +holding a `std::views::filter` result "can be declared 'const'". Declared `const`, the code no longer +compiles: `filter_view` (also `drop_while_view`, `chunk_by_view` and `split_view`) caches its +`begin()`, so it has no `begin() const`. + +```cpp +// clang-tidy -checks='-*,misc-const-correctness' t.cpp -- -std=c++23 +#include +#include +#include +#include +struct H { int f; unsigned long s; }; +bool keep(const H& h) { return h.f == 1; } +unsigned long a1(const std::array& in) { + auto v = in | std::views::filter(keep); // warning: variable 'v' ... can be declared 'const' + return std::ranges::fold_left(v | std::views::transform(&H::s), 0UL, std::plus()); +} +``` + +With `const auto v`, GCC 16 says `no match for 'operator|' (operand types are 'const +std::ranges::filter_view<...>' ...)`. Clang rejects it too. + +Observed with: + +| Version | Standard library | Result | +|---|---|---| +| 23.1.0 | libstdc++ 16 | warns | +| 23.1.0 | libc++ 23 | warns | +| 22.1.8 | — | does not warn on a view returned by an adaptor; does warn on `std::ranges::filter_view v { r, p };` written out, which is the same false positive | + +The same holds when `v` is iterated with a range-for, or passed to `std::ranges::distance(v | std::views::take(2))`. + +Expected: no diagnostic when the variable's type has no const `begin()` and is used as a range. + +--- + +## UP-23 · [clangd] with `--experimental-modules-support`, every code completion rescans the file's module dependencies + +Labels: `clangd`, `clang:modules`, `performance` + +With `--experimental-modules-support`, each `textDocument/completion` in a file that imports no +module and is in a project with no modules is about 3 times slower when the file includes a heavy +header (vulkan-hpp's `vulkan.hpp` and `vulkan_raii.hpp`). The verbose log shows one more driver +invocation per completion: 25 against 13 for the same session. That is a module dependency scan, +which preprocesses the whole translation unit again on every request. + +**Measurement.** clangd 23.1.0, Linux x86-64, libstdc++ 16, compile_commands.json with +`-std=c++23 -I`. Twelve completions after `phy_device.`, `vk::`, +`memory_properties.` and `std::ranges::`, each on a settled preamble. + +| | median | worst | +|---|---|---| +| without the flag | 82 ms | 95 ms | +| with `--experimental-modules-support` | 254 ms | 869 ms | +| the same in a header that is not in the database | 81 ms / 257 ms | — | + +`--use-dirty-headers`, `--header-insertion=never` and `-j=8` change nothing. + +Expected: the result of the dependency scan is kept with the preamble, or reused while the file's +`import` lines are unchanged, so completion costs the same with and without the flag in a file with +no imports. + +**Reproducing.** A single `.cpp` with `#include ` and a function body, a +compile_commands.json entry for it, and an LSP client that sends didOpen, waits for diagnostics, then +sends completions. The scripts we used are `bench.py` in the issue #37 review +(`.agents/docs/reviews/2026-10-01-issue-37-review.md` §B). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e33002..bfba9a71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,26 +277,26 @@ jobs: - platform: linux-x64 part: 1 of 2 os: ubuntu-24.04 - fixtures: mcpp-split mcpp-partition-definition mcpp-all-cppm verify-changes mingw mcpp-split-gcc mcpp-watch multi-root mcpp-llvm mcpp-watch@polling mcpp-emit mcpp-rules-generated s1-two-sets compdb-lto-msvc failure-at-base@vscode generated-module-negotiated typing-import typing-import-spin workaround-canaries typing-autosave module-edit-autosave partial-scan-standins compdb-rejected-command compdb-mixed-standards mcpp-emit-partial mcpp-emit-partial-download compdb-midwrite xmake-basic xmake-late-config xmake-user-mode xmake-no-standard + fixtures: mcpp-split mcpp-partition-definition mcpp-all-cppm verify-changes mingw mcpp-split-gcc mcpp-watch multi-root mcpp-llvm mcpp-watch@polling mcpp-emit mcpp-rules-generated s1-two-sets compdb-lto-msvc failure-at-base@vscode generated-module-negotiated typing-import typing-import-spin workaround-canaries typing-autosave module-edit-autosave partial-scan-standins compdb-rejected-command compdb-mixed-standards mcpp-emit-partial mcpp-emit-partial-download compdb-midwrite xmake-basic xmake-late-config xmake-user-mode xmake-no-standard xmake-needs-download - platform: linux-x64 part: 2 of 2 os: ubuntu-24.04 extras: true - fixtures: inferred inferred-bom build-discovery-off 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 mcpp-emit-edits cmake-clang cmake-clang-bdb cmake-fetchcontent-offline 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 provisional-no-prime inferred-cxx26 reset-cache include-cleaner-modules + fixtures: inferred inferred-bom build-discovery-off 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 mcpp-emit-edits cmake-clang cmake-clang-bdb cmake-fetchcontent-offline 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 provisional-no-prime inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views # 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. - platform: darwin-arm64 os: macos-14 extras: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted mcpp-llvm mcpp-split mcpp-partition-definition mcpp-all-cppm verify-changes mcpp-watch multi-root failure-at-base clangd-cannot-load failure-at-base@zed generated-module generated-module-old-mcpp generated-module-negotiated typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context mcpp-emit-wait provisional-no-prime inferred-cxx26 reset-cache include-cleaner-modules + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted mcpp-llvm mcpp-split mcpp-partition-definition mcpp-all-cppm verify-changes mcpp-watch multi-root failure-at-base clangd-cannot-load failure-at-base@zed generated-module generated-module-old-mcpp generated-module-negotiated typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context mcpp-emit-wait provisional-no-prime inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views # No mcpp on the arm64 runner (its tools are the cross-built ones), so the fixtures that # need no build tool and no compiler of their own: the semantic kit, clangd and the server # on aarch64, with module faults, a corrupt payload and polling included. - platform: linux-arm64 os: ubuntu-24.04-arm cross-tools: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted payload-corrupt clangd-cannot-load failure-at-base watch-polling typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context inferred-cxx26 reset-cache include-cleaner-modules + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults untrusted payload-corrupt clangd-cannot-load failure-at-base watch-polling typing-import typing-import-spin workaround-canaries completion-keywords diagnostic-bundle typing-autosave module-edit-autosave partial-scan-standins clangd-crash-context inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views - platform: win32-x64 part: 1 of 2 os: windows-2022 @@ -305,7 +305,7 @@ jobs: part: 2 of 2 os: windows-2022 extras: true - fixtures: inferred inferred-bom build-discovery-off engine-none module-faults mcpp-emit-hang inferred-msvc untrusted mingw cmake-msvc cmake-msvc-bdb cmake-clangxx-msvc cmake-clang-cl mcpp-msvc mcpp-watch failure-at-base clangd-cannot-load completion-keywords diagnostic-bundle mcpp-emit-wait inferred-cxx26 reset-cache include-cleaner-modules + fixtures: inferred inferred-bom build-discovery-off engine-none module-faults mcpp-emit-hang inferred-msvc untrusted mingw cmake-msvc cmake-msvc-bdb cmake-clangxx-msvc cmake-clang-cl mcpp-msvc mcpp-watch failure-at-base clangd-cannot-load completion-keywords diagnostic-bundle mcpp-emit-wait inferred-cxx26 reset-cache include-cleaner-modules no-modules tidy-const-views defaults: run: shell: bash @@ -739,7 +739,9 @@ jobs: # (cold start, warm start on the same cache, typing and saves on that cache, faults on a cache of their # own); a stage that fails does not skip the ones after it, so a run says everything that is over budget, # and every stage's measure file is uploaded whether or not it passed, for the trend. The `long` stage - # (ten idle minutes, a git checkout twenty commits back) is nightly's. + # (ten idle minutes, a git checkout twenty commits back) is nightly's. ux-heavy-headers (0.0.9 plan M-2) + # needs no checkout: it generates a project of heavy headers and holds completion through the server to + # what clangd alone does on it, in its one stage. ux: name: ux (${{ matrix.fixture }}) needs: payload @@ -748,7 +750,7 @@ jobs: strategy: fail-fast: false matrix: - fixture: [ux-mcpp, ux-xlings] + fixture: [ux-mcpp, ux-xlings, ux-heavy-headers] defaults: run: shell: bash @@ -756,6 +758,7 @@ jobs: - uses: actions/checkout@v7 - uses: ./.github/actions/setup-mcpp - name: GCC 16, which the mcpp and xlings repositories build with + if: matrix.fixture != 'ux-heavy-headers' run: mcpp toolchain install gcc 16.1.0 # The mcpp repository's .xlings.json asks for the mcpp it is built with, and xlings runs that one # inside it, refusing to when it is not installed (nightly's self-hosting job does the same). @@ -792,10 +795,15 @@ jobs: fi echo "::endgroup::" } - run_stage cold cache-a - run_stage warm cache-a --expect-warm - run_stage edits cache-a --expect-warm - run_stage faults cache-b + if [ "${{ matrix.fixture }}" = ux-heavy-headers ]; then + # 0.0.9 plan M-2: one run, of a generated project: completion through the server against clangd alone. + run_stage edits cache-a + else + run_stage cold cache-a + run_stage warm cache-a --expect-warm + run_stage edits cache-a --expect-warm + run_stage faults cache-b + fi { echo "### ${{ matrix.fixture }}" echo diff --git a/.gitignore b/.gitignore index dacef478..88328835 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ mcpp.lock # build.mcpp would generate it at, so each is self-contained; `target/` above would otherwise hide # it. Only those fixtures: any other fixture's target/ is build output like everywhere else. !conformance/fixtures/generated-module*/target/ + +# Local review reports (issue analyses, not shipped). +.agents/docs/reviews/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e954f3b2..5fd68bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,97 @@ release's notes are that section. Versions are three-part semantic versions, `MAJOR.MINOR.PATCH`, and every editor plugin carries the product version unchanged. +## [0.0.9] — 2026-10-02 + +Completion on a project without modules as fast as plain clangd's, and the rest of issue #37. clangd's +modules support made every completion in a header-heavy project about three times slower (vulkan-hpp: +259 ms at the median through mcppls, now 97 ms); a project that uses no modules now gets clangd without +it. A view that cannot be const is no longer said to be, settings no longer show `undefined`, an xmake +project no longer starts with a configuration that must fail, a download that is needed says what it is +and what else helps, and a fetch you ask for reports how it ended. A second editor window, or a server +left behind by a reload, no longer makes the next one start cold. The analysis and the plan are +`.agents/docs/2026-10-01-0.0.9-plan.md`. + +### Completion and diagnostics + +- **Completion on a project without modules is as fast as plain clangd's again** (issue #37). clangd's + modules support scans a file's module dependencies again for every completion; on vulkan-hpp that took + completion from 82 ms to 254 ms at the median, 869 ms at worst, in sources and headers alike. A project + whose plan has no module unit, no module import, no `import std` and no stand-in now gets clangd without + `--experimental-modules-support`; the verdict is kept for the next session, and the first plan that uses + modules restarts clangd with it (workaround `WA-CLANGD-009`, UP-23 in issue #24). Through mcppls, the + same completions went from 259 ms to 97 ms at the median. +- **No "can be declared 'const'" for a view that cannot be const** (issue #37). clang-tidy 23.1's + `misc-const-correctness` says so of a variable holding a filter, drop_while, chunk_by or split view, or + a view built on one, although none of them has a const `begin()`; that diagnostic is dropped and the + check's others are kept (workaround `WA-CLANGD-010`, UP-22 in issue #24). + +### Starting + +- **A second instance plans with the owner's model at once** (issue #37). A server that finds the + workspace served by another instance works in a private directory; it now reads the owner's cached + project model, read only, instead of planning from scanned sources and restarting clangd when the + build tool answered (3 to 4.4 s of slow first requests on vulkan-rt). +- **A server whose editor is gone leaves.** The client's process id from `initialize` is watched (Linux + and macOS, when the process is visible at the start); once it is gone the server exits as if its input + had closed, and the next server owns the workspace instead of starting cold beside a leftover one. +- **clangd waits for a build tool that is known to answer soon.** With no cached model, clangd started + on the model scanned from sources after 2.5 s and was restarted when the build tool answered, building + its preambles twice. How long each build tool took is now kept per workspace, and clangd waits up to + 1.2 times that (at most 5.5 s more); mcppls's own engine answers meanwhile, as before. +- **A clangd restart no longer holds the event loop.** The old clangd is stopped on its own thread and + the new one starts once it is gone; a clangd building a preamble takes 2.4 to 3.9 s to exit, and every + request waited for it (446 ms and 1803 ms in the reporter's logs, up to 2.5 s). + +### Settings + +- **The settings screen no longer shows `undefined`, and `engine.workers` takes effect.** + `mcppls.engine` and `mcppls.buildDiscovery` were plain values that were also the parents of other + settings, and VS Code drops a child's default when its parent is a plain value: + `mcppls.engine.workers` showed `undefined` and "Value must match regex", and the build discovery + checkbox showed unchecked. They are now `mcppls.engine.name` and `mcppls.buildDiscovery.mode`. The + old names still apply in every editor, Neovim and Zed `init_options` included; VS Code offers once to + move your values to the new names and changes nothing until you click. `mcppls.engine.workers` now + reaches the server, accepts `auto` or 1 to 99 (empty means `auto`), and a change restarts it. A `null` + in `initializationOptions` means not set, and in `didChangeConfiguration` returns the setting to its + default; the bundle no longer reports "compiler as the wrong kind of value" for an unset compiler. + +### Build tools + +- **xmake no longer starts with a configuration that must fail.** mcppls asks `xmake f --help` which + options it takes and passes on only the `xmake.conf` keys on that list. The keys xmake wrote there + itself (`proxy`, `dotnet`, `dotnet_sdkver`) were passed back, `xmake f` refused them, and every start + ran it twice and showed a wrong "run `xmake f -c`" notice. +- **A needed download says what it is and what else works.** The message says these are packages xmake + would fetch or build for the project's requirements (build tools included, such as `libtool` or + `libpthread-stubs` for a library built from source), that the run stayed offline, and that installing + them with the system package manager works too. A run with the network allowed whose install fails + reports `producer-install-failed` with xmake's error lines and its install log, instead of "stayed + offline" and an offer to download the same thing again. Missing packages found by `xmake project` are + reported the same way. +- **A download you asked for says how it ended, and can be allowed for a workspace.** After "Download + and Continue", the status carries the outcome (S3 `onlineRun`), and VS Code tells it once: fetched, or + failed with what failed and buttons for the log and the terminal. "Always Download in This Workspace" + fetches every download the build description needs without asking, while the build tool still runs + offline otherwise; "C++ Modules: Ask Before Downloading in This Workspace" takes it back. Nothing is + written into the project. + +### Diagnostic bundle and tests + +- **A slow request shows whose time it was.** The report's `requests.` splits the requests an + engine answered into `engineP50Ms`/`engineP95Ms` (the engine's own time) and + `overheadP50Ms`/`overheadP95Ms` (what mcppls added). +- The conformance suite talks to clangd directly for two canaries: WA-CLANGD-009 (completion with + `--experimental-modules-support` more than 1.8 times slower on a heavy header) and WA-CLANGD-010 (the + filter view's const advice). New fixtures `no-modules` and `tidy-const-views`, and a ux scenario, + `ux-heavy-headers`, that holds completion through mcppls to 1.3 times clangd's own plus 30 ms on a + project without modules (it fails with WA-CLANGD-009 turned off: 530 ms against 56 ms). + +### Packaging + +- **A released payload is no longer marked `dirty`.** Its build record counted files the build itself + leaves in the checkout; only a tracked file that differs from the commit counts now. + ## [0.0.8] — 2026-10-01 Completion that shows up. In VS Code 1.125 and later with Copilot (built into VS Code) or another diff --git a/conformance/README.md b/conformance/README.md index 55f88852..d4d67943 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -24,6 +24,7 @@ checks fail at once with that reason instead of each waiting out its timeout. |---|---| | `xmake-basic` | 0.0.8 part 2 X-1, X-6, X-7: an xmake project with GCC 16 and `import std`, described by xmake run privately (source xmake, L3, std prepared, navigation works); the provisional model never says `untrusted-workspace`; nothing is written into the project. Linux, needs `xmake` on PATH | | `xmake-late-config` | X-1, X-5 (E1): no `set_languages`, and a stale `compile_commands.json` in the root (`prepare xmake-stale-compdb`); the engine commands are xmake's, not the file's; adding `set_languages("c++23")` to xmake.lua reaches the engine within 15 s with no `compdb-invalid`, `model-stale` or `preparation-stalled`; the project directory is unchanged once the edit is put back | +| `xmake-needs-download` | Plan 0.0.9 D-1, D-2, D-3, D-6, against a stand-in `xmake` script (`fake-bin/`, first on the server's PATH by the scenario's `server-path-prepend`; POSIX only, so in the Linux fixture lists). Offline, missing packages: `producer-needs-download` naming them, that build tools are included, that the run stayed offline and that the system package manager works too, `askOnline`, the project untouched. `mcppls.describeOnline` and the install fails: `producer-install-failed` with xmake's error lines and its install log, no ask. Missing at the `xmake project` stage: needs-download, not `xmake-configure-failed`. `.xmake/*/*/xmake.conf` holds `proxy`, `dotnet` and `dotnet_sdkver` (which `xmake f` refuses) and a project option: `xmake f` runs once per load, with no `xmake-options-left-out` | | `xmake-user-mode` | X-2 (E5): `.xmake/linux/x86_64/xmake.conf` says `mode = "debug"` and `fancy = true`; the engine commands have `-O0 -g` and the project's `-DFANCY_PART`, and no `-DNDEBUG`; the project directory, `.xmake/` included, is unchanged | | `xmake-no-standard` | X-3, X-7 (E4): no `set_languages`, GCC 16, `main.cpp` alone imports std (no module file, so xmake names no standard); the module units are read as C++23 -- the engine command carries `-std=gnu++23`, std is prepared and `std::println` is found -- and the report says the standard was mcppls's choice | | `compdb-midwrite` | X-5: a `compile_commands.json` cut to half and written complete 300 ms later (and again with a writer that takes 2.5 s): no `compdb-invalid`, no `model-stale`, the model follows the complete file, the slow writer's half is read again a second later (`model-reread` event) | @@ -93,7 +94,10 @@ checks fail at once with that reason instead of each waiting out its timeout. | `compdb-rejected-command` | Fix plan 2026-09-26 F6: a `compile_commands.json` whose commands carry a value the compiler rejects (`-std=c++99999`, `prepare compdb-rejected-command`), as issue #23's did: every module scan fails, and the status says the command was rejected, as an environment issue in the compiler's words | | `mcpp-emit-wait` | Fix plan 2026-09-26 F4 (D1): the producer hangs past the ten seconds after which scanned sources are planned, to its 20 s bound; mcppls's own engine answers meanwhile and clangd is given no plan until the producer answers or is given up | | `completion-keywords` | Fix plan 2026-09-26 F9 and F15, as VS Code (`client-info`): the space is a completion trigger character; a space after `import ` or `export import ` opens the module list from mcppls's own index, and one typed anywhere else, or after `import ` or `export module `, is answered at once with nothing. The module-syntax keywords are offered where each can begin a declaration and not inside a function body, merged with clangd's answer, and on their own within 1.5 s for a file clangd cannot answer for yet (a new file waiting for clangd to read a database that has it); the report counts what the space trigger cost (S3-6.2-1 to S3-6.2-5) | -| `workaround-canaries` | Import-hang plan §9: one `clangd-check` per registered workaround with a canary, run against the payload's clangd. A failure here means a clangd update fixed that defect and the workaround it names can be removed | +| `workaround-canaries` | Import-hang plan §9, 0.0.9 plan M-3: one check per registered workaround with a canary, run against the payload's clangd. WA-CLANGD-001 is a `clangd-check`; WA-CLANGD-009 (completion with `--experimental-modules-support` takes over 1.8 times as long on a file including 120 generated headers, `prepare heavy-headers`) and WA-CLANGD-010 (misc-const-correctness on a filter view and a view over one) are `clangd-lsp` checks, on Linux only. A failure here means a clangd update fixed that defect and the workaround it names can be removed | +| `no-modules` | 0.0.9 plan M-3, WA-CLANGD-009: sources and headers with no module in the plan: clangd runs without `--experimental-modules-support`, hover and definition work, and the report splits request time into clangd's and the server's (M-1); a module interface written into the workspace restarts clangd with the flag, the event's reason naming WA-CLANGD-009 | +| `tidy-const-views` | 0.0.9 plan M-3, WA-CLANGD-010: a project `.clangd` with `FastCheckFilter: None` and a `.clang-tidy` for misc-const-correctness; of a filter view, a transform view over it and an int never changed, the server shows only the int (`diagnostic-code-lines`) | +| `ux-heavy-headers` | 0.0.9 plan M-2 (issue #37): ten sources including 400 generated headers (`prepare heavy-headers 400`), no modules, no checkout. `completion-baseline` holds completion through the server, each after an edit, to 1.3 times clangd alone's p95 plus 30 ms on the very compile_commands.json the server wrote, and to a share of answers by clangd. CI's `ux` job runs its one stage (`edits`); with `--disable-workaround WA-CLANGD-009` it fails (405 ms against 14 ms locally) | | `mcpp-emit-edits` | Plan 2026-09-30 G-5, a project being written: edits inside functions, saved, never run the producer again although it names every source as an input; an edit that changes a file's imports, and a new source, do. `write-file` with `"expect-reload": false` watches for `settle` seconds that the model is not loaded again | | `reset-cache` | Plan 2026-09-30 C-1: `mcppls.resetCache` removes the workspace's cache (models, engine database, clangd's module cache and locks) and plans and starts again; navigation, hover and completion answer as before | | `diagnostic-bundle` | Issue #23 fix plan F18: the diagnostic bundle an editor exports (`mcppls.exportBundle`) holds what its manifest says, digest for digest, within its 25 MB cap, and no file of it, nor `cxxModules/report`, names the user or the home directory the server runs with, the home written into the client's log it is sent included (S3-5.5-3); asked to hide project paths, not the workspace either. On a CI runner that is `runneradmin`, its `RUNNER~1` and `C:\Users\runneradmin` on Windows, `/home/runner` and `/Users/runner` elsewhere | @@ -205,7 +209,7 @@ always has been. | `semantic-tokens` | `textDocument/semanticTokens/full` (or `/range`, with `"range"`) for `"file"` (optionally with an unsaved `"text"`), decoded with the legend `initialize` gave, has every entry of `"expect"` (`{"line", "text", "type", "modifiers"?}`; `"modifiers"` is a list, and optional) among its tokens (design doc 2026-09-25 K/§7) | | `module-graph-contains` | `cxxModules/graph` lists module `expect`; retries within the check's own timeout, so it doubles as "a change reaches the graph within N seconds" (usable plan W9.3's `watch-polling`) | | `set-context` | sends `cxxModules/setContext` with `"context"` (usable plan W9.2), then a hover at `"at"` contains `expect`, retried the same way as `hover-contains` | -| `write-file` | writes `"content"` (default: a fresh `export module ;`; `"content-from"` copies another workspace file) to `"file"` directly, the way a file system watcher — or, without one, the server's own polling fallback — would notice it, without the runner opening it as a document (usable plan W9.3); with `"expect-reload": true`, also waits for the status to pass through `loading` again (S2-5-1); `"replace": {"from", "with"}` rewrites part of the file as it is, and a later `"restore": true` writes back what it was (a run puts back every file it changed when it ends) | +| `write-file` | writes `"content"` (default: a fresh `export module ;`; `"content-from"` copies another workspace file) to `"file"` directly, the way a file system watcher — or, without one, the server's own polling fallback — would notice it, without the runner opening it as a document (usable plan W9.3); with `"expect-reload": true`, also waits for the status to pass through `loading` again (S2-5-1); `"replace": {"from", "with"}` rewrites part of the file as it is, and a later `"restore": true` writes back what it was (a run puts back every file it changed when it ends); `"notify": true` reports the write to the server even when it registered no watcher for it, as an editor's own watcher of build files does (an inferred model, kept while a build tool cannot answer, registers none) | | `second-instance` | a second server on the same workspace and cache reports the notice `notice-code` (default `shared-workspace`) in its status (overall design 6.3) | | `mcp` | S5 section 6: `mcppls mcp`, started once per fixture with the fixture's server arguments beside the language server, answers the tool call `"tool"` with `"arguments"` (or, with `"method"` and `"params"`, another request) with a result meeting `"expect"`; `"is-error": true` expects a tool error instead; the call is repeated until the expectations hold or the check's time is up, unless `"retry": false`; with `"via": "daemon"`, through `mcppls mcp --daemon` and the workspace daemon it starts (S5 6.1) | | `execute-command` | `workspace/executeCommand` with `"command"` and `"arguments"` is answered without an error (the editor's review commands, design 7.7); `"{workspace-uri}"` in an argument is the workspace folder's URI as the client sent it, and `"expect"` names fields the answer must carry with those values | @@ -213,6 +217,8 @@ always has been. | `stress` | real-project stress testing (real-project plan RP0): seeded random use — see below — meets every key present in `"budget"` | | `type-text` | line `line` of `file` takes each of `steps` in turn, `interval-ms` apart (default 120), the whole buffer sent each time; after each, `request` (default `textDocument/documentSymbol`) is answered within `answer-within` seconds (default 5); with `save`, each step is also written to disk and reported as saved and changed, as autosave does; fails when the status turned to a state listed in `states-never` meanwhile (import-hang plan §8) | | `clangd-check` | the runner's own clangd (`--clangd`, else the payload's) run with `--check` on `file` does not finish (`expect: "hangs"`: not finished after `seconds`, default 10, or crashed) or finishes normally (`"finishes"`); a workaround's canary expects its defect, and fails with `says` once a clangd update fixed it (import-hang plan §9) | +| `clangd-lsp` | 0.0.9 plan M-3: the runner's clangd driven over LSP, the server not involved, started with `arguments` (`{workspace}` stands for the workspace; the isolated HOME applies). `"action": "diagnostics"`: `file` is opened and, once clangd's diagnostics settle (`quiet-ms`, 3000, after the first), `code` is on exactly the 0-based `lines`, or with `"includes": true` on at least those; `"completion-ratio"`: the median of `rounds` (10) completions at `at`, with `arguments`, over the same without them (`baseline-arguments`), `passes` (2) alternating sessions each, over `min-ratio`. A canary holds while the defect is there and fails with `says`, as `clangd-check` does; `only-on` keeps one off a platform it was not verified on | +| `completion-baseline` | 0.0.9 plan M-2: after the server settles, clangd alone (`baseline-arguments`; `{engine-database}` is the directory of the compile_commands.json the server wrote for its own clangd) is timed over `rounds` (40) completions at `at`, each right after the buffer was edited (`interval-ms` apart), then the server over the same. Budget: `ratio` (1.3) and `slackMs` (30) of clangd's p95, `engineShare` (a minimum), `maxEmpty`; the measure carries both p50 and p95 and the report's own `engineP95Ms` and `overheadP95Ms` for completion | | `report` | robustness design O3: `cxxModules/report` meets `"expect"`, retried within the check's time like an `mcp`/`cli` result (a plan or an engine may still be on its way) | | `bundle` | issue #23 fix plan F18: `workspace/executeCommand` `mcppls.exportBundle` with `"arguments"` (and a client log naming the home directory) answers with the path of a zip under 25 MB whose `manifest.json` lists every other file with its SHA-256, which has every file of `"expect-files"`, and in which no file names the server's home directory (either separator), a distinctive user name (`USER`, `USERNAME`, `LOGNAME`) or its 8.3 form, or, with `"forbid-workspace": true`, the workspace; neither may `cxxModules/report` (the workspace aside) | diff --git a/conformance/fixtures/mcpp-emit-needs-download/scenario.json b/conformance/fixtures/mcpp-emit-needs-download/scenario.json index 437c6a20..2011eb87 100644 --- a/conformance/fixtures/mcpp-emit-needs-download/scenario.json +++ b/conformance/fixtures/mcpp-emit-needs-download/scenario.json @@ -49,6 +49,12 @@ "source": "mcpp", "timeout": 60 }, + { + "id": "D8-and-the-status-says-the-fetch-succeeded", + "kind": "status", + "online-run": "fetched", + "online-run-message": "fetched what the build description needed" + }, { "id": "D4-nothing-was-written-into-the-project", "kind": "workspace-unchanged" diff --git a/conformance/fixtures/no-modules/scenario.json b/conformance/fixtures/no-modules/scenario.json new file mode 100644 index 00000000..8e30b8d0 --- /dev/null +++ b/conformance/fixtures/no-modules/scenario.json @@ -0,0 +1,34 @@ +{ + "name": "no-modules", + "description": "0.0.9 plan M-3 (and M-1, the report's split of a request's time into clangd's and the server's), WA-CLANGD-009: a project of plain sources and headers, no module in it and no build system, so the plan has none either. clangd runs without --experimental-modules-support (with it clangd scans a file's module dependencies again for every completion), hover and go to definition work as before, and nothing has restarted it since the project was read. Then a module interface is written into the workspace: the plan gains a module, clangd is restarted with the flag and the reason says why, and the file that had no module in it is answered still. The first clangd of a cold cache starts with the flag, before the first plan, and is restarted without it before a document is open, so the check is on the arguments clangd runs with, not on the history.", + "server-arguments": ["--no-discover"], + "checks": [ + { "id": "S1", "kind": "status", "source": "inferred", "state": "ready", "engine-name": "clangd" }, + { "id": "N1-plan-has-no-modules", "kind": "report", "expect": [ + { "path": "/roots/0/plan/stdUnits", "equals": 0 }, + { "path": "/roots/0/plan/standIns", "max-items": 0 } + ] }, + { "id": "N2-hover", "kind": "hover-contains", "file": "src/main.cpp", "at": [6, 10], "expect": "Rect" }, + { "id": "N3-definition", "kind": "definition", "file": "src/main.cpp", "at": [6, 10], "expect": "src/shapes.hpp" }, + { "id": "N4-definition-function", "kind": "definition-any", "file": "src/main.cpp", "at": [7, 18] }, + { "id": "N4b-time-split", "kind": "report", "expect": [ + { "path": "/roots/0/requests/*/engineP50Ms", "exists": true }, + { "path": "/roots/0/requests/*/engineP95Ms", "exists": true }, + { "path": "/roots/0/requests/*/overheadP50Ms", "exists": true }, + { "path": "/roots/0/requests/*/overheadP95Ms", "exists": true } + ] }, + { "id": "N5-no-modules-support", "kind": "report", "expect": [ + { "path": "/roots/0/engines/*/details/arguments/*", "equals": "--use-dirty-headers", "min-matches": 1 }, + { "path": "/roots/0/engines/*/details/arguments/*", "equals": "--experimental-modules-support", "max-matches": 0 }, + { "path": "/roots/0/events/*/detail/reason", "contains": "gets its modules support", "max-matches": 0 } + ] }, + { "id": "M1-write-module", "kind": "write-file", "file": "src/geom.cppm", "content": "export module geom;\n\nexport int scale(int value) { return value * 2; }\n" }, + { "id": "M2-restarted-with-modules-support", "kind": "report", "expect": [ + { "path": "/roots/0/engines/*/details/arguments/*", "equals": "--experimental-modules-support", "min-matches": 1 }, + { "path": "/roots/0/events/*/detail/reason", "contains": "WA-CLANGD-009" }, + { "path": "/roots/0/events/*/detail/reason", "contains": "gets its modules support" } + ] }, + { "id": "M3-hover", "kind": "hover-contains", "file": "src/main.cpp", "at": [6, 10], "expect": "Rect" }, + { "id": "M4-module-in-graph", "kind": "module-graph-contains", "expect": "geom" } + ] +} diff --git a/conformance/fixtures/no-modules/src/compile_flags.txt b/conformance/fixtures/no-modules/src/compile_flags.txt new file mode 100644 index 00000000..dfad3644 --- /dev/null +++ b/conformance/fixtures/no-modules/src/compile_flags.txt @@ -0,0 +1,2 @@ +-std=c++23 +-xc++ diff --git a/conformance/fixtures/no-modules/src/main.cpp b/conformance/fixtures/no-modules/src/main.cpp new file mode 100644 index 00000000..85487230 --- /dev/null +++ b/conformance/fixtures/no-modules/src/main.cpp @@ -0,0 +1,10 @@ +#include + +#include "report.hpp" +#include "shapes.hpp" + +int main() { + const Rect room { 3, 4 }; + std::cout << area(room) << " " << perimeter(room) << "\n"; + std::cout << describe({ area(room), perimeter(room) }) << "\n"; +} diff --git a/conformance/fixtures/no-modules/src/report.cpp b/conformance/fixtures/no-modules/src/report.cpp new file mode 100644 index 00000000..3424e385 --- /dev/null +++ b/conformance/fixtures/no-modules/src/report.cpp @@ -0,0 +1,7 @@ +#include "report.hpp" + +std::string describe(const std::vector& values) { + std::string text; + for (const int value : values) text += std::to_string(value) + " "; + return text; +} diff --git a/conformance/fixtures/no-modules/src/report.hpp b/conformance/fixtures/no-modules/src/report.hpp new file mode 100644 index 00000000..b4cf43d9 --- /dev/null +++ b/conformance/fixtures/no-modules/src/report.hpp @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +std::string describe(const std::vector& values); diff --git a/conformance/fixtures/no-modules/src/shapes.cpp b/conformance/fixtures/no-modules/src/shapes.cpp new file mode 100644 index 00000000..be7a44e1 --- /dev/null +++ b/conformance/fixtures/no-modules/src/shapes.cpp @@ -0,0 +1,5 @@ +#include "shapes.hpp" + +int area(const Rect& rect) { return rect.width * rect.height; } + +int perimeter(const Rect& rect) { return 2 * (rect.width + rect.height); } diff --git a/conformance/fixtures/no-modules/src/shapes.hpp b/conformance/fixtures/no-modules/src/shapes.hpp new file mode 100644 index 00000000..1d01a823 --- /dev/null +++ b/conformance/fixtures/no-modules/src/shapes.hpp @@ -0,0 +1,10 @@ +#pragma once + +struct Rect { + int width; + int height; +}; + +// The area of a rectangle in square units. +int area(const Rect& rect); +int perimeter(const Rect& rect); diff --git a/conformance/fixtures/tidy-const-views/.clang-tidy b/conformance/fixtures/tidy-const-views/.clang-tidy new file mode 100644 index 00000000..e0e4aa8a --- /dev/null +++ b/conformance/fixtures/tidy-const-views/.clang-tidy @@ -0,0 +1 @@ +Checks: "-*,misc-const-correctness" diff --git a/conformance/fixtures/tidy-const-views/.clangd b/conformance/fixtures/tidy-const-views/.clangd new file mode 100644 index 00000000..3f077956 --- /dev/null +++ b/conformance/fixtures/tidy-const-views/.clangd @@ -0,0 +1,3 @@ +Diagnostics: + ClangTidy: + FastCheckFilter: None diff --git a/conformance/fixtures/tidy-const-views/scenario.json b/conformance/fixtures/tidy-const-views/scenario.json new file mode 100644 index 00000000..ccf80dfa --- /dev/null +++ b/conformance/fixtures/tidy-const-views/scenario.json @@ -0,0 +1,10 @@ +{ + "name": "tidy-const-views", + "description": "0.0.9 plan M-3, WA-CLANGD-010: clang-tidy's misc-const-correctness says a variable holding a filter view, or a view over one, can be declared const, and adding it breaks the build, since such a view caches begin() and has no const one (UP-22). The project's .clangd turns on the check for everything (FastCheckFilter: None, without which clang-tidy leaves it out of a file that includes a standard library), and the file has two such variables and one int that is never changed: the server shows the int and not the views. T1 waits for clangd's diagnostics (a diagnostic-code-lines check alone can read the empty set mcppls publishes before clangd has built the file, as on a Windows runner); T2 then holds the lines exactly. The standard library is the semantic kit's libc++ on every platform; workaround-canaries holds the same file against clangd alone, with the host's.", + "server-arguments": ["--no-discover"], + "checks": [ + { "id": "S1", "kind": "status", "state": "ready", "engine-name": "clangd" }, + { "id": "T1-the-int-is-told", "kind": "diagnostic-code", "file": "src/main.cpp", "expect": "misc-const-correctness", "line": 6, "timeout": 60 }, + { "id": "T2-const-advice-only-where-true", "kind": "diagnostic-code-lines", "file": "src/main.cpp", "code": "misc-const-correctness", "lines": [6] } + ] +} diff --git a/conformance/fixtures/tidy-const-views/src/main.cpp b/conformance/fixtures/tidy-const-views/src/main.cpp new file mode 100644 index 00000000..5d731d93 --- /dev/null +++ b/conformance/fixtures/tidy-const-views/src/main.cpp @@ -0,0 +1,13 @@ +#include +#include + +int count_even(const std::vector& values) { + auto evens = values | std::views::filter([](int v) { return v % 2 == 0; }); + auto doubled = evens | std::views::transform([](int v) { return v * 2; }); + int limit = 100; + int total = 0; + for (const int v : doubled) { + if (total + v <= limit) total += v; + } + return total; +} diff --git a/conformance/fixtures/ux-heavy-headers/scenario.json b/conformance/fixtures/ux-heavy-headers/scenario.json new file mode 100644 index 00000000..64b3c48a --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/scenario.json @@ -0,0 +1,38 @@ +{ + "name": "ux-heavy-headers", + "description": "0.0.9 plan M-2 (user-experience scenario, issue #37): a project without modules whose files include a heavy header, the case WA-CLANGD-009 is for. Ten sources include src/heavy/all.hpp, which the prepare step generates (400 headers of 60 class templates, variable templates, functions and macros each, and some of the standard library's own: some 8 MB to preprocess for every file, no network and no licence). U20 types into one of them, a comment line appended before each completion, and asks for the completion after `values.`: first of clangd alone, started on the very compile_commands.json the server wrote for its own (the baseline: what the project costs clangd, whatever mcppls does), then of the server, the same number of times. The server's p95 may be 1.3 times clangd's plus 30 ms. With 0.0.8's --experimental-modules-support clangd scans the file's module dependencies again for every completion, and the same file took 3 to 5 times as long (the workaround-canaries fixture measures it directly); a change that costs a project without modules anything of the kind is caught here whatever its cause. Local runs on a shared machine are noisy: judge a budget on CI's release-payload run.", + "prepare": [ + ["{conformance}", "prepare", "heavy-headers", "400"] + ], + "server-arguments": [ + "--no-discover" + ], + "checks": [ + { + "id": "U20-completion-against-clangd", + "kind": "completion-baseline", + "stage": "edits", + "file": "src/unit_00.cpp", + "at": [3, 11], + "rounds": 40, + "interval-ms": 150, + "edit": true, + "baseline-arguments": ["--use-dirty-headers", "--compile-commands-dir={engine-database}", "--header-insertion=never", "--pretty=false", "--background-index=false", "--log=error"], + "budget": { + "ratio": 1.3, + "slackMs": 30, + "engineShare": 0.95 + }, + "budget-why": "The plan's rule (0.0.9 M-2, memory 'budgets = 1.3 x p95'): the server may cost a completion what clangd alone costs it, plus the 30 ms of scheduling and the JSON in both directions that a person does not feel; measured through the server, 10 to 15 ms over clangd on a vulkan-hpp file. The engine share is the share of the answers clangd gave: an answer from the file's words is fast and no completion, so a server that gave up on clangd for this project would pass the time and fail here." + }, + { + "id": "U21-no-modules-support", + "kind": "report", + "stage": "edits", + "expect": [ + { "path": "/roots/0/engines/*/details/arguments/*", "equals": "--experimental-modules-support", "max-matches": 0 }, + { "path": "/roots/0/requests/*/engineP95Ms", "exists": true } + ] + } + ] +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_00.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_00.cpp new file mode 100644 index 00000000..7bc904ce --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_00.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_00(const std::vector& values) { + values.size(); + return heavy::n000::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_01.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_01.cpp new file mode 100644 index 00000000..c21cdf42 --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_01.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_01(const std::vector& values) { + values.size(); + return heavy::n001::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_02.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_02.cpp new file mode 100644 index 00000000..0fb5e6b3 --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_02.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_02(const std::vector& values) { + values.size(); + return heavy::n002::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_03.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_03.cpp new file mode 100644 index 00000000..483a49a5 --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_03.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_03(const std::vector& values) { + values.size(); + return heavy::n003::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_04.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_04.cpp new file mode 100644 index 00000000..590520c4 --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_04.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_04(const std::vector& values) { + values.size(); + return heavy::n004::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_05.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_05.cpp new file mode 100644 index 00000000..cf82f3c8 --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_05.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_05(const std::vector& values) { + values.size(); + return heavy::n005::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_06.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_06.cpp new file mode 100644 index 00000000..090f1bbe --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_06.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_06(const std::vector& values) { + values.size(); + return heavy::n006::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_07.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_07.cpp new file mode 100644 index 00000000..3815d1ca --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_07.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_07(const std::vector& values) { + values.size(); + return heavy::n007::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_08.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_08.cpp new file mode 100644 index 00000000..3b02997d --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_08.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_08(const std::vector& values) { + values.size(); + return heavy::n008::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/ux-heavy-headers/src/unit_09.cpp b/conformance/fixtures/ux-heavy-headers/src/unit_09.cpp new file mode 100644 index 00000000..7db470ed --- /dev/null +++ b/conformance/fixtures/ux-heavy-headers/src/unit_09.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int unit_09(const std::vector& values) { + values.size(); + return heavy::n009::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/workaround-canaries/scenario.json b/conformance/fixtures/workaround-canaries/scenario.json index b9b59c8c..42c0ed6a 100644 --- a/conformance/fixtures/workaround-canaries/scenario.json +++ b/conformance/fixtures/workaround-canaries/scenario.json @@ -1,6 +1,9 @@ { "name": "workaround-canaries", - "description": "Import-hang plan \u00a79: each registered clangd workaround's defect, run against the payload's clangd. A check here failing means an update of clangd fixed the defect, and the workaround it names can be removed (src/engine/clangd/workarounds.cpp).", + "description": "Import-hang plan §9: each registered clangd workaround's defect, run against the payload's clangd. A check here failing means an update of clangd fixed the defect, and the workaround it names can be removed (src/engine/clangd/workarounds.cpp). WA-CLANGD-001 runs clangd --check; WA-CLANGD-009 and 010 drive the clangd over LSP (kind clangd-lsp, 0.0.9 plan M-3), since clangd --check prints no clang-tidy diagnostics and times no completion.", + "prepare": [ + ["{conformance}", "prepare", "heavy-headers", "120"] + ], "server-arguments": [ "--no-discover" ], @@ -12,6 +15,31 @@ "expect": "hangs", "seconds": 10, "says": "WA-CLANGD-001 is no longer needed: this clangd finishes `import hello.` at once; remove it from src/engine/clangd/workarounds.cpp and the typing-import-spin fixture" + }, + { + "id": "WA-CLANGD-009", + "kind": "clangd-lsp", + "only-on": ["linux"], + "action": "completion-ratio", + "file": "src/heavy_use.cpp", + "at": [3, 11], + "arguments": ["--log=error", "--background-index=false", "--experimental-modules-support"], + "baseline-arguments": ["--log=error", "--background-index=false"], + "rounds": 10, + "passes": 2, + "min-ratio": 1.8, + "says": "WA-CLANGD-009 is no longer needed: with --experimental-modules-support this clangd does not scan a file's module dependencies again for every completion of a project without modules (120 headers of 60 templates each took 3 to 5 times as long, measured with 23.1.0); remove it from src/engine/clangd/workarounds.cpp and the no-modules and ux-heavy-headers fixtures" + }, + { + "id": "WA-CLANGD-010", + "kind": "clangd-lsp", + "only-on": ["linux"], + "action": "diagnostics", + "file": "src/tidy/views.cpp", + "code": "misc-const-correctness", + "lines": [4, 5, 6], + "arguments": ["--log=error", "--background-index=false"], + "says": "WA-CLANGD-010 is no longer needed: this clangd no longer says a variable holding a filter view (line 4) or a view over one (line 5) can be declared const, and line 6, an int never changed, is told; remove it from src/engine/clangd/workarounds.cpp and the tidy-const-views fixture" } ] } diff --git a/conformance/fixtures/workaround-canaries/src/heavy_use.cpp b/conformance/fixtures/workaround-canaries/src/heavy_use.cpp new file mode 100644 index 00000000..37adb5ef --- /dev/null +++ b/conformance/fixtures/workaround-canaries/src/heavy_use.cpp @@ -0,0 +1,6 @@ +#include "heavy/all.hpp" + +int total_of(const std::vector& values) { + values.size(); + return heavy::n001::fn1(static_cast(values.size())); +} diff --git a/conformance/fixtures/workaround-canaries/src/tidy/.clang-tidy b/conformance/fixtures/workaround-canaries/src/tidy/.clang-tidy new file mode 100644 index 00000000..e0e4aa8a --- /dev/null +++ b/conformance/fixtures/workaround-canaries/src/tidy/.clang-tidy @@ -0,0 +1 @@ +Checks: "-*,misc-const-correctness" diff --git a/conformance/fixtures/workaround-canaries/src/tidy/.clangd b/conformance/fixtures/workaround-canaries/src/tidy/.clangd new file mode 100644 index 00000000..3f077956 --- /dev/null +++ b/conformance/fixtures/workaround-canaries/src/tidy/.clangd @@ -0,0 +1,3 @@ +Diagnostics: + ClangTidy: + FastCheckFilter: None diff --git a/conformance/fixtures/workaround-canaries/src/tidy/compile_flags.txt b/conformance/fixtures/workaround-canaries/src/tidy/compile_flags.txt new file mode 100644 index 00000000..dfad3644 --- /dev/null +++ b/conformance/fixtures/workaround-canaries/src/tidy/compile_flags.txt @@ -0,0 +1,2 @@ +-std=c++23 +-xc++ diff --git a/conformance/fixtures/workaround-canaries/src/tidy/views.cpp b/conformance/fixtures/workaround-canaries/src/tidy/views.cpp new file mode 100644 index 00000000..5d731d93 --- /dev/null +++ b/conformance/fixtures/workaround-canaries/src/tidy/views.cpp @@ -0,0 +1,13 @@ +#include +#include + +int count_even(const std::vector& values) { + auto evens = values | std::views::filter([](int v) { return v % 2 == 0; }); + auto doubled = evens | std::views::transform([](int v) { return v * 2; }); + int limit = 100; + int total = 0; + for (const int v : doubled) { + if (total + v <= limit) total += v; + } + return total; +} diff --git a/conformance/fixtures/xmake-needs-download/.xmake/linux/x86_64/xmake.conf b/conformance/fixtures/xmake-needs-download/.xmake/linux/x86_64/xmake.conf new file mode 100644 index 00000000..032f3f97 --- /dev/null +++ b/conformance/fixtures/xmake-needs-download/.xmake/linux/x86_64/xmake.conf @@ -0,0 +1,9 @@ +{ + arch = "x86_64", + plat = "linux", + mode = "release", + fancy = true, + proxy = "127.0.0.1:7890", + dotnet = "8.0", + dotnet_sdkver = "8.0.100" +} diff --git a/conformance/fixtures/xmake-needs-download/fake-bin/xmake b/conformance/fixtures/xmake-needs-download/fake-bin/xmake new file mode 100755 index 00000000..defff851 --- /dev/null +++ b/conformance/fixtures/xmake-needs-download/fake-bin/xmake @@ -0,0 +1,69 @@ +#!/bin/sh +# A stand-in for xmake (plan 0.0.9 D-1, D-2, D-6): it knows the three commands mcppls runs and says what xmake 3.1.1 says. +# xmake f --help the options `xmake f` takes: the standard ones and the project's `fancy`; `proxy` and `dotnet` are not there. +# xmake f ... an option it does not list is refused (exit 255); with "CONFIGURE_MISSING" in xmake.lua it fails on missing +# packages (offline) or on an install that failed (the network allowed: `-y`, no offline policy). +# xmake project writes compile_commands.json; with "PROJECT_MISSING" in xmake.lua it fails on missing packages the same way. +missing() { + if [ "$1" = online ]; then + echo " -> libtool 2.4.7: xmake.lua:4" >&2 + echo "error: autoreconf: command not found" >&2 + echo "error: install libtool failed!" >&2 + echo "note: see /home/user/.xmake/cache/packages/2510/l/libtool/2.4.7/installdir.failed/logs/install.txt" >&2 + else + echo "error: The packages($2) not found, please install them first!" >&2 + fi + exit 1 +} +mode=offline +for argument in "$@"; do + case "$argument" in -y) mode=online ;; esac +done +case "$1" in +f) + if [ "$2" = "--help" ]; then + cat <<'HELP' +Usage: $xmake config|f [options] + +Configure the project. + +Common options: + -q, --quiet Quiet operation. + -y, --yes Input yes by default if need user confirm. + --confirm=CONFIRM Input the given result if need user confirm. + - yes + - no + -p PLAT, --plat=PLAT Compile for the given platform. (default: auto) + -a ARCH, --arch=ARCH Compile for the given architecture. (default: auto) + -m MODE, --mode=MODE Set the given compilation mode. (default: release) + --toolchain=TOOLCHAIN Set toolchains. + -o BUILDDIR, --builddir=BUILDDIR Set build directory. (default: build) + --policies=POLICIES Set the build policies. + +Command options (Project Configuration): + + --fancy=[y|n] A project option +HELP + exit 0 + fi + for argument in "$@"; do + case "$argument" in + --proxy=* | --dotnet=* | --dotnet_sdkver=*) + echo "error: Invalid option: $argument" >&2 + exit 255 + ;; + esac + done + if grep -q CONFIGURE_MISSING xmake.lua; then missing "$mode" "libtool, libpthread-stubs"; fi + exit 0 + ;; +project) + if grep -q PROJECT_MISSING xmake.lua; then missing "$mode" "libsdl3"; fi + cat > "$4/compile_commands.json" <&2 +exit 2 diff --git a/conformance/fixtures/xmake-needs-download/scenario.json b/conformance/fixtures/xmake-needs-download/scenario.json new file mode 100644 index 00000000..a7b39db9 --- /dev/null +++ b/conformance/fixtures/xmake-needs-download/scenario.json @@ -0,0 +1,146 @@ +{ + "name": "xmake-needs-download", + "description": "Plan 0.0.9 D-1, D-2, D-3, D-6, run against a stand-in xmake (fake-bin/xmake, first on the server's PATH; POSIX only) that says what xmake 3.1.1 says. The project's .xmake/linux/x86_64/xmake.conf holds a project option (fancy) and keys xmake wrote itself (proxy, dotnet, dotnet_sdkver), which `xmake f` refuses. (a) Offline, `xmake f` fails on missing packages: the status says what xmake would fetch or build (build tools included), that the run stayed offline and that the system package manager works too, offers to fetch them (askOnline), and the project directory is untouched. (b) The person accepts (mcppls.describeOnline) and the install fails: the status carries producer-install-failed with the packages, xmake's error lines and its install log, and offers nothing to fetch. (c) `xmake f` passes but `xmake project` finds packages missing: the same needs-download, not xmake-configure-failed (D-6). (d) The packages are there: the model is xmake's, and across all of it `xmake f` ran once per load -- keys xmake wrote itself were never passed on, so no retry and no xmake-options-left-out.", + "server-path-prepend": "{workspace}/fake-bin", + "prepare": [ + ["chmod", "+x", "{workspace}/fake-bin/xmake"] + ], + "checks": [ + { + "id": "A1-the-status-says-what-is-missing", + "kind": "status", + "source": "inferred", + "issue-code": "producer-needs-download", + "issue-message": "libtool, libpthread-stubs", + "issue-command": "mcppls.runBuildToolInTerminal", + "issue-category": "environment", + "timeout": 60 + }, + { + "id": "A2-build-tools-and-the-offline-run-are-named", + "kind": "status", + "issue-code": "producer-needs-download", + "issue-message": "build tools included" + }, + { + "id": "A3-and-the-system-package-manager-is-a-way", + "kind": "status", + "issue-code": "producer-needs-download", + "issue-message": "system package manager" + }, + { + "id": "A4-a-client-may-offer-to-fetch-it", + "kind": "status", + "issue-code": "producer-needs-download", + "issue-ask-online": true + }, + { + "id": "A5-nothing-was-written-into-the-project", + "kind": "workspace-unchanged" + }, + { + "id": "B1-the-person-accepts", + "kind": "execute-command", + "command": "mcppls.describeOnline" + }, + { + "id": "B2-the-install-failed-and-the-status-says-why", + "kind": "status", + "issue-code": "producer-install-failed", + "issue-message": "error: install libtool failed!", + "issue-command": "mcppls.runBuildToolInTerminal", + "issue-category": "environment", + "timeout": 60 + }, + { + "id": "B3-with-the-packages-and-the-log", + "kind": "status", + "issue-code": "producer-install-failed", + "issue-message": "installdir.failed/logs/install.txt" + }, + { + "id": "B3b-the-status-says-the-fetch-failed", + "kind": "status", + "online-run": "failed", + "online-run-message": "installdir.failed/logs/install.txt" + }, + { + "id": "B4-nothing-is-offered-that-would-fail-the-same-way", + "kind": "report", + "timeout": 20, + "expect": [ + { "path": "/roots/0/project/needsDownload", "absent": true } + ] + }, + { + "id": "C1-the-packages-are-missing-at-the-project-stage", + "kind": "write-file", + "file": "xmake.lua", + "replace": { "from": "CONFIGURE_MISSING", "with": "PROJECT_MISSING" }, + "notify": true + }, + { + "id": "C1b-xmake-f-ran-again", + "kind": "report", + "timeout": 30, + "expect": [ + { "path": "/roots/0/events/*/detail/purpose", "equals": "configure", "min-matches": 3 } + ] + }, + { + "id": "C2-it-is-needs-download-not-a-configure-failure", + "kind": "status", + "issue-code": "producer-needs-download", + "issue-message": "libsdl3", + "issue-ask-online": true, + "timeout": 60 + }, + { + "id": "D1-the-packages-are-there", + "kind": "write-file", + "file": "xmake.lua", + "replace": { "from": "PROJECT_MISSING", "with": "THERE" }, + "notify": true + }, + { + "id": "D1b-xmake-f-ran-again", + "kind": "report", + "timeout": 30, + "expect": [ + { "path": "/roots/0/events/*/detail/purpose", "equals": "configure", "min-matches": 4 } + ] + }, + { + "id": "D2-the-model-is-xmake-s", + "kind": "status", + "source": "xmake", + "timeout": 60 + }, + { + "id": "D3-no-option-was-left-out", + "kind": "status-never", + "after-ready": false, + "issue-codes": ["xmake-options-left-out", "xmake-configure-failed"] + }, + { + "id": "D4-configure-ran-once-for-each-of-the-four-loads", + "kind": "report", + "timeout": 20, + "expect": [ + { "path": "/roots/0/events/*/detail/purpose", "equals": "configure", "max-matches": 4 }, + { "path": "/roots/0/events/*/detail/purpose", "equals": "configure", "min-matches": 4 }, + { "path": "/roots/0/project/source", "equals": "xmake" } + ] + }, + { + "id": "W1-put-back", + "kind": "write-file", + "file": "xmake.lua", + "restore": true + }, + { + "id": "U1-the-project-directory-is-as-it-was", + "kind": "workspace-unchanged" + } + ] +} diff --git a/conformance/fixtures/xmake-needs-download/src/main.cpp b/conformance/fixtures/xmake-needs-download/src/main.cpp new file mode 100644 index 00000000..d0bad2de --- /dev/null +++ b/conformance/fixtures/xmake-needs-download/src/main.cpp @@ -0,0 +1,6 @@ +#include + +int main() { + std::puts("hello"); + return 0; +} diff --git a/conformance/fixtures/xmake-needs-download/xmake.lua b/conformance/fixtures/xmake-needs-download/xmake.lua new file mode 100644 index 00000000..fcc324cf --- /dev/null +++ b/conformance/fixtures/xmake-needs-download/xmake.lua @@ -0,0 +1,10 @@ +-- CONFIGURE_MISSING +add_rules("mode.debug", "mode.release") +set_languages("c++20") +add_requires("libsdl3") + +option("fancy", {default = false, description = "A project option"}) + +target("hello") + set_kind("binary") + add_files("src/*.cpp") diff --git a/conformance/traceability.json b/conformance/traceability.json index 2255620b..f1a16f21 100644 --- a/conformance/traceability.json +++ b/conformance/traceability.json @@ -1136,6 +1136,26 @@ "contains": "once per code per session, and again when a bundle arrives later" } ], + "S3-4-26": [ + { + "check": "mcpp-emit-needs-download/D8-and-the-status-says-the-fetch-succeeded" + }, + { + "check": "xmake-needs-download/B3b-the-status-says-the-fetch-failed" + } + ], + "S3-4-27": [ + { + "script": "editors/vscode/test/unit/downloadAsk.test.ts", + "contains": "each fetch is told once, and only a known outcome" + } + ], + "S3-4-28": [ + { + "script": "editors/vscode/test/unit/downloadAsk.test.ts", + "contains": "fetched without asking once allowed" + } + ], "S3-5.5-1": [ { "check": "module-faults/F0-stand-in" diff --git a/docs/20-projects.md b/docs/20-projects.md index 1eeaf749..df54be8d 100644 --- a/docs/20-projects.md +++ b/docs/20-projects.md @@ -101,9 +101,20 @@ The model follows what you do, with nothing to run by hand: a change to any `xma project again, and so does your own `xmake f` — mcppls reads what it left in `.xmake///xmake.conf` (read only, the newest one when there are several) and configures its private run the same way: platform, architecture, mode (`-m debug` gives `-O0 -g`, not the release -flags), toolchain, SDK, runtimes, kind and the options your own `xmake.lua` declares. If xmake refuses -one of those options (one that `xmake.lua` no longer declares), mcppls configures again with the -standard ones only and the status says which were left out. +flags), toolchain, SDK, runtimes, kind and the options your own `xmake.lua` declares. Which keys of that +file are options is asked of xmake: mcppls runs `xmake f --help` once in the project (kept in its cache +until `xmake` or `xmake.lua` changes) and passes on only the keys that list names. The rest are what xmake +wrote for itself (`proxy`, `dotnet`, `dotnet_sdkver`, toolchain probes): `xmake f` refuses them, so they are +skipped, and nothing is said about it. Only when xmake gives no usable help and then refuses an option does +mcppls configure again with the standard ones only, and the status says which were left out. + +When xmake needs packages it does not have (`add_requires`, and what they need to build, build tools such as +`libtool` or `meson` included), the run stops, as it stays offline: the status says which packages, and +offers to download them, to run xmake in your terminal, or to install them with your system package manager, +which also works because xmake uses the system's package when it finds one. If the network is allowed +(`mcppls.buildTool = online`, or you chose to download) and the install fails, the status says +`producer-install-failed` instead, naming the packages with xmake's own error lines and the path of its +install log, and does not offer the same download again. A `compile_commands.json` of yours, at the root or in `.vscode/` (where xmake's VS Code plugin writes one), is **not** read while mcppls can run xmake: it only follows your last `xmake project`, not your @@ -122,7 +133,7 @@ downloaded stops it with the same offer. L3, like xmake. ## Turning discovery off -`mcppls.buildDiscovery = off` makes mcppls detect no build system at all: nothing is read or run +`mcppls.buildDiscovery.mode = off` makes mcppls detect no build system at all: nothing is read or run implicitly, and only a database you name with `mcppls.database` is used, else the sources are scanned (L4). `mcppls.buildDiscovery.providers` leaves out single build systems instead — for example, only ever read an existing CMake build directory and never run xmake. `mcppls.buildTool = off` is the diff --git a/docs/30-settings.md b/docs/30-settings.md index 7267d6ad..60d54987 100644 --- a/docs/30-settings.md +++ b/docs/30-settings.md @@ -19,8 +19,15 @@ restarted (the VS Code extension already does this for the settings below that n 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. +under its earlier name (dotted key or `initializationOptions`/`didChangeConfiguration` form alike). +Two have been renamed, in 0.0.9, because a setting that is a plain value cannot also be the parent of +other settings (VS Code then shows the children as `undefined`): `mcppls.engine` is now +`mcppls.engine.name`, and `mcppls.buildDiscovery` is now `mcppls.buildDiscovery.mode`. The old names +keep working in every editor; the VS Code extension offers once to move your own settings to the new +names, and changes nothing until you click. + +**`null` means not set.** A `null` in `initializationOptions` is skipped; in `didChangeConfiguration` +it returns that setting to its default. `initializationOptions` and `didChangeConfiguration` both accept a nested object (`{"semanticTokens": {"modules": false}}`) or a dotted key (`{"semanticTokens.modules": false}`), @@ -31,20 +38,20 @@ either wrapped in a top-level `mcppls` object or not. | 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.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.mode` 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: offline, 5 minutes the first time and then three times what the last run took, between 1 and 10 minutes; ten minutes once `buildTool` is `online`. Set it to watch that bound work, or to fix one for a build whose time varies. | -| `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.untrusted` | `true`, `false` | `false` | `--untrusted` | restart | Run no build tool and no compiler; an untrusted workspace is also read as though `buildDiscovery.mode` 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. `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.mode` | `auto`, `off` | `auto` | `--build-discovery` | reload | Whether the project's build system is detected at all. `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.mode` 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.engine.name` | `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.engine.workers` | a string | `auto` | `--engine-workers` | restart | How many files clangd builds at once (its `-j`), shared by module preparation, indexing and the files you edit; one is always kept for what you are waiting on. `auto`: one fewer than the hardware threads, between two and eight, and no more than half the memory in gigabytes. A number sets it. | | `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). | diff --git a/docs/50-troubleshooting.md b/docs/50-troubleshooting.md index ff34e1a9..cc067b97 100644 --- a/docs/50-troubleshooting.md +++ b/docs/50-troubleshooting.md @@ -60,6 +60,17 @@ is not offered in the editor. and the last `toolRuns` entry say why. A common cause is the build tool needing a download: the status bar offers to run it in your terminal. +**"xmake needs libtool, libpthread-stubs downloaded".** Those names are not libraries your code is missing: +they are what xmake would fetch or build for the packages your `xmake.lua` requires, build tools included +(a package set to build from source brings its own, such as `libtool` or `meson`), and mcppls stayed offline +so that it would not download them on its own. Three ways on: choose **Download and Continue** in the +notification, run `xmake` in your terminal (the description is read again when it is done), or install them +with your system package manager (`apt install libtool libpthread-stubs0-dev`, `pacman -S libtool`, `brew +install libtool`): xmake's package recipes name the system packages, and xmake uses the system's when it +finds it. If the status says `producer-install-failed` instead, the network was allowed and the install +failed; the message has xmake's error lines and the path of its `install.txt` log, and the usual cause is +a tool the source build needs (autotools, a compiler) that is not installed. + **Go-to-definition works, completion does not, or the standard library is missing.** Look at `profile`. A semantic kit means no usable compiler was found; `import std` still resolves, but diagnostics come from libc++ rather than from your toolchain. @@ -123,6 +134,21 @@ not built by clangd until the file is saved (it reads imports from disk); while project, that is an information-level "module 'X' is in the project; clangd loads it once the file is saved", not an error (`WA-CLANGD-007`). +**A view "can be declared 'const'", and declared so it does not compile (clang-tidy).** clang-tidy 23.1's +`misc-const-correctness` says so of a variable holding a `std::views::filter`, `drop_while`, `chunk_by` +or `split` view, or a view built on one, although such a view has no const `begin()` (issue #37). It runs +only when your clangd config sets `Diagnostics.ClangTidy.FastCheckFilter: None`. From 0.0.9 mcppls drops +that diagnostic and keeps the check's other ones (workaround `WA-CLANGD-010`). Before then, or to keep +the check quiet altogether, set `misc-const-correctness.AnalyzeValues: false` under +`Diagnostics.ClangTidy.CheckOptions` in the project's `.clangd`. + +**Completion is slower than plain clangd on a project without modules (0.0.8 and earlier).** clangd's +modules support scans a file's module dependencies again for every completion, which costs a heavy +header such as `vulkan.hpp` about 170 ms each time (issue #37). From 0.0.9, a project that has no module +units, no module imports and no `import std` gets clangd without its modules support; the first import +you add restarts clangd with it (workaround `WA-CLANGD-009`, `engine-restart` in the report's +`events`). + **"clangd would not finish main.cpp".** A file's build ran past its budget — five times its own last build, never under 20 s — while the editor waited on it: clangd will not finish it, busy or not. The file is answered by mcppls's own engine, with module-level features, until its text @@ -152,7 +178,9 @@ prepared, is a line saying so. It is clangd being busy with modules, not a failu clangd's late completion is not thrown away: it keeps working for up to 10 s, the requests you make while typing the same word wait for it, and it goes to them as soon as it comes, so in a file clangd rebuilds slowly the list still arrives before you finish the word. `requests..answeredBy` -in the report counts, per method, which engine answered; `completion.late` counts clangd's late +in the report counts, per method, which engine answered; `engineP50Ms` and `engineP95Ms` are the time +clangd (or another engine) took for the requests it answered and `overheadP50Ms` and `overheadP95Ms` the +time mcppls added around it, so a slow completion shows whose time it was; `completion.late` counts clangd's late answers and the requests they went to; `slowestFiles` names the ten slowest files and, per file, how many completions got only words; `engines[].details.buildTimes` says what building each file cost clangd (preamble, imported modules, AST builds). diff --git a/docs/specs/CHANGELOG.md b/docs/specs/CHANGELOG.md index 0f8a6ed8..5c3b1314 100644 --- a/docs/specs/CHANGELOG.md +++ b/docs/specs/CHANGELOG.md @@ -2,6 +2,20 @@ Changes to the specifications in this directory. Each specification is versioned independently. +## 2026-10-01 — S3: an install that failed, and how a download the client asked for ended + +The issue code `producer-install-failed` is named: the build tool, run with the network allowed, could not +install what the project's description needs. Its message names the packages and carries the build tool's own +error lines; it has no `askOnline`, since fetching again would fail the same way, and its command is the terminal +action `producer-needs-download` has. `producer-needs-download` itself is now reported with `askOnline` only +when the description that needed the download was run offline. All additive: protocol version stays 1. + +`CxxModulesStatusParams` gains `onlineRun` (optional): the outcome (`fetched` or `failed`), a sentence +for the person, and when it ended, for the last description of the root that `mcppls.describeOnline` +asked for (S3-4-26). A client tells each run once without blocking (S3-4-27), and may fetch every +offered download of a workspace without asking once the person has said so, with a way back +(S3-4-28). Additive: protocol version stays 1. + ## 2026-09-30 — S3: a bundle for what cannot be recovered from, and resetting a root's cache `CxxModulesIssue` gains `bundle` (optional): the absolute path of a diagnostic bundle the server wrote diff --git a/docs/specs/s3-lsp-extensions.md b/docs/specs/s3-lsp-extensions.md index 65dce12f..db0f511e 100644 --- a/docs/specs/s3-lsp-extensions.md +++ b/docs/specs/s3-lsp-extensions.md @@ -75,6 +75,13 @@ interface CxxModulesStatusParams { progress?: { done: number; total: number }; issues?: CxxModulesIssue[]; // reasons for degradation; absent or empty when there are none notices?: CxxModulesIssue[]; // facts worth showing that reduce no feature, e.g. a producer that writes into the project + onlineRun?: OnlineRun; // how the last download the client asked for ended (S3-4-26) +} + +interface OnlineRun { + outcome: "fetched" | "failed"; // the build tool described the project with what it fetched, or did not + message: string; // one sentence for a person: what was fetched, or why it failed + at: string; // when it ended (RFC 3339); a new value is a new run } interface EngineStatus { @@ -101,6 +108,7 @@ interface CxxModulesIssue { | "engine-incompatible" // the engine cannot run on this machine at all (its program loader refused it); module-level features remain | "modules-doomed" // modules that cannot be prepared because a module they import does not compile | "producer-needs-download" // the build tool, run offline, cannot describe the project without a download + | "producer-install-failed" // the build tool, run with the network allowed, could not install what the description needs; the message names it and says why | "producer-online" // the build tool is describing the project with the network, as the client asked | "generated-files-missing" // files the build generates are named by the build description but not written yet | "implementation-unreadable" // an implementation unit does not build, so the definitions in it are not reached @@ -142,7 +150,9 @@ Each issue **SHOULD** carry a `category` saying whose problem it is. S3-4-15 -A `producer-needs-download` issue says that the build tool, run without the network as a server runs it on its own, cannot describe the project until something is downloaded. A server **MAY** set `askOnline` on it when, asked by `workspace/executeCommand` with the command `mcppls.describeOnline`, it will describe the project once with the network allowed; every later description is without it again. S3-4-16 Until the client asks, and while the download runs, the server **MUST** go on serving the root from what it has (its sources, a partial description) S3-4-17, and **MUST NOT** reach the network on its own. S3-4-18 A client that offers the download **MUST NOT** block anything on the question: no modal dialog, and no request, activation or startup waits for the answer. S3-4-19 It **SHOULD** ask at most once per root and set of missing things. S3-4-20 It **MUST NOT** act on an answer that comes after the root's status no longer carries the issue: the person may have built the project in their own terminal meanwhile, and the server's own offline retries find that by themselves. S3-4-21 A client that does not know `askOnline` sees an issue with a command, as before. +A `producer-needs-download` issue says that the build tool, run without the network as a server runs it on its own, cannot describe the project until something is downloaded. A server **MAY** set `askOnline` on it when, asked by `workspace/executeCommand` with the command `mcppls.describeOnline`, it will describe the project once with the network allowed; every later description is without it again. S3-4-16 Until the client asks, and while the download runs, the server **MUST** go on serving the root from what it has (its sources, a partial description) S3-4-17, and **MUST NOT** reach the network on its own. S3-4-18 A client that offers the download **MUST NOT** block anything on the question: no modal dialog, and no request, activation or startup waits for the answer. S3-4-19 It **SHOULD** ask at most once per root and set of missing things. S3-4-20 It **MUST NOT** act on an answer that comes after the root's status no longer carries the issue: the person may have built the project in their own terminal meanwhile, and the server's own offline retries find that by themselves. S3-4-21 A client that does not know `askOnline` sees an issue with a command, as before. A `producer-install-failed` issue is what that description answers when the network was allowed (the client asked, or the person set the build tool online) and the install failed: it names what failed and carries the build tool's own error lines, it carries no `askOnline` (asking again would repeat the failure), and its command is the same terminal action. + +A server that described a root with the network because a client asked (`mcppls.describeOnline`) **SHOULD** say how that ended in `onlineRun`, and keep it in the status of that root until another such run ends. S3-4-26 A client **SHOULD** tell the person each run once, told apart by `at`, without blocking anything (S3-4-18); a failed run's `message` says what failed, and the root's issues say what is still missing. S3-4-27 A client **MAY** remember, per workspace and only on the person's say-so, that every download the server offers is to be fetched, and then call `mcppls.describeOnline` without asking; it **MUST** offer a way to take that back. S3-4-28 An issue the server cannot recover from without the person — an engine that keeps exiting, cannot be started or cannot run on the machine, a corrupt installation, preparation that stopped making progress — is what a bug report is written about, and what it needs is gone once the editor is restarted. For such an issue a server **SHOULD** write a diagnostic bundle by itself when the issue first appears, and name it in `bundle`. S3-4-22 The bundle **MUST** be redacted as a report is (S3-5.5-3), and stay on the machine it was written on: neither the server nor the client sends it anywhere. S3-4-23 A server **SHOULD** keep only the few newest bundles it wrote by itself. S3-4-24 A client that presents `bundle` **SHOULD** offer, once per issue and without blocking anything, to report the problem with the file attached by the person, to restart and to leave the server off for the workspace. S3-4-25 A client that does not know `bundle` sees the issue as before. diff --git a/docs/zh-CN/20-projects.md b/docs/zh-CN/20-projects.md index ee3cb1bf..a49a7b77 100644 --- a/docs/zh-CN/20-projects.md +++ b/docs/zh-CN/20-projects.md @@ -41,7 +41,9 @@ mcpp 给出的文档里列出了每个翻译单元、它的模块角色、它的 有 `xmake.lua` 就是 xmake 工程。mcppls 用 xmake 自己的命令 `xmake project -k compile_commands` 向 xmake 要一份,这个命令不编译任何东西——但它会配置并扫描模块,所以 mcppls 把 xmake 的配置目录和构建目录指向自己的缓存(`XMAKE_CONFIGDIR`、`--builddir`),你的工程保持不变:不生成、不修改、不删除里面的任何文件,包括你自己的 `compile_commands.json`。它离线运行(`--policies=package.fetch_only,network.mode:private`):没有安装的包会让它停下,并给出和上面一样的询问。第一次描述要几秒钟(实测约 6–8 秒,大部分是 xmake 在探测工具链);这期间工程按源码提供服务。模块角色靠扫描得到,所以 xmake 工程是 L3。 -模型会跟着你的操作走,不需要手动做任何事:任何一个 `xmake.lua` 变了,就重新描述工程;你自己运行 `xmake f` 也一样——mcppls 读取它留在 `.xmake///xmake.conf` 里的内容(只读;有多个时取最新的那个),并让自己的私有运行用同样的方式配置:平台、架构、模式(`-m debug` 得到 `-O0 -g`,而不是 release 的参数)、工具链、SDK、运行库、kind,以及你的 `xmake.lua` 声明的选项。如果 xmake 拒绝其中某个选项(`xmake.lua` 里已经没有声明的那种),mcppls 会只带标准选项再配置一次,状态栏会说明哪些没有带上。 +模型会跟着你的操作走,不需要手动做任何事:任何一个 `xmake.lua` 变了,就重新描述工程;你自己运行 `xmake f` 也一样——mcppls 读取它留在 `.xmake///xmake.conf` 里的内容(只读;有多个时取最新的那个),并让自己的私有运行用同样的方式配置:平台、架构、模式(`-m debug` 得到 `-O0 -g`,而不是 release 的参数)、工具链、SDK、运行库、kind,以及你的 `xmake.lua` 声明的选项。文件里哪些键算选项,由 xmake 自己回答:mcppls 在工程里运行一次 `xmake f --help`(结果缓存在自己的缓存里,直到 `xmake` 或 `xmake.lua` 变化),只传这份列表里有的键。其余的是 xmake 写给自己的(`proxy`、`dotnet`、`dotnet_sdkver`、工具链探测结果):`xmake f` 一律拒绝它们,所以直接跳过,也不会提示。只有 xmake 给不出可用的帮助、又拒绝了某个选项时,mcppls 才会只带标准选项再配置一次,状态栏会说明哪些没有带上。 + +xmake 需要它没有的包时(`add_requires`,以及构建这些包所需要的东西,包括 `libtool`、`meson` 这样的构建工具),运行会停下,因为它保持离线:状态栏会说明是哪些包,并提议下载、在终端里运行 xmake,或者用系统包管理器安装——后者同样有效,因为 xmake 找到系统里的包就会直接用。如果允许联网(`mcppls.buildTool = online`,或者你选择了下载)而安装失败,状态里会改为 `producer-install-failed`,写出包名、xmake 自己的 `error:` 行和它的安装日志路径,不再提供同一个下载。 你自己的 `compile_commands.json`(在根目录或 `.vscode/` 里,xmake 的 VS Code 插件写在那里)在 mcppls 能运行 xmake 时**不会被读取**:它只反映你上一次运行 `xmake project` 时的样子,跟不上 `xmake.lua`,两个来源轮流生效会互相打架。mcppls 运行不了 xmake 时——工作区不受信任、`PATH` 上没有 xmake,或 `mcppls.buildTool` 是 `off`——才会原样读取它,并监视它;如果它比某个 `xmake.lua`(或你的 `xmake.conf`)旧,会有一条通知说一次:运行 `xmake project -k compile_commands` 更新它。想让 mcppls 有意去读你自己的文件,把 `mcppls.buildTool` 设为 `off`。 @@ -51,7 +53,7 @@ mcpp 给出的文档里列出了每个翻译单元、它的模块角色、它的 ## 关闭探测 -`mcppls.buildDiscovery = off` 让 mcppls 完全不探测构建系统:不隐式读取或运行任何东西,只使用你用 `mcppls.database` 指定的数据库,否则扫描源码(L4)。`mcppls.buildDiscovery.providers` 则只去掉个别构建系统——比如只读现有的 CMake 构建目录、永远不运行 xmake。`mcppls.buildTool = off` 是更窄的开关:仍然探测构建系统、读取它们已有的输出,只是从不运行。见 [30-settings.md](30-settings.md)。 +`mcppls.buildDiscovery.mode = off` 让 mcppls 完全不探测构建系统:不隐式读取或运行任何东西,只使用你用 `mcppls.database` 指定的数据库,否则扫描源码(L4)。`mcppls.buildDiscovery.providers` 则只去掉个别构建系统——比如只读现有的 CMake 构建目录、永远不运行 xmake。`mcppls.buildTool = off` 是更窄的开关:仍然探测构建系统、读取它们已有的输出,只是从不运行。见 [30-settings.md](30-settings.md)。 ## compile_commands.json diff --git a/docs/zh-CN/30-settings.md b/docs/zh-CN/30-settings.md index f4781be7..2291436d 100644 --- a/docs/zh-CN/30-settings.md +++ b/docs/zh-CN/30-settings.md @@ -16,8 +16,13 @@ VS Code 扩展已经会这样做);`重新加载模型` 只重新加载项目 下次被读取时就是它生效的时候。 **改名后的设置照常能用。** 注册表给一个设置登记了旧名时,用旧名(不管是点号写法还是 -`initializationOptions`/`didChangeConfiguration` 里的写法)依然有效;下面这些设置目前还没有改过名, -所以都没有登记旧名。 +`initializationOptions`/`didChangeConfiguration` 里的写法)依然有效。0.0.9 改了两个名字: +一个本身是普通值的设置不能同时是其他设置的父键(否则 VS Code 会把子设置显示成 `undefined`),所以 +`mcppls.engine` 现在是 `mcppls.engine.name`,`mcppls.buildDiscovery` 现在是 `mcppls.buildDiscovery.mode`。 +旧名在所有编辑器里继续有效;VS Code 扩展会提示一次是否把你自己的设置改成新名,点了才改。 + +**`null` 等于未设置。** `initializationOptions` 里的 `null` 被跳过;`didChangeConfiguration` 里的 `null` +让该设置恢复默认值。 `initializationOptions` 和 `didChangeConfiguration` 都同时接受嵌套对象 (`{"semanticTokens": {"modules": false}}`)和点号写法的键(`{"semanticTokens.modules": false}`), @@ -28,20 +33,20 @@ VS Code 扩展已经会这样做);`重新加载模型` 只重新加载项目 | 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | |---|---|---|---|---|---| -| `mcppls.buildTool` | `offline`, `online`, `off` | `offline` | `--build-tool` | 重新加载模型 | 项目构建工具的运行方式。`offline`:不联网运行——如果构建工具因此无法在不下载东西的情况下描述构建,状态栏会说明缺什么,并提议在终端里运行它。`online`:允许联网,超时时间从一分钟延长到十分钟。`off`:从不运行构建工具;仍会探测构建系统、仍读取它已有的产物(要连探测也关掉,见 `buildDiscovery`)。 | +| `mcppls.buildTool` | `offline`, `online`, `off` | `offline` | `--build-tool` | 重新加载模型 | 项目构建工具的运行方式。`offline`:不联网运行——如果构建工具因此无法在不下载东西的情况下描述构建,状态栏会说明缺什么,并提议在终端里运行它。`online`:允许联网,超时时间从一分钟延长到十分钟。`off`:从不运行构建工具;仍会探测构建系统、仍读取它已有的产物(要连探测也关掉,见 `buildDiscovery.mode`)。 | | `mcppls.toolEnvironment` | `auto`, `editor` | `auto` | `--tool-environment` | 重启 | 构建工具在哪个环境中启动。`auto` 会在后台读取一次你登录 shell 的环境(仅限 POSIX 系统)——从桌面项或 Dock 图标启动的编辑器不带任何 shell 配置,没有这个选项,它找到的构建工具可能就不是你终端里找到的那个。在 Windows 上,编辑器的环境本就和终端一致。`editor` 始终使用编辑器进程自身的环境。 | | `mcppls.producerTimeout` | 非负整数(秒) | `0` | `--producer-timeout` | 重新加载模型 | 构建工具描述项目最多可以花多长时间。默认 `0` 使用设计本身的限制:离线时第一次 5 分钟,之后是上一次用时的三倍,在 1 到 10 分钟之间;`buildTool` 为 `online` 时十分钟。调短可以观察限制是否生效,构建用时起伏大时可以定一个值。 | -| `mcppls.untrusted` | `true`, `false` | `false` | `--untrusted` | 重启 | 不运行任何构建工具,也不运行编译器;一个不受信任的工作区也等同于 `buildDiscovery` 为 `off`。 | +| `mcppls.untrusted` | `true`, `false` | `false` | `--untrusted` | 重启 | 不运行任何构建工具,也不运行编译器;一个不受信任的工作区也等同于 `buildDiscovery.mode` 为 `off`。 | | `mcppls.discoverCompilers` | `true`, `false` | `true` | `--no-discover` | 重新加载模型 | 为构建描述没有覆盖到的源码在本机查找编译器。关闭后,这类源码改用语义工具包。 | -| `mcppls.buildDiscovery` | `auto`, `off` | `auto` | `--build-discovery` | 重新加载模型 | 是否探测项目的构建系统。`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.mode` | `auto`, `off` | `auto` | `--build-discovery` | 重新加载模型 | 是否探测项目的构建系统。`off`:不隐式读取或执行任何东西——只用明确配置的 `database`,否则扫描源码。`buildTool` 管的是探测到的构建工具能不能*执行*;这个开关管的是要不要去探测它。 | +| `mcppls.buildDiscovery.providers` | `mcpp`, `cmake`, `xmake`, `meson`, `compile-commands`(逗号分隔) | `mcpp`, `cmake`, `xmake`, `meson`, `compile-commands` | `--build-discovery-providers` | 重新加载模型 | `buildDiscovery.mode` 可以使用哪些构建系统提供者;从中去掉某个提供者即停用它的探测(例如只想用已有的 CMake 构建目录,不要 xmake)。 | | `mcppls.buildDiscovery.askBeforeDownload` | `true`, `false` | `true` | — | 立即生效 | 当构建工具需要下载才能完成描述项目时,客户端可以提议去获取它。关闭后,状态栏说明需要下载,但不会再询问。 | ### 引擎 | 设置 | 取值 | 默认值 | 命令行 | 生效方式 | 作用 | |---|---|---|---|---|---| -| `mcppls.engine` | `clangd`, `none` | `clangd` | `--engine` | 重启 | 核心引擎。无论如何,mcppls 自己的模块引擎都会运行;`none` 表示只提供模块相关功能。 | +| `mcppls.engine.name` | `clangd`, `none` | `clangd` | `--engine` | 重启 | 核心引擎。无论如何,mcppls 自己的模块引擎都会运行;`none` 表示只提供模块相关功能。 | | `mcppls.engine.workers` | 字符串 | `auto` | `--engine-workers` | 重启 | clangd 同时构建的文件数(它的 `-j`),由模块预建、索引和你正在编辑的文件共用;总会留一个给你正在等的东西。`auto`:硬件线程数减一,介于二到八之间,且不超过内存 GB 数的一半。写数字则按数字。 | | `mcppls.compiler` | 字符串 | (空) | `--compiler` | 重新加载模型 | 为模块语义使用这个编译器,而不是检测到的那个:可以是绝对路径、`PATH` 上的名字,或 `kit`(强制使用内置的语义工具包)。空表示自动检测。 | | `mcppls.semanticKit` | `auto`, `off` | `auto` | `--semantic-kit` | 重新加载模型 | 内置的标准库工具包是否可以被使用:`auto` 在没有找到编译器时使用;`off` 从不使用(没有编译器时只剩模块相关功能)。 | diff --git a/docs/zh-CN/50-troubleshooting.md b/docs/zh-CN/50-troubleshooting.md index 1d4635ef..11266979 100644 --- a/docs/zh-CN/50-troubleshooting.md +++ b/docs/zh-CN/50-troubleshooting.md @@ -45,6 +45,8 @@ mcppls report --bundle problem.zip --root path/to/project # 可加 --hide-proj ## 常见症状 +**“xmake needs libtool, libpthread-stubs downloaded”。** 这些名字不是你的代码缺的库:它们是 xmake 为你的 `xmake.lua` 所要求的包而要下载或构建的东西,包括构建工具(设成从源码构建的包会带进自己的构建工具,比如 `libtool`、`meson`),而 mcppls 保持离线,不会自己去下载。有三条路:在通知里选 **Download and Continue**;在终端里运行 `xmake`(做完后会重新读取描述);或者用系统包管理器安装(`apt install libtool libpthread-stubs0-dev`、`pacman -S libtool`、`brew install libtool`)——xmake 的包定义里写了对应的系统包,找到系统里的就直接用。如果状态里写的是 `producer-install-failed`,说明已允许联网、安装本身失败了;消息里有 xmake 的 `error:` 行和 `install.txt` 日志的路径,常见原因是源码构建需要的工具(autotools、编译器)没有安装。 + **所有功能失效,任何位置都无法跳转到定义。** 看报告里的 `project.source`。如果一个用了构建系统的项目里它是 `inferred`,说明构建工具没有给出答复;原因在 `project.issues` 和 `toolRuns` 的最后一条里。常见原因是构建工具需要下载东西:这时状态栏会提议在你的终端里运行它。 **跳转到定义能用,补全不能用,或者标准库缺失。** 看 `profile`。语义工具包意味着没找到可用的编译器;`import std` 仍然能解析,但诊断来自 libc++,不是来自你的工具链。 @@ -63,6 +65,10 @@ mcppls report --bundle problem.zip --root path/to/project # 可加 --hide-proj **“Import directive must end with a ';'” 标在了别的行上,或刚输入的 import 报 “module X not found”。** clangd 把缺少 `;` 的指令报在它后面的代码上;mcppls 会把这条诊断移回指令所在行(规避措施 `WA-CLANGD-006`)。刚输入、还没保存的 import,clangd 要等文件保存后才会构建(它从磁盘读取 import);只要这个模块在项目里,这时给出的是信息级提示 “module 'X' is in the project; clangd loads it once the file is saved”,而不是错误(`WA-CLANGD-007`)。 +**某个 view 被提示 “can be declared 'const'”,加了 const 却编译不过(clang-tidy)。** clang-tidy 23.1 的 `misc-const-correctness` 会对保存 `std::views::filter`、`drop_while`、`chunk_by`、`split` 视图(或建立在它们之上的视图)的变量给出这条提示,但这类视图没有 const 的 `begin()`(issue #37)。只有 clangd 配置里设了 `Diagnostics.ClangTidy.FastCheckFilter: None` 时这项检查才会运行。从 0.0.9 起 mcppls 会去掉这条诊断,这项检查的其他诊断照常保留(规避措施 `WA-CLANGD-010`)。在此之前,或者想让这项检查完全安静,可以在项目的 `.clangd` 里 `Diagnostics.ClangTidy.CheckOptions` 下设置 `misc-const-correctness.AnalyzeValues: false`。 + +**不用模块的项目里,补全比直接用 clangd 慢(0.0.8 及更早版本)。** 开启模块支持时,clangd 每次补全都要重新扫描一遍文件的模块依赖,像 `vulkan.hpp` 这样很重的头文件每次要多花约 170 ms(issue #37)。从 0.0.9 起,没有模块单元、没有模块 import、也没有 `import std` 的项目,clangd 启动时不开模块支持;加入第一个 import 时会重启一次 clangd 并开启它(规避措施 `WA-CLANGD-009`,报告的 `events` 里有对应的 `engine-restart`)。 + **“clangd would not finish main.cpp”。** 某个文件的构建超出了预算——该文件上次构建耗时的五倍,最少 20 秒——而编辑器还在等它:不管 clangd 忙不忙,它都不会完成这个文件了。这个文件改由 mcppls 自己的引擎应答(提供模块层面的功能),直到它的文本发生变化(让 clangd 卡住的那份文本永远不会再交给它),同时立即重启一个不带这个文件的 clangd。`events` 日志里有一条带具体数字的 `engine-spin`。 **某个规避措施还需要吗?** `--disable-workaround WA-CLANGD-`(可重复)可以关掉一个;日志开头几行会列出正在使用的规避措施。每个规避措施在一致性测试里都有一个对应的检测项(`workaround-canaries`),clangd 更新修好了对应缺陷后,这个检测项就会失败。 @@ -71,7 +77,7 @@ mcppls report --bundle problem.zip --root path/to/project # 可加 --hide-proj **每次启动都很慢。** 第二次会话应该很快:模型连同构建工具所读一切内容的指纹一起被缓存,与之匹配的会话会立即套用计划,并在后台确认;已经构建好的模块会复用,不会重建(0.0.6 及更早版本在热启动时会把每个模块都重建一遍,issue #30)。`project.firstOrigin` 会说明发生了哪种情况。如果它一直是 `producer`,说明指纹没有匹配上——该看报告里的 `project.producerRun` 和构建文件的时间戳。 -**补全只有文件里的词,或悬停提示说正在准备模块。** 每种请求给 clangd 的都有预算——补全和签名帮助 1 秒,悬停 2 秒,跳转到定义 10 秒——超过之后 mcppls 用手上有的东西作答。补全这时给出的是文件里离光标最近的那些词,是一份不完整的列表,所以你继续输入时编辑器会再问一次;悬停在模块准备期间给出的是一行说明。这是 clangd 正忙着处理模块,不是故障。从 0.0.8 起,clangd 迟到的补全不再丢弃:它最多再算 10 秒,你在同一个词里继续输入时发出的请求都等它,一到就交给它们,所以在 clangd 重建得慢的文件里,列表仍会在你打完这个词之前出现。报告里的 `requests..answeredBy` 按方法统计了各由哪个引擎作答;`completion.late` 统计 clangd 迟到的答案和用上它们的请求;`slowestFiles` 列出最慢的十个文件,以及每个文件有多少次补全只拿到了词;`engines[].details.buildTimes` 说明 clangd 构建每个文件花在哪里(preamble、导入的模块、AST 构建次数)。 +**补全只有文件里的词,或悬停提示说正在准备模块。** 每种请求给 clangd 的都有预算——补全和签名帮助 1 秒,悬停 2 秒,跳转到定义 10 秒——超过之后 mcppls 用手上有的东西作答。补全这时给出的是文件里离光标最近的那些词,是一份不完整的列表,所以你继续输入时编辑器会再问一次;悬停在模块准备期间给出的是一行说明。这是 clangd 正忙着处理模块,不是故障。从 0.0.8 起,clangd 迟到的补全不再丢弃:它最多再算 10 秒,你在同一个词里继续输入时发出的请求都等它,一到就交给它们,所以在 clangd 重建得慢的文件里,列表仍会在你打完这个词之前出现。报告里的 `requests..answeredBy` 按方法统计了各由哪个引擎作答;`engineP50Ms`、`engineP95Ms` 是引擎作答的那些请求在引擎里花的时间,`overheadP50Ms`、`overheadP95Ms` 是 mcppls 在其外加的时间,由此看出一次慢的补全慢在谁;`completion.late` 统计 clangd 迟到的答案和用上它们的请求;`slowestFiles` 列出最慢的十个文件,以及每个文件有多少次补全只拿到了词;`engines[].details.buildTimes` 说明 clangd 构建每个文件花在哪里(preamble、导入的模块、AST 构建次数)。 **模块单元里出现"未使用的头文件"警告。** 这是 clangd 的 include cleaner,默认开启,mcppls 不关它:在模块接口的全局模块片段、实现单元和导入方里,它和在普通文件里一样,只报没有任何东西用到的头文件(有一个 conformance fixture 在 clangd 升级时守着这一点)。要关掉,在项目的 `.clangd` 或你的 clangd `config.yaml` 里写 `Diagnostics: { UnusedIncludes: None }`;mcppls 启动的 clangd 两处都会读。 diff --git a/editors/claude-code/.claude-plugin/marketplace.json b/editors/claude-code/.claude-plugin/marketplace.json index f4e713a6..87d1edbd 100644 --- a/editors/claude-code/.claude-plugin/marketplace.json +++ b/editors/claude-code/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "displayName": "C++ Modules Language Server", "source": "./mcppls-lsp", "description": "Registers mcppls as the language server for C and C++ sources, including C++20/23 named modules.", - "version": "0.0.8", + "version": "0.0.9", "author": { "name": "Sunrisepeak", "url": "https://github.com/Sunrisepeak/mcpp-language-server" diff --git a/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json b/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json index 063ca52a..48dd73ec 100644 --- a/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json +++ b/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mcppls-lsp", "displayName": "C++ Modules Language Server", - "version": "0.0.8", + "version": "0.0.9", "description": "Registers mcppls as the language server for C and C++ sources, including C++20/23 named modules, and its MCP tools (symbols, references, modules, verification, review). Replaces clangd-lsp for a project; do not enable both at once.", "author": { "name": "Sunrisepeak", diff --git a/editors/clion/gradle.properties b/editors/clion/gradle.properties index afacb8fc..4a6dfaf8 100644 --- a/editors/clion/gradle.properties +++ b/editors/clion/gradle.properties @@ -2,7 +2,7 @@ # ones within the same major line; sinceBuild/untilBuild in plugin.xml is what actually gates it. platformType = CL platformVersion = 2026.2.3 -pluginVersion = 0.0.8 +pluginVersion = 0.0.9 org.gradle.jvmargs = -Xmx2g # The IDE ships the Kotlin standard library; bundling a second copy in the plugin is what JetBrains # asks plugins not to do. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 78846997..c66b6bdd 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -79,7 +79,9 @@ All settings are optional. |---|---|---| | `mcppls.compiler` | automatic | Follow this compiler instead of the discovered one | | `mcppls.semanticKit` | `auto` | `off` never uses the built-in standard library kit | -| `mcppls.engine` | `clangd` | `none` runs without clangd: module-level features only | +| `mcppls.engine.name` | `clangd` | `none` runs without clangd: module-level features only. Called `mcppls.engine` before 0.0.9; a value under the old name still applies, and the extension offers once to move it | +| `mcppls.engine.workers` | `auto` | How many files clangd builds at once; `auto`, or a whole number from 1 to 99. Takes effect when the server restarts, which a change does by itself | +| `mcppls.buildDiscovery.mode` | `auto` | `off` detects no build system and runs nothing implicitly. Called `mcppls.buildDiscovery` before 0.0.9, handled the same way | | `mcppls.enable` | `true` | `false` in a workspace's (or folder's) settings starts nothing there: the extension stays installed, activation stays cheap, and the status bar item says the server is off and turns it back on. Changing it takes effect at once | | `mcppls.ai.enabled` | `false` | Show the review commands | | `mcppls.detectConflicts` | `true` | Offer once to turn off other C++ extensions' language features in the workspace, and notice again if one becomes active later | diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 8d383981..b0d898a9 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "mcpp-language-server", "displayName": "C++ Modules Language Server", "description": "mcppls - C++20/23 named modules that just work: go to definition, completion, hover and references across modules for any compiler, with clangd and a standard library kit built in.", - "version": "0.0.8", + "version": "0.0.9", "publisher": "sunrisepeak", "license": "Apache-2.0", "icon": "icon.png", @@ -201,6 +201,11 @@ "title": "Run the Build Tool in a Terminal", "category": "C++ Modules" }, + { + "command": "mcppls.askBeforeDownloading", + "title": "Ask Before Downloading in This Workspace", + "category": "C++ Modules" + }, { "command": "mcppls.turnOffOtherCppFeatures", "title": "Turn Off Other C++ Language Features", @@ -237,10 +242,11 @@ "mcppls.engine.workers": { "type": "string", "default": "auto", - "pattern": "^(auto|[1-9][0-9]?)$", + "pattern": "^(auto|[1-9][0-9]?)?$", + "patternErrorMessage": "auto, or a whole number from 1 to 99", "description": "How many files clangd builds at once (its -j), shared by module preparation, indexing and the files you edit; one is always kept for what you are waiting on. auto: one fewer than the hardware threads, between two and eight, and no more than half the memory in gigabytes. A number sets it. Takes effect when the server restarts." }, - "mcppls.engine": { + "mcppls.engine.name": { "type": "string", "enum": [ "clangd", @@ -317,7 +323,7 @@ ], "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": { + "mcppls.buildDiscovery.mode": { "type": "string", "enum": [ "auto", @@ -349,7 +355,7 @@ "meson", "compile-commands" ], - "description": "Which build system providers mcppls.buildDiscovery may use; leave one out to stop mcppls from detecting it." + "description": "Which build system providers mcppls.buildDiscovery.mode may use; leave one out to stop mcppls from detecting it." }, "mcppls.buildDiscovery.askBeforeDownload": { "type": "boolean", diff --git a/editors/vscode/src/commands.ts b/editors/vscode/src/commands.ts index 4b8c2680..3845aecc 100644 --- a/editors/vscode/src/commands.ts +++ b/editors/vscode/src/commands.ts @@ -9,6 +9,7 @@ import { restoreOtherCppFeatures, turnOffOtherCppFeatures } from './conflicts'; import { advertisesCacheReset, freedText, parseCacheResetResult, RESET_CACHE_COMMAND, SERVER_RESET_CACHE_COMMAND, sizeText } from './cacheReset'; import { turnOffInWorkspace, turnOnInWorkspace } from './enable'; import { sourceOf } from './quickSuggestions'; +import { RENAMED_SETTINGS, resolveRenamed, workersSetting } from './settingsRead'; import { redactJson, Who } from './redact'; import { describeProfile, SemanticProfile } from './status'; @@ -233,7 +234,9 @@ function mcpplsSettings(): Record { return { compiler: settings.get('compiler'), semanticKit: settings.get('semanticKit'), - engine: settings.get('engine'), + 'engine.name': resolveRenamed(settings, RENAMED_SETTINGS[0], 'clangd'), + 'engine.workers': workersSetting(settings.get('engine.workers')), + 'buildDiscovery.mode': resolveRenamed(settings, RENAMED_SETTINGS[1], 'auto'), buildTool: settings.get('buildTool'), toolEnvironment: settings.get('toolEnvironment'), semanticTokensModules: settings.get('semanticTokens.modules'), diff --git a/editors/vscode/src/downloadAsk.ts b/editors/vscode/src/downloadAsk.ts index b5fb419e..b3555477 100644 --- a/editors/vscode/src/downloadAsk.ts +++ b/editors/vscode/src/downloadAsk.ts @@ -19,3 +19,28 @@ export function needsDownload(status: { issues?: { code: string; message: string 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); } + +// D-4 (plan 0.0.9): what to do about a download the server offers. With downloads allowed for this workspace +// ("Always Download in This Workspace") it is fetched without asking -- once per set of missing things, like the +// question -- and otherwise asked, unless the person said "Don't Ask Again". A run already open for this root waits. +export type DownloadAction = 'ask' | 'fetch' | 'none'; + +export function downloadAction(issue: DownloadIssue | undefined, never: boolean, allowed: boolean, askedAbout: readonly string[], open: boolean): DownloadAction { + if (issue === undefined || issue.askOnline !== true || open || askedAbout.includes(issue.message)) return 'none'; + if (allowed) return 'fetch'; + return never ? 'none' : 'ask'; +} + +// D-5 (plan 0.0.9): how the last fetch the person asked for ended, as the server says it in the status (S3 +// `onlineRun`). `at` tells one run from the next, so each is told once. +export interface OnlineRun { + outcome: 'fetched' | 'failed'; + message: string; + at: string; +} + +export function onlineRunToTell(status: { onlineRun?: OnlineRun }, told: readonly string[]): OnlineRun | undefined { + const run = status.onlineRun; + if (run === undefined || typeof run.at !== 'string' || told.includes(run.at)) return undefined; + return run.outcome === 'fetched' || run.outcome === 'failed' ? run : undefined; +} diff --git a/editors/vscode/src/downloadPrompt.ts b/editors/vscode/src/downloadPrompt.ts index 4f1481c6..1ccf0f51 100644 --- a/editors/vscode/src/downloadPrompt.ts +++ b/editors/vscode/src/downloadPrompt.ts @@ -9,17 +9,27 @@ // - 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. +// +// D-4 (plan 0.0.9): "Always Download in This Workspace" makes the answer the default for this workspace (kept in +// workspaceState, never in the project's files): what the server offers to fetch is fetched without asking, and +// "C++ Modules: Ask Before Downloading in This Workspace" takes it back. The build tool still runs offline every +// other time. D-5: how a fetch the person asked for ended is told once, from the status's `onlineRun`. import * as vscode from 'vscode'; -import { askOnce } from './prompt'; +import { askOnce, notifyOnce } from './prompt'; import type { CxxModulesStatus } from './status'; -import { needsDownload, shouldAsk } from './downloadAsk'; +import { downloadAction, needsDownload, onlineRunToTell } from './downloadAsk'; export const DESCRIBE_ONLINE_COMMAND = 'mcppls.describeOnline'; export const RUN_IN_TERMINAL_COMMAND = 'mcppls.runBuildToolInTerminal'; +export const SHOW_LOGS_COMMAND = 'mcppls.showLogs'; export const NEVER_KEY = 'mcppls.downloadPrompt.never'; export const ASKED_KEY = 'mcppls.downloadPrompt.asked'; +export const ALWAYS_KEY = 'mcppls.downloadPrompt.always'; +export const TOLD_KEY = 'mcppls.downloadPrompt.told'; export const FETCH = 'Download and Continue'; +export const ALWAYS = 'Always Download in This Workspace'; +export const SHOW_LOGS = 'Show Logs'; export const RUN_IN_TERMINAL = 'Run in Terminal'; export const NEVER = "Don't Ask Again"; @@ -33,6 +43,7 @@ export class DownloadPromptController { // Called for every cxxModules/status notification from the running server. onStatus(status: CxxModulesStatus): void { const root = status.project.root; + this.tellOnlineRun(status); const issue = needsDownload(status); if (issue) { this.pending.set(root, issue.message); @@ -40,25 +51,66 @@ export class DownloadPromptController { this.pending.delete(root); } const never = this.context.workspaceState.get(NEVER_KEY, false); + const allowed = this.context.workspaceState.get(ALWAYS_KEY, false); const askedAbout = this.context.workspaceState.get(ASKED_KEY, []); - if (!shouldAsk(issue, never, askedAbout, this.open.has(root)) || !issue) { + const action = downloadAction(issue, never, allowed, askedAbout, this.open.has(root)); + if (action === 'none' || !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; + if (action === 'fetch') { + this.log(`fetching what the build description of ${folder} needs: downloads are allowed in this workspace`); + this.fetch(); + return; + } + this.open.add(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?', + 'meanwhile. Fetch it now (the build tool may reach the network), or run the build yourself? ' + + 'The status bar item says what is missing.', FETCH, + ALWAYS, RUN_IN_TERMINAL, NEVER, ).then((answer) => this.answered(root, answer)); } + // "C++ Modules: Ask Before Downloading in This Workspace": takes "Always Download" back, and "Don't Ask Again". + askBeforeDownloading(): void { + void this.context.workspaceState.update(ALWAYS_KEY, false); + void this.context.workspaceState.update(NEVER_KEY, false); + this.log('downloads for the build description are asked about again in this workspace'); + void vscode.window.showInformationMessage('C++ Modules: a download the build description needs will be asked about first in this workspace.'); + } + + private fetch(): void { + 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)}`); + }); + } + + // D-5: once per run, whether it fetched what was needed or why not. + private tellOnlineRun(status: CxxModulesStatus): void { + const told = this.context.workspaceState.get(TOLD_KEY, []); + const run = onlineRunToTell(status, told); + if (!run) return; + void this.context.workspaceState.update(TOLD_KEY, [...told, run.at].slice(-8)); + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.parse(status.project.root))?.name ?? status.project.root; + this.log(`the fetch for ${folder} ${run.outcome === 'fetched' ? 'succeeded' : 'failed'}: ${run.message}`); + if (run.outcome === 'fetched') { + void notifyOnce('downloadResult', 'info', `C++ Modules: ${folder}: ${run.message}`, []); + return; + } + void notifyOnce('downloadResult', 'warning', `C++ Modules: ${folder}: ${run.message}`, [SHOW_LOGS, RUN_IN_TERMINAL]).then((choice) => { + if (choice === SHOW_LOGS) void vscode.commands.executeCommand(SHOW_LOGS_COMMAND); + else if (choice === RUN_IN_TERMINAL) void vscode.commands.executeCommand(RUN_IN_TERMINAL_COMMAND); + }); + } + private answered(root: string, answer: string | undefined): void { this.open.delete(root); if (answer === NEVER) { @@ -69,16 +121,18 @@ export class DownloadPromptController { if (answer === undefined) { return; } + if (answer === ALWAYS) { + void this.context.workspaceState.update(ALWAYS_KEY, true); + this.log('downloads the build description needs are fetched without asking in this workspace'); + } 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) { + if (answer === FETCH || answer === ALWAYS) { 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)}`); - }); + this.fetch(); } 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 71cf14ad..779db3cd 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -40,14 +40,8 @@ import { promptTestHarness, PromptKind, ShownPrompt } from './prompt'; import { CxxModulesStatus, ModuleIssue, ModuleState, StatusController } from './status'; import { describeActiveWorkarounds } from './workarounds'; import { overriddenByLanguageDefault } from './quickSuggestions'; - -// build description design 4.4: a value this extension does not know must not turn the network on. -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']; +import { buildInitializationOptions } from './settingsRead'; +import { offerSettingsMigration } from './settingsMigration'; const CLIENT_ID = 'mcppls'; const CLIENT_NAME = 'C++ Modules'; @@ -313,44 +307,7 @@ class ServerHost implements vscode.Disposable { }); }, }, - initializationOptions: { - compiler: compiler.length > 0 ? compiler : null, - semanticKit: configuration.get('semanticKit') === 'off' ? 'off' : 'auto', - // overall design 5.6: the core semantic engine; mcppls's own module engine always runs. - engine: configuration.get('engine') === 'none' ? 'none' : 'clangd', - // build description design 4.4: how the user's build tool may be run. - buildTool: buildToolSetting(configuration.get('buildTool')), - // build description design 4.3: which environment it is run in. - toolEnvironment: configuration.get('toolEnvironment') === 'editor' ? 'editor' : 'auto', - // This extension finds other C/C++ language servers itself (mcppls.detectConflicts) - // and offers, once, to turn their language features off. Saying so keeps the server - // from also explaining it: a server cannot see its siblings through LSP, so it tells - // clients that arbitrate nothing — which is every editor but this one. - conflictArbitration: 'client', - // Design 2026-09-25 §7/§12: `modules` is this setting; `moduleType` is fixed true - // because this extension always declares the custom `module` semantic token type - // (package.json contributes.semanticTokenTypes) with a `namespace` fallback for - // themes that do not colour it. - semanticTokens: { - modules: configuration.get('semanticTokens.modules', true), - moduleType: true, - }, - // Fix plan 2026-09-26 F9: a space after `import` opens the module list. The server - // advertises the space as a trigger character to this client unless this is off. - 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', - }, + initializationOptions: buildInitializationOptions(configuration, compiler), middleware: { // Fix plan 2026-09-26 F9 (D4 layer 1): of the completions a typed space asks for, only // the one after `import` or `export import` is sent; every other is answered here, with @@ -592,6 +549,7 @@ export function activate(context: vscode.ExtensionContext): TestApi { const commandLineTools = new CommandLineToolsController(context, (line) => host.log(line)); const downloadPrompt = new DownloadPromptController(context, (line) => host.log(line)); status.onUpdate((current) => downloadPrompt.onStatus(current)); + context.subscriptions.push(vscode.commands.registerCommand('mcppls.askBeforeDownloading', () => downloadPrompt.askBeforeDownloading())); let latestConflictCheck: Promise = Promise.resolve('none-found'); // Conflicts can appear or disappear after activation (another extension @@ -633,6 +591,9 @@ export function activate(context: vscode.ExtensionContext): TestApi { const quickSuggestionsOverridden = overriddenByLanguageDefault( vscode.workspace.getConfiguration('editor', { languageId: 'cpp' }).inspect('quickSuggestions')); if (quickSuggestionsOverridden) host.log(quickSuggestionsOverridden); + // S-1 (plan 0.0.9): a setting of theirs under a renamed name keeps working; they are told once, and + // nothing is written unless they click. + void offerSettingsMigration(context, (line) => host.log(line)); // Coexistence design (§10): a conflict that becomes active after activation -- another C++ // extension installed, enabled, or its setting turned back on -- gets a notice, once per // conflict per session, distinct from the one-time question above. @@ -665,11 +626,15 @@ export function activate(context: vscode.ExtensionContext): TestApi { } } if (event.affectsConfiguration('mcppls.compiler') || event.affectsConfiguration('mcppls.semanticKit') - || event.affectsConfiguration('mcppls.engine') || event.affectsConfiguration('mcppls.buildTool') + // S-1, S-2 (plan 0.0.9): `mcppls.engine` and `mcppls.buildDiscovery` still match, as the old + // names a hand edit may touch; the new ones are named so the list says what it restarts for. + || event.affectsConfiguration('mcppls.engine') || event.affectsConfiguration('mcppls.engine.name') + || event.affectsConfiguration('mcppls.engine.workers') || event.affectsConfiguration('mcppls.buildTool') || event.affectsConfiguration('mcppls.toolEnvironment') || event.affectsConfiguration('mcppls.semanticTokens.modules') || 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') || event.affectsConfiguration('mcppls.buildDiscovery.mode') + || event.affectsConfiguration('mcppls.buildDiscovery.providers') || event.affectsConfiguration('mcppls.buildDiscovery.askBeforeDownload') || event.affectsConfiguration('mcppls.index.primeImplementationUnits')) { void host.restart(); diff --git a/editors/vscode/src/prompt.ts b/editors/vscode/src/prompt.ts index edcf2d64..d8b4fd78 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' | 'download' | 'turnOffScope' | 'restoreScope' | 'unrecoverable'; +export type PromptKind = 'conflict' | 'commandLineTools' | 'download' | 'downloadResult' | 'turnOffScope' | 'restoreScope' | 'unrecoverable' | 'settingsRenamed'; const TEST_MODE = process.env.MCPPLS_TEST === '1'; const SUBSTITUTION_GRACE_MS = 5000; diff --git a/editors/vscode/src/settingsMigration.ts b/editors/vscode/src/settingsMigration.ts new file mode 100644 index 00000000..a0bcee76 --- /dev/null +++ b/editors/vscode/src/settingsMigration.ts @@ -0,0 +1,50 @@ +// S-1 (plan 0.0.9): `mcppls.engine` and `mcppls.buildDiscovery` were renamed (see settingsRead.ts). +// Their old values keep working without anyone doing anything; this offers, once per old name, to move +// them -- and writes nothing unless the button is clicked. The extension never edits a person's +// settings.json on its own. + +import * as vscode from 'vscode'; +import { explicitValue, legacyNamesInUse, RenamedSetting, USER_LAYERS } from './settingsRead'; +import { notifyOnce } from './prompt'; + +const BUTTON = 'Update Settings'; +const NOTICE_KEY = 'settingsRenamed'; + +const TARGETS: Record<(typeof USER_LAYERS)[number], vscode.ConfigurationTarget> = { + workspaceFolderValue: vscode.ConfigurationTarget.WorkspaceFolder, + workspaceValue: vscode.ConfigurationTarget.Workspace, + globalValue: vscode.ConfigurationTarget.Global, +}; + +/** For every scope that has a value under the old name: the new name gets it, the old name is removed. */ +async function moveToNewName(renamed: RenamedSetting): Promise { + const configuration = vscode.workspace.getConfiguration('mcppls'); + const inspected = configuration.inspect(renamed.legacy); + for (const layer of USER_LAYERS) { + const value = inspected?.[layer]; + if (typeof value !== 'string') continue; // an object here is the new settings' parent, not an old value + // A value already under the new name in this scope is theirs and stays; only the old one goes. + if (configuration.inspect(renamed.current)?.[layer] === undefined) { + await configuration.update(renamed.current, value, TARGETS[layer]); + } + await configuration.update(renamed.legacy, undefined, TARGETS[layer]); + } +} + +/** + * Tells a person, once per old name (remembered in `globalState`), that a setting of theirs was renamed. + * Their value keeps working either way. `log` gets one line saying what was found. + */ +export async function offerSettingsMigration(context: vscode.ExtensionContext, log: (line: string) => void): Promise { + const configuration = vscode.workspace.getConfiguration('mcppls'); + for (const renamed of legacyNamesInUse(configuration)) { + const key = `${NOTICE_KEY}.${renamed.legacy}`; + if (context.globalState.get(key, false)) continue; + void context.globalState.update(key, true); + log(`mcppls.${renamed.legacy} is set (${JSON.stringify(explicitValue(configuration.inspect(renamed.legacy)))}); it is now mcppls.${renamed.current}, and the old name still works.`); + const choice = await notifyOnce('settingsRenamed', 'info', + `mcppls: the setting mcppls.${renamed.legacy} is now mcppls.${renamed.current}. Your value still applies; update your settings to the new name?`, + [BUTTON]); + if (choice === BUTTON) await moveToNewName(renamed); + } +} diff --git a/editors/vscode/src/settingsRead.ts b/editors/vscode/src/settingsRead.ts new file mode 100644 index 00000000..76febc56 --- /dev/null +++ b/editors/vscode/src/settingsRead.ts @@ -0,0 +1,138 @@ +// S-1, S-2 (plan 0.0.9): the settings the extension sends the server, and the two it renamed. +// +// `mcppls.engine` and `mcppls.buildDiscovery` were plain values and the parents of other settings +// (`engine.workers`, `buildDiscovery.providers`): VS Code drops a child's default when its parent is a +// scalar, so the settings UI showed `undefined` for them. They are `mcppls.engine.name` and +// `mcppls.buildDiscovery.mode` now, and package.json no longer contributes the old names. A person's +// own value under an old name keeps working here -- `resolveRenamed` reads it when the new name has +// none -- and `settingsMigration.ts` offers, once, to move it. +// +// Plain TypeScript (no `vscode` import), so test/unit runs it in Node. + +/** The fields of `WorkspaceConfiguration.inspect()` this reads. */ +export interface InspectedValue { + defaultValue?: unknown; + globalValue?: unknown; + workspaceValue?: unknown; + workspaceFolderValue?: unknown; +} + +/** What of a `WorkspaceConfiguration` the functions here use, so a test can stand in for it. */ +export interface SettingsReader { + get(section: string, defaultValue?: T): T | undefined; + inspect(section: string): InspectedValue | undefined; +} + +/** A setting that moved: `legacy` is the name package.json used to contribute, relative to `mcppls`. */ +export interface RenamedSetting { + readonly legacy: string; + readonly current: string; +} + +export const RENAMED_SETTINGS: readonly RenamedSetting[] = [ + { legacy: 'engine', current: 'engine.name' }, + { legacy: 'buildDiscovery', current: 'buildDiscovery.mode' }, +]; + +/** The scopes a person can have written a value in, narrowest first, as `inspect()` names them. */ +export const USER_LAYERS: readonly ('workspaceFolderValue' | 'workspaceValue' | 'globalValue')[] = [ + 'workspaceFolderValue', 'workspaceValue', 'globalValue', +]; + +/** The value a person set themselves: the narrowest scope that has one. Not the default. */ +export function explicitValue(inspected: InspectedValue | undefined): unknown { + for (const layer of USER_LAYERS) { + if (inspected?.[layer] !== undefined) return inspected[layer]; + } + return undefined; +} + +/** + * The value of a renamed setting: one set under the new name wins, else one set under the old name + * when it is a string, else `fallback`. VS Code does not validate the old name any more, so what is + * under it can be anything a person typed. + */ +export function resolveRenamed(reader: SettingsReader, renamed: RenamedSetting, fallback: string): string { + const current = explicitValue(reader.inspect(renamed.current)); + if (typeof current === 'string') return current; + // The narrowest scope that holds a string under the old name: a narrower one may hold the new settings' parent object + // (`engine.workers` set in the workspace, the old `engine` string in the user's settings). + const inspected = reader.inspect(renamed.legacy); + for (const layer of USER_LAYERS) { + const legacy = inspected?.[layer]; + if (typeof legacy === 'string') return legacy; + } + return fallback; +} + +/** The old names that still have a value of a person's own, for the one-time notice. */ +/** + * The old names that still have a value of a person's own, for the one-time notice. Only a string is an old value: + * the old name is now the parent of the new one and its siblings, so a person who set `engine.workers` alone reads + * back an object (`{ workers: "4" }`) under `engine`, which is no old setting and must not be moved. + */ +export function legacyNamesInUse(reader: SettingsReader): RenamedSetting[] { + return RENAMED_SETTINGS.filter((renamed) => USER_LAYERS.some((layer) => typeof reader.inspect(renamed.legacy)?.[layer] === 'string')); +} + +// build description design 4.4: a value this extension does not know must not turn the network on. +function buildToolSetting(value: string | undefined): string { + return value === 'online' || value === 'off' ? value : 'offline'; +} + +/** mcppls.buildDiscovery.providers' own default (config registry, settings section 9 T1): every provider. */ +export const BUILD_DISCOVERY_PROVIDERS = ['mcpp', 'cmake', 'xmake', 'meson', 'compile-commands']; + +const WORKERS = /^(auto|[1-9][0-9]?)?$/; + +/** `engine.workers` as the server takes it: `auto` or a whole number from 1 to 99; empty means `auto`. */ +export function workersSetting(value: string | undefined): string { + const trimmed = (value ?? '').trim(); + return trimmed.length > 0 && WORKERS.test(trimmed) ? trimmed : 'auto'; +} + +/** + * The `initializationOptions` of the language client: one entry per setting the server's registry + * marks `clientConfigurable` (test/unit/settingsRead.test.ts checks that against package.json). + * Dotted keys for the sub-settings, not nested objects -- the server accepts both (settings section 9 T1). + */ +export function buildInitializationOptions(configuration: SettingsReader, compiler: string): Record { + const get = (key: string, fallback: T): T => configuration.get(key, fallback) ?? fallback; + return { + compiler: compiler.length > 0 ? compiler : null, + semanticKit: configuration.get('semanticKit') === 'off' ? 'off' : 'auto', + // overall design 5.6: the core semantic engine; mcppls's own module engine always runs. + 'engine.name': resolveRenamed(configuration, RENAMED_SETTINGS[0], 'clangd') === 'none' ? 'none' : 'clangd', + // S-2 (plan 0.0.9): how many files clangd builds at once; a change restarts the server. + 'engine.workers': workersSetting(configuration.get('engine.workers')), + // build description design 4.4: how the user's build tool may be run. + buildTool: buildToolSetting(configuration.get('buildTool')), + // build description design 4.3: which environment it is run in. + toolEnvironment: configuration.get('toolEnvironment') === 'editor' ? 'editor' : 'auto', + // This extension finds other C/C++ language servers itself (mcppls.detectConflicts) + // and offers, once, to turn their language features off. Saying so keeps the server + // from also explaining it: a server cannot see its siblings through LSP, so it tells + // clients that arbitrate nothing -- which is every editor but this one. + conflictArbitration: 'client', + // Design 2026-09-25 section 7/12: `modules` is this setting; `moduleType` is fixed true + // because this extension always declares the custom `module` semantic token type + // (package.json contributes.semanticTokenTypes) with a `namespace` fallback for + // themes that do not colour it. + semanticTokens: { + modules: get('semanticTokens.modules', true), + moduleType: true, + }, + // Fix plan 2026-09-26 F9: a space after `import` opens the module list. The server + // advertises the space as a trigger character to this client unless this is off. + completion: { + triggerOnSpace: get('completion.triggerOnSpace', true), + }, + // 0.0.6 plan section 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. + 'buildDiscovery.mode': resolveRenamed(configuration, RENAMED_SETTINGS[1], 'auto') === 'off' ? 'off' : 'auto', + 'buildDiscovery.providers': get('buildDiscovery.providers', BUILD_DISCOVERY_PROVIDERS), + 'buildDiscovery.askBeforeDownload': get('buildDiscovery.askBeforeDownload', true), + // 0.0.6 plan section 2.6, 9 T5: implementation units opened in the background. + 'index.primeImplementationUnits': configuration.get('index.primeImplementationUnits') === 'off' ? 'off' : 'auto', + }; +} diff --git a/editors/vscode/src/status.ts b/editors/vscode/src/status.ts index abf83f94..dd62ccfb 100644 --- a/editors/vscode/src/status.ts +++ b/editors/vscode/src/status.ts @@ -2,6 +2,7 @@ // status item for C++ files, driven by the server's cxxModules/status notification. import * as vscode from 'vscode'; +import type { OnlineRun } from './downloadAsk'; import { offersCacheReset, RESET_CACHE_COMMAND } from './cacheReset'; import { TURN_ON_COMMAND } from './enable'; import { stateTexts } from './statusText'; @@ -56,6 +57,8 @@ export interface CxxModulesStatus { issues?: ModuleIssue[]; // Facts worth showing that reduce no feature, e.g. a Visual Studio without the std module (S3 4). notices?: ModuleIssue[]; + // D-5 (plan 0.0.9): how the last fetch the person asked for ended (downloadPrompt.ts tells it once). + onlineRun?: OnlineRun; } // What the status bar shows for each state. diff --git a/editors/vscode/test/runTest.ts b/editors/vscode/test/runTest.ts index 86b043f7..9d8dde21 100644 --- a/editors/vscode/test/runTest.ts +++ b/editors/vscode/test/runTest.ts @@ -104,16 +104,30 @@ function prepareWorkspace(): string { // "before activation" only has an unambiguous meaning if it is captured // before VS Code is even launched. +// Windows can still hold a file or directory for a moment after VS Code exits (EPERM, EBUSY: the 0.0.8 release run +// failed on `scandir ...\.vscode` with every test passed); a few retries see it released. +function retried(read: () => T): T { + for (let attempt = 0; ; ++attempt) { + try { + return read(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (attempt >= 10 || (code !== 'EPERM' && code !== 'EBUSY')) throw error; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); + } + } +} + function hashWorkspace(workspace: string): Map { const result = new Map(); const walk = (dir: string): void => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + for (const entry of retried(() => fs.readdirSync(dir, { withFileTypes: true }))) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); } else if (entry.isFile()) { const relative = path.relative(workspace, full).split(path.sep).join('/'); - result.set(relative, crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')); + result.set(relative, crypto.createHash('sha256').update(retried(() => fs.readFileSync(full))).digest('hex')); } } }; diff --git a/editors/vscode/test/suite/workspaceSwitch.test.ts b/editors/vscode/test/suite/workspaceSwitch.test.ts index a26f51ad..d4600f2b 100644 --- a/editors/vscode/test/suite/workspaceSwitch.test.ts +++ b/editors/vscode/test/suite/workspaceSwitch.test.ts @@ -35,7 +35,9 @@ suite('mcppls.enable switch', function () { suiteTeardown(async () => { await vscode.workspace.getConfiguration('mcppls').update('enable', undefined, vscode.ConfigurationTarget.Workspace); const workspace = process.env.MCPPLS_E2E_WORKSPACE; - if (workspace) fs.rmSync(path.join(workspace, '.vscode'), { recursive: true, force: true }); + // Windows can still hold the directory while VS Code writes its settings (ENOTEMPTY, EPERM on CI): Node retries + // those with maxRetries. + if (workspace) fs.rmSync(path.join(workspace, '.vscode'), { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); }); test('false in the workspace stops the server and says so; true starts it again', async () => { diff --git a/editors/vscode/test/unit/downloadAsk.test.ts b/editors/vscode/test/unit/downloadAsk.test.ts index 3712458f..6216d486 100644 --- a/editors/vscode/test/unit/downloadAsk.test.ts +++ b/editors/vscode/test/unit/downloadAsk.test.ts @@ -31,3 +31,32 @@ suite('the build description download offer', () => { assert.strictEqual(needsDownload({}), undefined); }); }); + +// D-4, D-5 (plan 0.0.9): downloads allowed for a workspace, and the outcome of a fetch told once. +import { downloadAction, onlineRunToTell } from '../../src/downloadAsk'; + +suite('downloads allowed in a workspace, and what a fetch came to', () => { + const issue = { code: 'producer-needs-download', message: 'xmake needs libtool downloaded', askOnline: true }; + + test('fetched without asking once allowed, asked otherwise, nothing after "Don\'t Ask Again"', () => { + assert.strictEqual(downloadAction(issue, false, true, [], false), 'fetch'); + assert.strictEqual(downloadAction(issue, false, false, [], false), 'ask'); + assert.strictEqual(downloadAction(issue, true, false, [], false), 'none'); + assert.strictEqual(downloadAction(issue, true, true, [], false), 'fetch', 'allowing is the newer answer'); + }); + + test('never for a run the server does not offer, a set of missing things already handled, or an open question', () => { + assert.strictEqual(downloadAction({ ...issue, askOnline: false }, false, true, [], false), 'none'); + assert.strictEqual(downloadAction(issue, false, true, [issue.message], false), 'none'); + assert.strictEqual(downloadAction(issue, false, true, [], true), 'none'); + assert.strictEqual(downloadAction(undefined, false, true, [], false), 'none'); + }); + + test('each fetch is told once, and only a known outcome', () => { + const run = { outcome: 'failed' as const, message: 'xmake could not install libtool', at: '2026-10-01T10:00:00Z' }; + assert.deepStrictEqual(onlineRunToTell({ onlineRun: run }, []), run); + assert.strictEqual(onlineRunToTell({ onlineRun: run }, [run.at]), undefined); + assert.strictEqual(onlineRunToTell({}, []), undefined); + assert.strictEqual(onlineRunToTell({ onlineRun: { ...run, outcome: 'other' as never } }, []), undefined); + }); +}); diff --git a/editors/vscode/test/unit/settingsRead.test.ts b/editors/vscode/test/unit/settingsRead.test.ts new file mode 100644 index 00000000..92e5ecac --- /dev/null +++ b/editors/vscode/test/unit/settingsRead.test.ts @@ -0,0 +1,86 @@ +// S-1, S-2 (plan 0.0.9) in plain Node: a renamed setting is read from its new name first, then from the +// old one, and the options sent to the server carry `engine.workers` under the names the server's +// registry knows. +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + buildInitializationOptions, InspectedValue, legacyNamesInUse, RENAMED_SETTINGS, resolveRenamed, SettingsReader, workersSetting, +} from '../../src/settingsRead'; + +/** A configuration with the given user values: `global` for the user's settings, `workspace` for the folder's. */ +function reader(global: Record = {}, workspace: Record = {}): SettingsReader { + const inspect = (key: string): InspectedValue => ({ globalValue: global[key], workspaceValue: workspace[key] }); + return { + get: (key: string, fallback?: T): T | undefined => (workspace[key] ?? global[key] ?? fallback) as T | undefined, + inspect, + }; +} + +const ENGINE = RENAMED_SETTINGS[0]; +const DISCOVERY = RENAMED_SETTINGS[1]; + +suite('S-1: the renamed settings', () => { + test('a value under the new name wins over the old name, at any scope', () => { + assert.strictEqual(resolveRenamed(reader({ 'engine.name': 'clangd', engine: 'none' }), ENGINE, 'clangd'), 'clangd'); + assert.strictEqual(resolveRenamed(reader({ engine: 'none' }, { 'engine.name': 'clangd' }), ENGINE, 'clangd'), 'clangd'); + }); + + test('the old name applies when the new one has no value of its own', () => { + assert.strictEqual(resolveRenamed(reader({ engine: 'none' }), ENGINE, 'clangd'), 'none'); + assert.strictEqual(resolveRenamed(reader({}, { buildDiscovery: 'off' }), DISCOVERY, 'auto'), 'off'); + }); + + test('an old value that is not a string is ignored, and nothing at all is the default', () => { + assert.strictEqual(resolveRenamed(reader({ engine: true }), ENGINE, 'clangd'), 'clangd'); + assert.strictEqual(resolveRenamed(reader(), DISCOVERY, 'auto'), 'auto'); + }); + + test('the notice is for an old name with a value of a person\'s own, and only that', () => { + assert.deepStrictEqual(legacyNamesInUse(reader()), []); + assert.deepStrictEqual(legacyNamesInUse(reader({ engine: 'none' }, { buildDiscovery: 'off' })), [ENGINE, DISCOVERY]); + }); + + test('a person who set only a new sub-setting is not told about a rename', () => { + // VS Code reads `engine` back as the parent object of `engine.workers` and `engine.name`. + assert.deepStrictEqual(legacyNamesInUse(reader({ engine: { workers: '4' } }, { buildDiscovery: { providers: ['cmake'] } })), []); + assert.strictEqual(resolveRenamed(reader({ engine: { workers: '4' } }), ENGINE, 'clangd'), 'clangd'); + // The old string in the user's settings still applies under a workspace that set only `engine.workers`. + assert.strictEqual(resolveRenamed(reader({ engine: 'none' }, { engine: { workers: '4' } }), ENGINE, 'clangd'), 'none'); + }); +}); + +suite('S-2: what the server is sent', () => { + test('engine.workers is sent, and only auto or 1 to 99 is', () => { + assert.strictEqual(buildInitializationOptions(reader({ 'engine.workers': '4' }), '')['engine.workers'], '4'); + assert.strictEqual(buildInitializationOptions(reader(), '')['engine.workers'], 'auto'); + for (const bad of ['0', '100', 'many', '-1', '4 ']) { + assert.strictEqual(workersSetting(bad), bad === '4 ' ? '4' : 'auto', bad); + } + assert.strictEqual(workersSetting(''), 'auto'); + assert.strictEqual(workersSetting(undefined), 'auto'); + }); + + test('the renamed settings are sent under their new names, whichever name a person used', () => { + const options = buildInitializationOptions(reader({ engine: 'none', buildDiscovery: 'off' }), ''); + assert.strictEqual(options['engine.name'], 'none'); + assert.strictEqual(options['buildDiscovery.mode'], 'off'); + assert.ok(!('engine' in options) && !('buildDiscovery' in options), 'the old names are not sent'); + }); + + test('every property of package.json that the server reads is in the options', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../package.json'), 'utf8')) as { + contributes: { configuration: { properties: Record } }; + }; + const options = buildInitializationOptions(reader(), ''); + const sent = (key: string): boolean => key in options + || (key.includes('.') && key.split('.')[0] in options && typeof options[key.split('.')[0]] === 'object'); + // Not sent because they act in the extension itself, or are read where they happen. + const extensionOnly = new Set(['enable', 'trace.server', 'ai.enabled', 'detectConflicts']); + const missing = Object.keys(manifest.contributes.configuration.properties) + .filter((name) => name.startsWith('mcppls.')) + .map((name) => name.slice('mcppls.'.length)) + .filter((key) => !extensionOnly.has(key) && !sent(key)); + assert.deepStrictEqual(missing, [], 'a setting in package.json the server is never told about'); + }); +}); diff --git a/editors/zed/extension.toml b/editors/zed/extension.toml index a3c7d1de..74f387a7 100644 --- a/editors/zed/extension.toml +++ b/editors/zed/extension.toml @@ -1,6 +1,6 @@ id = "mcppls" name = "C++ Modules Language Server" -version = "0.0.8" +version = "0.0.9" schema_version = 1 description = "mcppls - C++20/23 named modules that just work: navigation, completion, hover and diagnostics across modules for any compiler" repository = "https://github.com/Sunrisepeak/mcpp-language-server" diff --git a/mcpp.toml b/mcpp.toml index ddd90e5d..ae18770b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -34,7 +34,7 @@ libarchive = "3.8.7" [package] name = "mcpp-language-server" -version = "0.0.8" +version = "0.0.9" description = "Compiler-agnostic C++ modules language server" license = "Apache-2.0" authors = ["Sunrisepeak"] diff --git a/modules/base/src/version.cppm b/modules/base/src/version.cppm index 4ce9ca1a..0ad46fa2 100644 --- a/modules/base/src/version.cppm +++ b/modules/base/src/version.cppm @@ -9,7 +9,7 @@ export namespace mcppls::base { // checked against mcpp.toml (the one source) by `mcppls-devtools version --check`, not kept in step by // hand. Three constants that lived here and nothing read were removed rather than left to drift: // the S1 profile version is spec::PROFILE_VERSION, the kit manifest version is spec::KIT_VERSION. -inline constexpr std::string_view VERSION { "0.0.8" }; +inline constexpr std::string_view VERSION { "0.0.9" }; // The clangd the payload ships. Checked against packaging/payload.lock.json by the same command. inline constexpr std::string_view CLANGD_VERSION { "23.1.0" }; // The oldest mcpp that answers `mcpp emit build-database` — the `mcpp.build-database` kind, which diff --git a/modules/pack/src/payload.cpp b/modules/pack/src/payload.cpp index 190a907e..1ea8d376 100644 --- a/modules/pack/src/payload.cpp +++ b/modules/pack/src/payload.cpp @@ -138,7 +138,10 @@ nlohmann::json provenance(const std::string& root) { record["builtAt"] = std::format("{:%FT%TZ}", now); if (const std::string commit { git_output(root, { "rev-parse", "HEAD" }) }; !commit.empty()) { record["commit"] = commit; - record["dirty"] = !git_output(root, { "status", "--porcelain" }).empty(); + // R-2 (plan 0.0.9): whether a tracked file differs from the commit. Files the build itself leaves in the checkout + // (CI's `cross/` and `host-devtools`) are untracked and say nothing about the source: counting them marked every + // released payload dirty (0.0.8's included). + record["dirty"] = !git_output(root, { "status", "--porcelain", "--untracked-files=no" }).empty(); } return record; } diff --git a/modules/platform/src/process.cpp b/modules/platform/src/process.cpp index b54551c1..1a5ed609 100644 --- a/modules/platform/src/process.cpp +++ b/modules/platform/src/process.cpp @@ -541,6 +541,33 @@ std::optional parse_cpu_time(std::string_view text) { return total + days * 86400; } +std::optional process_alive(std::int64_t pid) { + if (pid <= 0) return std::nullopt; + if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { + // A pid with no /proc entry is gone; one whose state (the first field after the command) is Z or X has exited. + if (!fs::exists(std::format("/proc/{}", pid))) return false; + const auto stat = fs::read_file(std::format("/proc/{}/stat", pid)); + if (!stat) return std::nullopt; + const auto close = stat->rfind(')'); + if (close == std::string::npos || close + 2 >= stat->size()) return std::nullopt; + const char state { (*stat)[close + 2] }; + return state != 'Z' && state != 'X'; + } else if constexpr (mcppls::os::FAMILY == mcppls::os::Family::macos) { + SpawnOptions options; + options.program = "/bin/ps"; + options.arguments = { "-o", "state=", "-p", std::to_string(pid) }; + options.pipeInput = false; + auto ran = run(std::move(options), std::chrono::seconds { 2 }); + if (!ran || ran->timedOut) return std::nullopt; + // ps exits 1 when no process has the pid. + if (ran->exitCode != 0) return ran->exitCode == 1 ? std::optional { false } : std::nullopt; + const std::string_view state { base::trim(ran->output) }; + return !state.empty() && state.front() != 'Z'; + } else { + return std::nullopt; + } +} + std::optional cpu_seconds(std::int64_t pid) { if (pid <= 0) return std::nullopt; if constexpr (mcppls::os::FAMILY == mcppls::os::Family::linux) { diff --git a/modules/platform/src/process.cppm b/modules/platform/src/process.cppm index 33359a75..f65bed6d 100644 --- a/modules/platform/src/process.cppm +++ b/modules/platform/src/process.cppm @@ -113,6 +113,10 @@ std::string last_lines(std::string_view text, std::size_t lines); // platform can say: /proc on Linux, ps(1) on macOS. nullopt on Windows (openkal exposes no process // times and a handle is not a pid there) and whenever the process cannot be read. std::optional cpu_seconds(std::int64_t pid); +// Whether a process is running, where the platform can say: /proc on Linux, ps(1) on macOS; a process that +// has exited and not been reaped yet (a zombie) is not running. nullopt on Windows (a handle is not a pid +// there) and whenever the answer cannot be read -- which is not the same as "gone". +std::optional process_alive(std::int64_t pid); // ps(1)'s cumulative "time" column, "[[dd-]hh:]mm:ss[.ss]", in seconds. std::optional parse_cpu_time(std::string_view text); diff --git a/src/bin/conformance.cpp b/src/bin/conformance.cpp index 3ea189c5..e1db8333 100644 --- a/src/bin/conformance.cpp +++ b/src/bin/conformance.cpp @@ -80,6 +80,9 @@ struct Options { // under the real $HOME/%USERPROFILE%. Empty until `run()` reads the scenario; once set, every // process the runner starts for the server under test uses it as HOME (and USERPROFILE). std::string isolatedHome; + // A directory the scenario's `server-path-prepend` puts first on the server's PATH (a POSIX list), for the fixtures whose + // build tool is a script of their own (xmake-needs-download's xmake). Empty for every fixture that predates it. + std::string pathPrepend; // issue #23 fix plan F18: where `bundle` checks leave a copy of the bundle they checked, for CI to keep; empty: nowhere. std::string keepBundles; // 0.0.7 plan 6.4: a check with a "stage" runs only when `--stage` names it (a fixture's cold start, warm start, edits and @@ -94,14 +97,28 @@ struct Options { // is every fixture that predates it. void apply_isolated_home(std::vector& environment, const Options& options) { if (options.isolatedHome.empty()) return; + // XDG_CONFIG_HOME too: with it set, clangd and the server read their user configuration from there, not from /.config. const auto isHomeVariable = [](const std::string& entry) { - return entry.starts_with("HOME=") || entry.starts_with("USERPROFILE=") || entry.starts_with("HOMEDRIVE=") || entry.starts_with("HOMEPATH="); + return entry.starts_with("HOME=") || entry.starts_with("USERPROFILE=") || entry.starts_with("HOMEDRIVE=") || entry.starts_with("HOMEPATH=") + || entry.starts_with("XDG_CONFIG_HOME="); }; std::erase_if(environment, isHomeVariable); environment.push_back("HOME=" + options.isolatedHome); environment.push_back("USERPROFILE=" + options.isolatedHome); } +// Puts `options.pathPrepend` first on the PATH of a spawn's environment. +void apply_path_prepend(std::vector& environment, const Options& options) { + if (options.pathPrepend.empty()) return; + for (auto& entry : environment) { + if (entry.starts_with("PATH=")) { + entry = "PATH=" + options.pathPrepend + ":" + entry.substr(5); + return; + } + } + environment.push_back("PATH=" + options.pathPrepend); +} + // Whether this profile looks like a client with no `experimental.cxxModules` at all: no // `cxxModules/status` arrives, so status checks make no sense and standard `$/progress` is what // the run must prove instead (cold-start plan 4.1). True for `plain` and `zed` (Zed advertises no @@ -370,6 +387,7 @@ class Client { auto environment = mcppls::platform::env::variables(); environment.push_back("MCPPLS_CACHE_DIR=" + cacheDirectory); apply_isolated_home(environment, options); + apply_path_prepend(environment, options); spawn.environment = std::move(environment); const bool verbose { verbose_ }; auto inbox = inbox_; @@ -638,6 +656,7 @@ class McpClient { auto environment = mcppls::platform::env::variables(); environment.push_back("MCPPLS_CACHE_DIR=" + cacheDirectory); apply_isolated_home(environment, options); + apply_path_prepend(environment, options); spawn.environment = std::move(environment); const bool verbose { options.verbose }; auto inbox = inbox_; @@ -815,6 +834,128 @@ bool ends_with_path(std::string_view uri, std::string_view suffix) { Json position(const Json& at) { return Json { { "line", at.at(0) }, { "character", at.at(1) } }; } +// M-3 (plan 0.0.9): clangd itself, started by the runner with the arguments a check names and no server between it and the +// check. A workaround's canary asks it what the defect is, and a latency budget is held against what it alone does. It +// speaks only what those need: documents, diagnostics and requests. +class DirectClangd { +private: + std::unique_ptr connection_; + std::shared_ptr> inbox_ { std::make_shared>() }; + std::int64_t nextId_ { 1 }; + std::map versions_; + + void dispatch(const Json& message) { + switch (lsp::kind_of(message)) { + case lsp::Kind::request: (void)connection_->send(lsp::make_result(message["id"], Json(nullptr))); break; + case lsp::Kind::notification: + if (message.value("method", std::string {}) == "textDocument/publishDiagnostics") { + const std::string documentUri { message["params"].value("uri", std::string {}) }; + diagnostics[documentUri] = message["params"].value("diagnostics", Json::array()); + ++published[documentUri]; + lastPublished = Clock::now(); + } + break; + default: break; + } + } + +public: + std::map diagnostics; // uri -> latest diagnostics + std::map published; // uri -> publishes received + Clock::time_point lastPublished {}; + + ~DirectClangd() { stop(); } + + base::Result start(const std::string& program, std::vector arguments, const std::string& directory, + std::vector environment, std::chrono::seconds timeout) { + mcppls::platform::SpawnOptions spawn; + spawn.program = program; + spawn.arguments = std::move(arguments); + spawn.workDirectory = directory; + spawn.environment = std::move(environment); + auto inbox = inbox_; + auto connection = lsp::Connection::start(std::move(spawn), [inbox](Json message) { inbox->push(std::move(message)); }, [inbox] { inbox->close(); }); + if (!connection) return std::unexpected { connection.error() }; + connection_ = std::move(*connection); + const auto initialized = request("initialize", Json { { "processId", nullptr }, { "rootUri", base::path_to_uri(directory) }, + { "capabilities", Json { { "textDocument", Json { { "publishDiagnostics", Json::object() } } } } } }, timeout); + if (!initialized) return base::fail("clangd-silent", "clangd did not answer initialize"); + (void)connection_->send(lsp::make_notification("initialized", Json::object())); + return {}; + } + + void stop() { + if (!connection_) return; + connection_->stop(std::chrono::milliseconds { 2000 }); + connection_.reset(); + } + + void open(const std::string& path, const std::string& text) { + versions_[path] = 1; + (void)connection_->send(lsp::make_notification("textDocument/didOpen", + Json { { "textDocument", Json { { "uri", base::path_to_uri(path) }, { "languageId", "cpp" }, { "version", 1 }, { "text", text } } } })); + } + + void change(const std::string& path, const std::string& text) { + (void)connection_->send(lsp::make_notification("textDocument/didChange", + Json { { "textDocument", Json { { "uri", base::path_to_uri(path) }, { "version", ++versions_[path] } } }, + { "contentChanges", Json::array({ Json { { "text", text } } }) } })); + } + + // Reads what clangd sends until `deadline`. + void pump_until(Clock::time_point deadline) { + while (Clock::now() < deadline) { + if (auto message = inbox_->pop_until(deadline)) dispatch(*message); + else if (inbox_->closed() && inbox_->size() == 0) return; + } + } + + bool alive() const { return connection_ != nullptr && !inbox_->closed(); } + + // The request's result; nullptr for an error answer, nothing when none came in time. + std::optional request(std::string_view method, Json params, std::chrono::seconds timeout) { + const std::int64_t id { nextId_++ }; + (void)connection_->send(lsp::make_request(id, method, std::move(params))); + const auto deadline = Clock::now() + timeout; + while (Clock::now() < deadline) { + auto message = inbox_->pop_until(deadline); + if (!message) { + if (inbox_->closed() && inbox_->size() == 0) break; + continue; + } + if (lsp::kind_of(*message) == lsp::Kind::response && (*message)["id"] == Json(id)) { + if (message->contains("error")) return Json(nullptr); + return message->value("result", Json {}); + } + dispatch(*message); + } + return std::nullopt; + } + + // The file's diagnostics once clangd has gone quiet: the first publication, then no other for `quiet`. A file's + // clang-tidy diagnostics may arrive in a publication of their own, after the compiler's. + bool settle(const std::string& path, std::chrono::seconds timeout, std::chrono::milliseconds quiet) { + const std::string documentUri { base::path_to_uri(path) }; + const auto deadline = Clock::now() + timeout; + while (published[documentUri] == 0 && Clock::now() < deadline && alive()) pump_until(std::min(deadline, Clock::now() + std::chrono::milliseconds { 200 })); + if (published[documentUri] == 0) return false; + while (Clock::now() < deadline && Clock::now() - lastPublished < quiet) pump_until(std::min(deadline, lastPublished + quiet)); + return true; + } + + // The lines (0-based) of the file's diagnostics with `code`, sorted. + std::vector lines_with(const std::string& path, std::string_view code) { + std::vector lines; + for (const auto& diagnostic : diagnostics[base::path_to_uri(path)]) { + if (diagnostic.value("code", Json {}) != Json(std::string { code })) continue; + const Json* start { lsp::find_path(diagnostic, { "range", "start" }) }; + lines.push_back(start == nullptr ? -1 : start->value("line", -1)); + } + std::ranges::sort(lines); + return lines; + } +}; + // ---- stress: seeded random use, per method answered/empty/timeout/error and latency ---------- // Every file under a directory, relative to it, '/'-separated: what a fixture's own glob @@ -1929,6 +2070,197 @@ class Scenario { return finish_measure(std::move(failures), std::move(summary), std::format("{}, {} round(s): {}", phase, rounds, base::join(brief, "; "))); } + // ---- clangd on its own (M-3, M-2 of plan 0.0.9) ---- + + // The clangd of the run: --clangd, else the payload's. + std::string clangd_program() const { + return !options_.clangd.empty() ? options_.clangd : base::join_path(options_.payload, "clangd/bin/clangd") + std::string { mcppls::os::EXECUTABLE_SUFFIX }; + } + + // A check's clangd arguments, `{workspace}` and `{engine-database}` (the directory of the compile_commands.json the server + // wrote for its own clangd, so a baseline runs the very commands the server's does) replaced. + std::vector direct_arguments(const Json& list) { + std::string database; + std::vector arguments; + for (const auto& item : list) { + std::string argument { base::replace_all(item.get(), "{workspace}", workspace_) }; + if (argument.contains("{engine-database}")) { + if (database.empty()) { + const Json root = root_report(); + if (root.is_object()) database = base::join_path(root.value("cacheDirectory", std::string {}), "contexts/default/cdb"); + } + argument = base::replace_all(argument, "{engine-database}", database); + } + arguments.push_back(std::move(argument)); + } + return arguments; + } + + std::vector direct_environment() const { + auto environment = mcppls::platform::env::variables(); + apply_isolated_home(environment, options_); + return environment; + } + + // One session of clangd alone on `file`, started with `arguments`: its completions at `at` timed in milliseconds, after + // `warmup` that are not. With "edit", each is asked right after the buffer changed (a comment appended), as typing does. + // Nothing when clangd could not be started or never published the file's diagnostics, with the reason in `why`. + std::optional> direct_completions(const Json& check, const std::string& file, const Json& arguments, int rounds, int warmup, + std::string& why) { + const std::string path { base::join_path(workspace_, file) }; + const std::string original { text_of(file) }; + DirectClangd clangd; + if (auto started = clangd.start(clangd_program(), direct_arguments(arguments), workspace_, direct_environment(), std::chrono::seconds { 30 }); !started) { + why = started.error().message; + return std::nullopt; + } + clangd.open(path, original); + if (!clangd.settle(path, timeout_, std::chrono::milliseconds { 0 })) { + why = "clangd never published the diagnostics of " + file; + return std::nullopt; + } + const bool edit { check.value("edit", false) }; + const std::chrono::milliseconds interval { check.value("interval-ms", 0) }; + const Json params { { "textDocument", Json { { "uri", base::path_to_uri(path) } } }, { "position", position(check.at("at")) } }; + std::vector milliseconds; + for (int round { 0 }; round < rounds + warmup; ++round) { + if (edit) clangd.change(path, original + std::format("// edit {}\n", round)); + const auto started { Clock::now() }; + const auto answer = clangd.request("textDocument/completion", params, std::chrono::seconds { 60 }); + if (!answer) { + why = "clangd did not answer a completion in 60 s"; + return std::nullopt; + } + if (round >= warmup) milliseconds.push_back(std::chrono::duration(Clock::now() - started).count()); + if (interval.count() > 0) clangd.pump_until(Clock::now() + interval); + client_.drain(std::chrono::milliseconds { 0 }); + } + return milliseconds; + } + + static double median_of(std::vector samples) { + std::ranges::sort(samples); + return percentile(std::move(samples), 0.5); + } + + // "clangd-lsp": the runner's clangd driven over LSP with the arguments the check names, the server not involved. + // "diagnostics": `file` is opened and, once clangd's diagnostics settle, `code` is on exactly the 0-based `lines` + // (none for an empty list), or, with "includes", on at least those. + // "completion-ratio": the median completion at `at` with "arguments" over the median with "baseline-arguments", over + // "passes" (default 2) alternating sessions of "rounds" (default 10) each; the check holds when the ratio is over + // "min-ratio". + // A canary holds while the defect its workaround exists for is there; `says` is what a failure means, as in clangd-check. + std::pair run_clangd_lsp(const Json& check, const std::string& file) { + if (!fs::is_regular_file(clangd_program())) return { false, "no clangd: pass --clangd or --payload" }; + const std::string says { check.value("says", std::string {}) }; + const std::string action { check.value("action", std::string {}) }; + const auto failed = [&](const std::string& detail) { + return std::pair { false, says.empty() ? detail : std::format("{} ({})", says, detail) }; + }; + const Json arguments = check.value("arguments", Json::array()); + if (action == "diagnostics") { + const std::string path { base::join_path(workspace_, file) }; + DirectClangd clangd; + if (auto started = clangd.start(clangd_program(), direct_arguments(arguments), workspace_, direct_environment(), std::chrono::seconds { 30 }); !started) { + return { false, started.error().message }; + } + clangd.open(path, text_of(file)); + const std::chrono::seconds within { check.value("seconds", 60) }; + if (!clangd.settle(path, within, std::chrono::milliseconds { check.value("quiet-ms", 3000) })) { + return { false, std::format("clangd published no diagnostics of {} in {} s", file, within.count()) }; + } + const std::string code { check.value("code", std::string {}) }; + const std::vector lines { clangd.lines_with(path, code) }; + std::vector wanted { check.value("lines", std::vector {}) }; + std::ranges::sort(wanted); + const bool held { check.value("includes", false) ? std::ranges::includes(lines, wanted) : lines == wanted }; + const std::string detail { std::format("{} on lines {}", code, lsp::dump(lines)) }; + return held ? std::pair { true, detail + ", as expected" } : failed(detail); + } + if (action == "completion-ratio") { + const int rounds { check.value("rounds", 10) }; + const int passes { std::max(1, check.value("passes", 2)) }; + const double minimum { check.value("min-ratio", 1.8) }; + std::vector with, without; + std::string why; + for (int pass { 0 }; pass < passes; ++pass) { + for (const bool baseline : { false, true }) { + auto samples = direct_completions(check, file, check.value(baseline ? "baseline-arguments" : "arguments", Json::array()), rounds, 2, why); + if (!samples) return { false, why }; + auto& into = baseline ? without : with; + into.insert(into.end(), samples->begin(), samples->end()); + } + } + const double ratio { median_of(without) > 0 ? median_of(with) / median_of(without) : 0.0 }; + measure_ = Json { { "with", latency_stats(with) }, { "without", latency_stats(without) }, { "ratio", ratio } }; + const std::string detail { std::format("completion median {:.0f} ms with the arguments, {:.0f} ms without: {:.2f}x", median_of(with), median_of(without), ratio) }; + return ratio > minimum ? std::pair { true, detail + std::format(", over {:.2f} as expected", minimum) } : failed(detail); + } + return { false, std::format("unknown clangd-lsp action '{}'", action) }; + } + + // "completion-baseline" (0.0.9 plan M-2): completion through the server held to what clangd alone does on the same file. + // The server is let to settle first; then clangd alone (with "baseline-arguments", of which `{engine-database}` is the + // server's own compile_commands.json) is timed over "rounds" completions at `at`, each right after an edit, and then + // the server over the same. Budget: "ratio" and "slack-ms": the server's p95 is at most ratio x clangd's p95 + slack; + // "engineShare" is the share of the server's answers that clangd gave, since an answer without it is fast and no use. + std::pair run_completion_baseline(const Json& check, const std::string& file) { + if (!fs::is_regular_file(clangd_program())) return { false, "no clangd: pass --clangd or --payload" }; + const int rounds { check.value("rounds", 40) }; + const std::chrono::seconds startWithin { check.value("start-within", 240) }; + open(file); + if (!settle(startWithin)) return { false, "the project did not settle: clangd never went quiet" }; + // The server's first completion of a file waits for the file's preamble: not what is measured. + const Json params { { "textDocument", Json { { "uri", uri(file) } } }, { "position", position(check.at("at")) } }; + const std::string original { text_of(file) }; + const std::chrono::milliseconds interval { check.value("interval-ms", 0) }; + const int warmup { 3 }; + std::string why; + auto baseline = direct_completions(check, file, check.value("baseline-arguments", Json::array()), rounds, warmup, why); + if (!baseline) return { false, why }; + const Json before = root_report(); + std::vector served; + int empty { 0 }; + for (int round { 0 }; round < rounds + warmup; ++round) { + change(file, original + std::format("// edit {}\n", round)); + const auto started { Clock::now() }; + const auto outcome { client_.request_full("textDocument/completion", params, std::chrono::seconds { 60 }) }; + if (outcome.timedOut) return { false, "the server did not answer a completion in 60 s" }; + if (round < warmup) continue; + served.push_back(std::chrono::duration(Clock::now() - started).count()); + if (completion_labels(outcome.result).empty()) ++empty; + client_.pump_until(Clock::now() + interval); + } + change(file, original); + const Json after = root_report(); + const double ratio { budget_number(check, "ratio").value_or(1.3) }; + const double slack { budget_number(check, "slackMs").value_or(30.0) }; + std::ranges::sort(*baseline); + std::ranges::sort(served); + const double clangdP95 { percentile(*baseline, 0.95) }; + const double serverP95 { percentile(served, 0.95) }; + const double limit { ratio * clangdP95 + slack }; + std::vector failures; + if (serverP95 > limit) failures.push_back(std::format("completion p95 {:.0f} ms through the server is over {:.0f} ms (clangd alone {:.0f} ms x {:.2f} + {:.0f})", serverP95, limit, clangdP95, ratio, slack)); + if (empty > 0 && budget_number(check, "maxEmpty") && empty > *budget_number(check, "maxEmpty")) failures.push_back(std::format("{} of {} completions answered empty", empty, served.size())); + Json detail; + const auto share = engine_share(before, after, "textDocument/completion", &detail); + enforce_min(check, "engineShare", share, "engine share of completion", failures); + const Json requests { { "clangd", Json { { "p50Ms", percentile(*baseline, 0.5) }, { "p95Ms", clangdP95 }, { "maxMs", baseline->back() } } }, + { "server", Json { { "p50Ms", percentile(served, 0.5) }, { "p95Ms", serverP95 }, { "maxMs", served.back() } } }, + { "limitMs", limit }, { "engineShare", share ? Json(*share) : Json(nullptr) }, { "answeredBy", detail } }; + // What the report says of where the server's time went (M-1): the engine's share and the server's own. + if (after.is_object()) { + if (const Json* stats { lsp::find_path(after, { "requests", "textDocument/completion" }) }; stats != nullptr) measure_ = Json { { "comparison", requests }, { "report", *stats } }; + } + if (!measure_.is_object()) measure_ = Json { { "comparison", requests } }; + measure_["load"] = load_average(); + const std::string brief { std::format("completion p50/p95 {:.0f}/{:.0f} ms through the server, {:.0f}/{:.0f} ms clangd alone, limit {:.0f} ms{}", percentile(served, 0.5), serverP95, + percentile(*baseline, 0.5), clangdP95, limit, share ? std::format(", {:.0f}% by clangd", *share * 100.0) : std::string {}) }; + if (failures.empty()) return { true, brief }; + return { false, std::format("{}; {}", base::join(failures, "; "), brief) }; + } + // ---- typing ---- // Whether a diagnostic of the file's latest publication names `needle`. @@ -3042,6 +3374,13 @@ class Scenario { return notice.value("code", std::string {}) == noticeCode->get(); }); } + // D-5 (plan 0.0.9): how the last fetch asked for ended (S3 onlineRun), and a part of its message. + if (auto outcome = check.find("online-run"); outcome != check.end()) { + const Json run = snapshot.value("onlineRun", Json::object()); + matched = matched && run.is_object() && run.value("outcome", std::string {}) == outcome->get() + && run.value("message", std::string {}).contains(check.value("online-run-message", std::string {})) + && !run.value("at", std::string {}).empty(); + } return matched; }; (void)client_.wait_for([&] { return settled(current()); }, timeout_); @@ -3122,6 +3461,7 @@ class Scenario { auto environment = mcppls::platform::env::variables(); environment.push_back("MCPPLS_CACHE_DIR=" + cacheDirectory_); apply_isolated_home(environment, options_); + apply_path_prepend(environment, options_); spawn.environment = std::move(environment); auto running = std::async(std::launch::async, [spawn, timeout = timeout_]() mutable { return mcppls::platform::run(std::move(spawn), timeout); }); while (running.wait_for(std::chrono::milliseconds { 200 }) != std::future_status::ready) client_.drain(std::chrono::milliseconds { 0 }); @@ -3189,8 +3529,7 @@ class Scenario { // import-hang plan §9, a workaround's canary: the runner's own clangd (--clangd, else the payload's) is run with // --check on `file`; `expect` is "hangs" (it has not finished after `seconds`, default 10) or "finishes". A canary // expects the defect its workaround exists for; once an update of clangd fixes it, the check fails with `says`. - const std::string clangd { !options_.clangd.empty() ? options_.clangd - : base::join_path(options_.payload, "clangd/bin/clangd") + std::string { mcppls::os::EXECUTABLE_SUFFIX } }; + const std::string clangd { clangd_program() }; if (!fs::is_regular_file(clangd)) return { false, "no clangd: pass --clangd or --payload" }; mcppls::platform::SpawnOptions spawn; spawn.program = clangd; @@ -3419,7 +3758,9 @@ class Scenario { // registered (--no-dynamic-watch) the server's own polling has to notice it. const int type { existed ? 2 : 1 }; const std::string canonical { fs::canonical_path(path) }; - if (client_.watches(path, type) || client_.watches(canonical, type)) { + // "notify": true is an editor's own watcher of build files (the VS Code client's), which reports the write whether or not + // the server registered anything: an inferred model, kept while a build tool cannot answer, registers nothing. + if (check.value("notify", false) || client_.watches(path, type) || client_.watches(canonical, type)) { client_.notify("workspace/didChangeWatchedFiles", Json { { "changes", Json::array({ Json { { "uri", base::path_to_uri(path) }, { "type", type } } }) } }); } // G-5 (plan 2026-09-30): "expect-reload": false is a change that must NOT load the model again (an edit that @@ -3805,6 +4146,8 @@ class Scenario { }); return { ok, result.is_object() ? lsp::dump(result).substr(0, 160) : std::string { "no response" } }; } + if (kind == "clangd-lsp") return run_clangd_lsp(check, file); + if (kind == "completion-baseline") return run_completion_baseline(check, file); if (kind == "latency") return run_latency(check); if (kind == "typing") return run_typing(check, file); if (kind == "edit-save") return run_edit_save(check); @@ -4000,6 +4343,9 @@ int run(Options options) { options.isolatedHome = isolatedHome; } Expansion expansion { workspace, base::parent_path(self), options.payload, self, isolatedHome }; + if (const auto prepend = scenario.find("server-path-prepend"); prepend != scenario.end() && prepend->is_string()) { + options.pathPrepend = expand(prepend->get(), expansion); + } std::optional> prepareEnvironment; if (scenario.value("prepare-environment", std::string {}) == "msvc") { if (options.msvcEnvironment.empty()) { @@ -4817,7 +5163,40 @@ int prepare_delayed_producer(const std::string& seconds) { return 0; } +// M-2, M-3 (plan 0.0.9): a header that costs as much to preprocess as a big library's, in src/heavy/ (`#include "heavy/all.hpp"` +// from any file in src/): (default 150) headers of 60 class templates, variable templates, functions and macros +// each, which all.hpp includes after some of the standard library's own. Nothing in them needs a network or a licence, and a +// file that includes them is parsed, and its modules scanned (WA-CLANGD-009), as the file of a project that uses such a library. +int prepare_heavy_headers(const std::string& argument) { + const int count { argument.empty() ? 150 : std::max(1, std::atoi(argument.c_str())) }; + const std::string directory { base::join_path(fs::current_directory(), "src/heavy") }; + (void)fs::create_directories(directory); + std::string all { "#pragma once\n#include \n#include \n#include \n#include \n#include \n#include \n" }; + for (int unit { 0 }; unit < count; ++unit) { + const std::string name { std::format("h{:03}", unit) }; + std::string text { std::format("#pragma once\n#include \n#include \nnamespace heavy::n{:03} {{\n", unit) }; + for (int member { 0 }; member < 60; ++member) { + text += std::format("template struct Box{0} {{ T items[N]; constexpr std::size_t size() const {{ return N; }} }};\n", member, member + 1); + text += std::format("template constexpr bool is_box{0}_v = std::is_class_v>;\n", member); + text += std::format("inline int fn{0}(int x) {{ return x * {0} + {1}; }}\n", member, unit); + text += std::format("#define HEAVY_{1:03}_{0}(a, b) ((a) + (b) * {0})\n", member, unit); + } + text += "}\n"; + if (auto written = fs::write_file(base::join_path(directory, name + ".hpp"), text); !written) { + say("prepare: {}", written.error().message); + return 1; + } + all += std::format("#include \"heavy/{}.hpp\"\n", name); + } + if (auto written = fs::write_file(base::join_path(directory, "all.hpp"), all); !written) { + say("prepare: {}", written.error().message); + return 1; + } + return 0; +} + int prepare(const std::string& kind, const std::string& argument) { + if (kind == "heavy-headers") return prepare_heavy_headers(argument); if (kind == "s1-two-sets") return prepare_s1_two_sets(argument); if (kind == "payload-corrupt") return prepare_payload_corrupt(argument); if (kind == "producer-candidate") return prepare_producer_candidate(argument); @@ -4833,7 +5212,7 @@ int prepare(const std::string& kind, const std::string& argument) { if (kind == "xmake-stale-compdb") return prepare_marked_compdb(kind, argument.empty() ? std::string { "g++" } : argument, "-DSTALE_COMPDB", "-std=c++17"); if (kind == "compdb-midwrite") return prepare_marked_compdb(kind, argument.empty() ? std::string { "clang++" } : argument, "-DVERSION_ONE", "-std=c++23"); if (kind == "compdb-lto-msvc") return prepare_compdb_lto_msvc(argument); - say("prepare: unknown fixture kind {} (s1-two-sets, payload-corrupt, producer-candidate, delayed-producer, failure-at-base, compdb-clang-cl-std, compdb-clangxx-msvc-std, generated-module-old-mcpp, clangd-cannot-load, compdb-lto-msvc, clangd-crash-context, compdb-rejected-command, compdb-mixed-standards, xmake-stale-compdb, compdb-midwrite)", kind); + say("prepare: unknown fixture kind {} (heavy-headers, s1-two-sets, payload-corrupt, producer-candidate, delayed-producer, failure-at-base, compdb-clang-cl-std, compdb-clangxx-msvc-std, generated-module-old-mcpp, clangd-cannot-load, compdb-lto-msvc, clangd-crash-context, compdb-rejected-command, compdb-mixed-standards, xmake-stale-compdb, compdb-midwrite)", kind); return 2; } diff --git a/src/cli/options.cpp b/src/cli/options.cpp index a9552388..7e4a4658 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -66,14 +66,14 @@ orchestrator::SessionOptions session_options(const cmdline::ParsedArgs& args) { 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.engine = settings.string_value("engine.name"); options.engineFactories = engine_factories; 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.buildDiscovery = settings.string_value("buildDiscovery.mode"); options.buildDiscoveryProviders = settings.list_value("buildDiscovery.providers"); options.buildDiscoveryAskBeforeDownload = settings.bool_value("buildDiscovery.askBeforeDownload"); options.primeImplementationUnits = settings.string_value("index.primeImplementationUnits"); diff --git a/src/config/settings.cpp b/src/config/settings.cpp index d436b7c5..a392cbc1 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -46,10 +46,10 @@ const std::vector& shipped_registry() { .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).", + "still detected and its own generated files are still read (see `buildDiscovery.mode` for turning that off too).", .summaryZh = "项目构建工具的运行方式。`offline`:不联网运行——如果构建工具因此无法在不下载东西的情况下描述构建,状态栏会说明缺什么," "并提议在终端里运行它。`online`:允许联网,超时时间从一分钟延长到十分钟。`off`:从不运行构建工具;仍会探测构建系统、" - "仍读取它已有的产物(要连探测也关掉,见 `buildDiscovery`)。", + "仍读取它已有的产物(要连探测也关掉,见 `buildDiscovery.mode`)。", .clientConfigurable = true, }, Setting { @@ -76,8 +76,8 @@ const std::vector& shipped_registry() { 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`。", + .summary = "Run no build tool and no compiler; an untrusted workspace is also read as though `buildDiscovery.mode` were `off`.", + .summaryZh = "不运行任何构建工具,也不运行编译器;一个不受信任的工作区也等同于 `buildDiscovery.mode` 为 `off`。", }, Setting { .key = "discoverCompilers", .kind = Kind::boolean, .defaultValue = "true", .commandLine = "--no-discover", @@ -87,7 +87,7 @@ const std::vector& shipped_registry() { .summaryZh = "为构建描述没有覆盖到的源码在本机查找编译器。关闭后,这类源码改用语义工具包。", }, Setting { - .key = "buildDiscovery", .kind = Kind::enumeration, .values = { "auto", "off" }, .defaultValue = "auto", + .key = "buildDiscovery.mode", .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. `off`: nothing is read or run " @@ -95,6 +95,7 @@ const std::vector& shipped_registry() { "whether a detected build tool may be *run*; this governs whether it is looked for in the first place.", .summaryZh = "是否探测项目的构建系统。`off`:不隐式读取或执行任何东西——只用明确配置的 `database`,否则" "扫描源码。`buildTool` 管的是探测到的构建工具能不能*执行*;这个开关管的是要不要去探测它。", + .aliases = { "buildDiscovery" }, .clientConfigurable = true, }, Setting { @@ -102,9 +103,9 @@ const std::vector& shipped_registry() { .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 " + .summary = "Which build system providers `buildDiscovery.mode` 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 构建目录," + .summaryZh = "`buildDiscovery.mode` 可以使用哪些构建系统提供者;从中去掉某个提供者即停用它的探测(例如只想用已有的 CMake 构建目录," "不要 xmake)。", .clientConfigurable = true, }, @@ -118,11 +119,12 @@ const std::vector& shipped_registry() { }, // ---- Engines ------------------------------------------------------------------------ Setting { - .key = "engine", .kind = Kind::enumeration, .values = { "clangd", "none" }, .defaultValue = "clangd", + .key = "engine.name", .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` 表示只提供模块相关功能。", + .aliases = { "engine" }, .clientConfigurable = true, }, Setting { @@ -423,8 +425,10 @@ const Json& unwrap_mcppls(const Json& 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; + // An alias that names a parent of other rows (`engine`, `buildDiscovery`) is a JSON object in the + // nested form, `{"engine": {"workers": "4"}}`; that is those rows' value, never this one's (S-1, plan 0.0.9). for (const auto& alias : row.aliases) { - if (const Json* found = find_dotted_or_nested(scope, alias)) return found; + if (const Json* found = find_dotted_or_nested(scope, alias); found != nullptr && !found->is_object()) return found; } return nullptr; } @@ -592,6 +596,8 @@ void Settings::apply_initialization_options(const Json& initializationOptionsOrP if (values_[row.key].origin == Origin::commandLine) continue; const Json* found { find_setting_json(scope, row) }; if (found == nullptr) continue; + // S-3 (plan 0.0.9): null is "not set", the way a client says it has no value for a key. + if (found->is_null()) 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 {}", @@ -616,7 +622,9 @@ ChangeResult Settings::apply_configuration_change(const Json& params) { 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) }; + // S-3 (plan 0.0.9): a client that clears a setting sends null; the key goes back to its default. + const bool cleared { found->is_null() }; + auto text { cleared ? std::optional { row.defaultValue } : 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) }); @@ -625,7 +633,7 @@ ChangeResult Settings::apply_configuration_change(const Json& params) { 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 }; + it->second = { newValue, cleared ? Origin::defaulted : Origin::clientUpdated }; if (!changed) continue; result.changedKeys.push_back(row.key); if (row.applies == Applies::restart) result.restartKeys.push_back(row.key); diff --git a/src/engine/clangd.cpp b/src/engine/clangd.cpp index 69371a5b..fe1c9169 100644 --- a/src/engine/clangd.cpp +++ b/src/engine/clangd.cpp @@ -43,11 +43,21 @@ EngineTraits traits_for_version(std::string_view version, std::span 0 || !plan.stubModules.empty()) return true; + if (std::ranges::any_of(plan.issues, [](const normalize::PlanIssue& issue) { return !issue.module.empty(); })) return true; + return std::ranges::any_of(plan.entries, [](const normalize::EngineEntry& entry) { + return !entry.provides.empty() || !entry.module.empty() || !entry.imports.empty(); + }); +} + bool is_interactive(std::string_view method) { static constexpr std::array INTERACTIVE { "textDocument/definition", "textDocument/declaration", "textDocument/hover", "textDocument/completion", "textDocument/signatureHelp", "textDocument/documentHighlight", "textDocument/typeDefinition", @@ -94,6 +104,11 @@ class ClangdEngine final : public Engine { }; std::string databaseDirectory_; + // WA-CLANGD-009: whether the clangd started next gets --experimental-modules-support. On, unless the last session + // found that the project uses no modules (modules_verdict_path_), until the first plan says; then on for good once + // a plan uses modules, so a project is not switched back and forth while modules come and go. + bool modulesSupport_ { true }; + std::string modulesVerdict_; // what modules_verdict_path_ holds, as last written std::string primeDirectory_; std::string moduleHintDirectory_; std::string stubDirectory_; // stand-ins for modules nothing usable provides (robustness design C2) @@ -339,6 +354,11 @@ class ClangdEngine final : public Engine { // cannot read clangd's CPU does not wake the loop again and again for the same request. std::optional stuckTriedFor_; bool stuckAtCap_ { false }; + // P-3 (plan 0.0.9): a clangd restart_ let go of is being stopped off the event loop, and the next one starts + // once it is gone ("reaped"). + bool reaping_ { false }; + bool startWhenReaped_ { false }; + std::jthread reaper_; // joined where nothing may outlive the old clangd: a cache cleared, the engine shut down bool cpuReadInFlight_ { false }; // a reading of clangd's CPU is on its way back (read_cpu_) std::jthread cpuReading_; // the thread taking it; joined when the engine goes, at most the ps(1) bound later // a stuck clangd was found at the restart cap, and that was said // robustness design O1, O3: for a report of a problem. @@ -612,6 +632,13 @@ class ClangdEngine final : public Engine { moduleHintDirectory_ = base::join_path(cache, "contexts/default/module-hints"); // never created stubDirectory_ = base::join_path(cache, "contexts/default/stubs"); (void)platform::fs::create_directories(databaseDirectory_); + // WA-CLANGD-009: the last session's verdict, so a project without modules starts clangd without the flag and + // its first plan does not restart clangd. A cache without one starts with it, as every version before did. + if (traits_.scansModulesOnEveryRequest) { + const auto verdict = platform::fs::read_file(modules_verdict_path_()); + modulesSupport_ = !(verdict && base::trim(*verdict) == "off"); + if (verdict) modulesVerdict_ = std::string { base::trim(*verdict) }; + } // clangd starts without a database; the first plan is written before any document reaches it. platform::fs::remove_all(base::join_path(databaseDirectory_, "compile_commands.json")); log::info("clangd {} at {}", options_.version.empty() ? "?" : options_.version, options_.executable.empty() ? "(none)" : options_.executable); @@ -634,6 +661,7 @@ class ClangdEngine final : public Engine { void shut_down() override { if (process_ && process_->running() && handshakeDone_) (void)send_(lsp::make_notification("exit", nullptr)); if (process_) process_->stop(std::chrono::seconds { 2 }); + if (reaper_.joinable()) reaper_.join(); // P-3: a clangd being let go of is gone before the server is } void configure_plan(normalize::PlanInput& input) const override { @@ -763,7 +791,16 @@ class ClangdEngine final : public Engine { writtenArguments_ = std::move(newArguments); for (auto& [key, held] : held_) held.planned = true; (void)structureChanged; - const bool restartNeeded { (providerLeft || argumentsChanged) && planApplied_ && handshakeDone_ }; + // WA-CLANGD-009: with its modules support clangd scans a file's module dependencies again for every completion + // (UP-23: 82 ms -> 254 ms at the median, 869 ms at worst, on vulkan-hpp in issue #37), so a project that uses no + // modules gets a clangd without it. It goes off only with the first plan, before clangd was given a document, so + // the restart costs nothing; it comes back, for the rest of the session, with the first plan that uses modules + // (an import typed into a file of a project that had none). The verdict is kept for the next session. + const bool usesModules { plan_uses_modules(*plan) }; + const bool modulesSwitch { traits_.scansModulesOnEveryRequest && usesModules != modulesSupport_ && (usesModules || !planApplied_) }; + if (traits_.scansModulesOnEveryRequest) remember_modules_verdict_(usesModules); + if (modulesSwitch) modulesSupport_ = usesModules; + const bool restartNeeded { ((providerLeft || argumentsChanged) && planApplied_ && handshakeDone_) || modulesSwitch }; if (!restartNeeded && accepting_) { for (const auto& document : host_->documents()) { if (document.path.empty()) continue; @@ -850,7 +887,12 @@ class ClangdEngine final : public Engine { const bool unresolvedForgot { forget_changed_unresolved_() }; const bool doomForgot { forget_changed_doom_() }; recompute_doom_(); - if (restartNeeded) { + if (modulesSwitch) { + // The person's project, not clangd failing: at once, and never counted against clangd. + request_restart_(usesModules ? "the project uses modules, so clangd gets its modules support (WA-CLANGD-009)" + : "the project uses no modules, so clangd runs without its modules support (WA-CLANGD-009)", + RestartCause::user); + } else if (restartNeeded) { const std::string reason { providerLeft ? std::format("a module's unit left the engine database ({})", providersMoved.front()) : std::format("units are compiled with other arguments ({})", base::file_name(argumentsChangedFor.front())) }; @@ -1170,6 +1212,15 @@ class ClangdEngine final : public Engine { const std::string kind { event.value("kind", std::string {}) }; // Whichever process it was read from, the reading is back and another may start. if (kind == "cpu") cpuReadInFlight_ = false; + // P-3: the clangd restart_ let go of is gone; whichever generation asked, the one due now starts. + if (kind == "reaped") { + reaping_ = false; + if (startWhenReaped_) { + startWhenReaped_ = false; + start_process_(); + } + return; + } if (event.value("generation", -1) != generation_) return; if (kind == "message") { handle_message_(event["message"]); @@ -1582,6 +1633,7 @@ class ClangdEngine final : public Engine { config.verboseLog = options_.verboseLog; workers_ = engine_workers(std::thread::hardware_concurrency(), total_memory_bytes(), options_.workers); config.workers = workers_; + config.modulesSupport = modulesSupport_; // WA-CLANGD-009 config.extraArguments = options_.extraArguments; // Extra engine arguments for troubleshooting, e.g. MCPPLS_ENGINE_ARGUMENTS="-j=8 --background-index-priority=background". if (auto extra = platform::env::get("MCPPLS_ENGINE_ARGUMENTS")) { @@ -1717,9 +1769,29 @@ class ClangdEngine final : public Engine { answer_searches_(); forget_primes_(); ++generation_; // late events of the old process are ignored - if (process_) process_->stop(std::chrono::milliseconds { 500 }); diagnosed_.clear(); host_->forget_engine_diagnostics(ENGINE_ID); + // P-3 (plan 0.0.9): the old clangd is stopped off the event loop, and the new one starts when it is gone. A + // clangd building a preamble takes 2.4-3.9 s to leave whether its input closes or it is sent SIGTERM (measured + // on vulkan-hpp), and stopping it here held every request and watchdog meanwhile: 446 ms, 1803 ms in issue + // #37's bundles, up to 2.5 s by the bounds. The new one waits for the old because start_process_ clears the + // module locks an earlier clangd left (C-4), which holds only once no clangd uses the cache. + // Nothing reaches clangd meanwhile: requests wait for the new one as they did for a synchronous restart. + handshakeDone_ = false; + accepting_ = false; + if (process_) { + reaping_ = true; + // The previous reaper is done by now ("reaped" cleared reaping_ before process_ could be set again); assigning + // joins it at once. + reaper_ = std::jthread { [old = std::shared_ptr { std::move(process_) }, sink = sink_] { + old->stop(std::chrono::milliseconds { 500 }); + sink(Json { { "kind", "reaped" } }); + } }; + } + if (reaping_) { + startWhenReaped_ = true; + return; + } start_process_(); } @@ -1915,7 +1987,20 @@ class ClangdEngine final : public Engine { // WA-CLANGD-007: clangd scans an open file's imports from disk (UP-14), so an import typed into the buffer and not // saved yet is "not found" although the project provides it; that is information, not an error, until the save. void rewrite_diagnostics_(const std::string& uri, Json& diagnostics) const { - if ((!traits_.misplacesDirectiveSemicolon && !traits_.readsImportsFromDisk) || !diagnostics.is_array()) return; + if (!diagnostics.is_array()) return; + // WA-CLANGD-010: clang-tidy 23.1's misc-const-correctness wants a variable holding a filter, drop_while, chunk_by + // or split view const (UP-22, issue #37), which then does not compile: such a view has no const begin(). Dropped. + if (traits_.flagsNonConstViewsConst) { + Json kept = Json::array(); + for (auto& diagnostic : diagnostics) { + const Json* codeValue { diagnostic.is_object() ? lsp::find(diagnostic, "code") : nullptr }; + const bool tidy { codeValue != nullptr && codeValue->is_string() && codeValue->get() == "misc-const-correctness" }; + if (tidy && const_correctness_on_non_const_view(diagnostic.value("message", std::string {}))) continue; + kept.push_back(std::move(diagnostic)); + } + diagnostics = std::move(kept); + } + if (!traits_.misplacesDirectiveSemicolon && !traits_.readsImportsFromDisk) return; std::optional document; for (const auto& each : host_->documents()) { if (each.uri == uri) document = each; @@ -3533,6 +3618,11 @@ class ClangdEngine final : public Engine { forget_primes_(); ++generation_; if (process_) process_->stop(std::chrono::milliseconds { 500 }); + // P-3: stopped is gone. A restart after this starts clangd itself, or, while a "reaped" is still on its way, + // once that arrives -- never a second reaper for a process already stopped. + process_.reset(); + if (reaper_.joinable()) reaper_.join(); // one being let go of after a restart may still use the cache + modulesVerdict_.clear(); // WA-CLANGD-009: the file goes with the cache; the next plan writes it again handshakeDone_ = false; accepting_ = false; restartAt_.reset(); @@ -4207,6 +4297,19 @@ class ClangdEngine final : public Engine { std::string built_modules_path_() const { return base::join_path(databaseDirectory_, "module-builds.json"); } + // WA-CLANGD-009: "on" or "off", beside the context's database; a cache reset forgets it with the rest. + std::string modules_verdict_path_() const { return base::join_path(base::parent_path(databaseDirectory_), "modules-support"); } + + void remember_modules_verdict_(bool usesModules) { + const std::string_view verdict { usesModules ? "on" : "off" }; + if (verdict == modulesVerdict_) return; + if (auto written = platform::fs::write_file_atomic(modules_verdict_path_(), verdict); !written) { + log::warning("cannot keep whether {} uses modules: {}", host_->root_directory(), written.error().message); + return; + } + modulesVerdict_ = verdict; + } + void load_built_modules_() { if (builtModulesLoaded_) return; builtModulesLoaded_ = true; diff --git a/src/engine/clangd.cppm b/src/engine/clangd.cppm index f940f9a6..cacfb2af 100644 --- a/src/engine/clangd.cppm +++ b/src/engine/clangd.cppm @@ -6,6 +6,7 @@ export module mcppls.engine.clangd; import std; import nlohmann.json; +import mcppls.normalize.plan; import mcppls.engine; import mcppls.engine.clangd.process; @@ -19,6 +20,11 @@ inline constexpr std::string_view ENGINE_ID { "clangd" }; // `disabled`: registered workarounds (WA-CLANGD-) turned off whatever the version. EngineTraits traits_for_version(std::string_view version, std::span disabled = {}); +// WA-CLANGD-009: whether the project of `plan` uses C++ modules at all -- a unit that is part of or provides a module, +// imports one (`import std;` included), or an import nothing provides (a stand-in, or an issue naming the module). +// A project that does not gets a clangd without --experimental-modules-support. +bool plan_uses_modules(const normalize::EnginePlan& plan); + struct Options { std::string executable; // empty, or a file that does not exist: the engine is unavailable std::string version; diff --git a/src/engine/clangd/process.cpp b/src/engine/clangd/process.cpp index 9c9b2aab..ff0118e8 100644 --- a/src/engine/clangd/process.cpp +++ b/src/engine/clangd/process.cpp @@ -13,8 +13,9 @@ import mcppls.lsp.connection; namespace mcppls::engine::clangd { std::vector clangd_arguments(const ProcessConfig& config) { - std::vector arguments { - "--experimental-modules-support", + std::vector arguments; + if (config.modulesSupport) arguments.emplace_back("--experimental-modules-support"); + arguments.insert(arguments.end(), { "--use-dirty-headers", "--compile-commands-dir=" + config.databaseDirectory, "--background-index", @@ -23,7 +24,7 @@ std::vector clangd_arguments(const ProcessConfig& config) { // Fix plan F17.1 (D3): info, not error, so an incident carries what clangd was doing. Its info // lines go to the ring buffer and the debug log only, never to the default log. config.verboseLog ? "--log=verbose" : "--log=info", - }; + }); const bool workersGiven { std::ranges::any_of(config.extraArguments, [](const std::string& argument) { return argument.starts_with("-j"); }) }; if (config.workers > 0 && !workersGiven) arguments.push_back(std::format("-j={}", config.workers)); arguments.insert(arguments.end(), config.extraArguments.begin(), config.extraArguments.end()); diff --git a/src/engine/clangd/process.cppm b/src/engine/clangd/process.cppm index 730dd3a2..04d7df83 100644 --- a/src/engine/clangd/process.cppm +++ b/src/engine/clangd/process.cppm @@ -19,6 +19,9 @@ struct ProcessConfig { std::vector extraArguments; bool verboseLog { false }; std::size_t workers { 0 }; // clangd's -j; 0: clangd's own default. Extra arguments naming -j win. + // --experimental-modules-support. WA-CLANGD-009: off for a project that uses no modules, where it only costs every + // request a scan of the file's module dependencies. + bool modulesSupport { true }; }; class Process { diff --git a/src/engine/clangd/workarounds.cpp b/src/engine/clangd/workarounds.cpp index 73ee9dc6..0c309baf 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", @@ -96,6 +96,28 @@ constexpr std::array REGISTRY { { .canary = "", .premise = "a unit clangd has built in the foreground keeps its symbols in clangd's index after it is closed", }, + { + .id = MODULE_SCAN_PER_REQUEST, + .title = "with --experimental-modules-support clangd scans a file's module dependencies again for every completion, about 170 ms more on a heavy header; a project that uses no modules gets clangd without it", + .fixedIn = "", + .upstream = "unfiled (UP-23 in issue #24)", + .evidence = ".agents/docs/reviews/2026-10-01-issue-37-review.md §B (vulkan-rt, issue #37): completion median 82 ms without the flag, 254 ms with it (max 869 ms), in a .cpp and a header alike", + .added = "0.0.9", + .removeWhen = "clangd reuses a file's module dependency scan between requests, so completion costs the same with and without --experimental-modules-support", + .canary = "", + .premise = "a project whose plan has no module unit, no module import and no standard library module needs nothing of clangd's modules support; a plan that gains one restarts clangd with it", + }, + { + .id = CONST_CORRECTNESS_VIEWS, + .title = "clang-tidy 23.1's misc-const-correctness says a variable holding a filter, drop_while, chunk_by or split view, or a view over one, can be const although such a view cannot be iterated as const; the diagnostic is dropped", + .fixedIn = "", + .upstream = "unfiled (UP-22 in issue #24); clang-tidy 22.1.8 does not warn for a view an adaptor returned", + .evidence = "issue #37 (vulkan-rt rank_device_by_memory); .agents/docs/reviews/2026-10-01-issue-37-review.md §A; tests/test_workarounds.cpp", + .added = "0.0.9", + .removeWhen = "misc-const-correctness leaves a variable alone whose view has no const begin() and is used through it", + .canary = "", + .premise = "the diagnostic names the variable's type, the canonical one after `aka` where it differs, and that type is a std::ranges view whose base is its first template argument", + }, } }; // "23.1.0" -> {23, 1, 0}; anything else -> nullopt. @@ -274,6 +296,77 @@ std::optional directive_missing_semicolon(std::string_view text, int return std::nullopt; } +namespace { + +// The standard views without a const begin(): each caches the begin() it found, so iterating one changes it. +constexpr std::array NON_CONST_ITERABLE_VIEWS { "filter_view", "drop_while_view", "chunk_by_view", "split_view" }; + +struct ViewType { + std::string_view name; // "filter_view" + std::string_view base; // its first template argument: the range it is built on +}; + +// "std::ranges::filter_view" (or "ranges::", libc++'s "std::__1::ranges::", the bare name an `aka` may print) +// -> {"filter_view", "V"}; anything that is not a standard range view -> nullopt. +std::optional view_type(std::string_view type) { + type = base::trim(type); + const std::size_t open { type.find('<') }; + if (open == std::string_view::npos) return std::nullopt; + const std::string_view head { type.substr(0, open) }; + std::string_view name { head }; + if (const std::size_t colons { head.rfind("::") }; colons != std::string_view::npos) { + const std::string_view scope { head.substr(0, colons + 2) }; + if (scope != "std::ranges::" && scope != "ranges::" && scope != "std::__1::ranges::") return std::nullopt; + name = head.substr(colons + 2); + } + if (!name.ends_with("_view")) return std::nullopt; + // The first argument ends at a ',' or the closing '>' outside nested <>, () and [] -- a lambda prints as + // "(lambda at f.cpp:3:5)", a function pointer as "bool (*)(const H &)". + int depth { 0 }; + std::size_t at { open + 1 }; + for (; at < type.size(); ++at) { + const char c { type[at] }; + if (c == '<' || c == '(' || c == '[') { + ++depth; + } else if (c == '>' || c == ')' || c == ']') { + if (depth == 0) break; + --depth; + } else if (c == ',' && depth == 0) { + break; + } + } + if (at >= type.size()) return std::nullopt; + return ViewType { name, base::trim(type.substr(open + 1, at - open - 1)) }; +} + +} // namespace + +bool const_correctness_on_non_const_view(std::string_view message) { + // clang-tidy: "variable 'v' of type 'T' can be declared 'const'", where T is followed by " (aka 'U')" when its + // canonical type U is spelled otherwise; clangd capitalises the first letter. + static constexpr std::string_view TAIL { " can be declared 'const'" }; + const std::size_t tail { message.find(TAIL) }; + if (tail == std::string_view::npos || message.size() < 10 || (message[0] != 'V' && message[0] != 'v') || !message.substr(1).starts_with("ariable '")) return false; + const std::string_view head { message.substr(0, tail) }; + std::string_view type; + if (const std::size_t aka { head.rfind(" (aka '") }; aka != std::string_view::npos && head.ends_with("')")) { + type = head.substr(aka + 7, head.size() - aka - 9); + } else if (const std::size_t of { head.find(" of type '") }; of != std::string_view::npos && head.ends_with('\'')) { + type = head.substr(of + 10, head.size() - of - 11); + } else { + return false; + } + // Down the views each is built on: a view over one without a const begin() has none either (its const begin() + // asks for a range), except ref_view, whose const begin() reaches the range it refers to as it is. + for (int depth { 0 }; depth < 16; ++depth) { + const auto view = view_type(type); + if (!view || view->name == "ref_view") return false; + if (std::ranges::find(NON_CONST_ITERABLE_VIEWS, view->name) != NON_CONST_ITERABLE_VIEWS.end()) return true; + type = view->base; + } + return false; +} + std::optional module_not_found_name(std::string_view message) { static constexpr std::string_view TAIL { "' not found" }; if (message.size() < 8 || (message[0] != 'm' && message[0] != 'M') || !message.substr(1).starts_with("odule '") || !message.ends_with(TAIL)) return std::nullopt; diff --git a/src/engine/clangd/workarounds.cppm b/src/engine/clangd/workarounds.cppm index 478a65d1..8fd069b8 100644 --- a/src/engine/clangd/workarounds.cppm +++ b/src/engine/clangd/workarounds.cppm @@ -36,6 +36,8 @@ 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" }; +inline constexpr std::string_view MODULE_SCAN_PER_REQUEST { "WA-CLANGD-009" }; +inline constexpr std::string_view CONST_CORRECTNESS_VIEWS { "WA-CLANGD-010" }; std::span workarounds(); const Workaround* find_workaround(std::string_view id); @@ -86,4 +88,17 @@ std::optional directive_missing_semicolon(std::string_view text, int // WA-CLANGD-007. The module of clangd's "module 'X' not found" (any capitalisation of the first letter). std::optional module_not_found_name(std::string_view message); +// WA-CLANGD-010. clang-tidy 23.1's misc-const-correctness says a variable holding a range view "can be declared +// 'const'" when the view cannot be iterated as const: filter_view, drop_while_view, chunk_by_view and split_view +// cache their begin(), so they have no const begin(), and a view built on one of them has none either. Declared +// const, `v | std::views::transform(f)`, a range-for or std::ranges::fold_left over it no longer compiles (issue #37, +// UP-22). Whether `message` is such a diagnostic: the variable's type (the `aka` one where clangd gives it) is a +// standard range view, `std::ranges::...` or the bare name libc++ prints, with one of those four views in it. +// A variable of another type, a view that is const-iterable (transform_view over an array), and lazy_split_view +// (const-iterable over a forward range) are left alone. +bool const_correctness_on_non_const_view(std::string_view message); + +// WA-CLANGD-009 has no helper here: it is carried out where clangd is started (ProcessConfig::modulesSupport) and +// where a plan is applied (clangd.cpp, plan_uses_modules), since it reads the plan. + } // namespace mcppls::engine::clangd diff --git a/src/engine/engine.cppm b/src/engine/engine.cppm index ef77aca2..526559d1 100644 --- a/src/engine/engine.cppm +++ b/src/engine/engine.cppm @@ -42,6 +42,8 @@ struct EngineTraits { 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 bool readsImportsFromDisk { false }; // an import only in an unsaved buffer is "not found"; told as information + bool scansModulesOnEveryRequest { false }; // modules support costs every request a scan; off for a project with no modules + bool flagsNonConstViewsConst { false }; // misc-const-correctness wants a filter view const; the diagnostic is dropped std::string kitStdlibVersion; // the libc++ version a semantic kit must have for it (S4-4-5); empty: any bool tested { false }; // a version this server's conformance suite runs against }; diff --git a/src/orchestrator/instance.cppm b/src/orchestrator/instance.cppm index 78101a19..ace48bdc 100644 --- a/src/orchestrator/instance.cppm +++ b/src/orchestrator/instance.cppm @@ -19,6 +19,9 @@ public: static WorkspaceLease acquire(std::string_view workspaceDirectory, std::chrono::system_clock::time_point now); const std::string& directory() const { return directory_; } // the cache directory this instance uses + // The owner's cache directory, /workspaces/: a guest may read what the owner wrote there (its + // cached project model, P-1 plan 0.0.9), never write to it. + const std::string& workspace_directory() const { return workspaceDirectory_; } bool shared() const { return shared_; } // another live instance owns the workspace directory void renew(std::chrono::system_clock::time_point now); // the owner's heartbeat; nothing for a guest void release(); // the owner drops its lease; a guest removes its directory diff --git a/src/orchestrator/workspace.cpp b/src/orchestrator/workspace.cpp index 8257915e..ef5e33d3 100644 --- a/src/orchestrator/workspace.cpp +++ b/src/orchestrator/workspace.cpp @@ -236,6 +236,9 @@ struct Workspace::Impl final : engine::Host { std::size_t cancelled { 0 }; double maxMs { 0 }; std::deque recentMs; // the latest durations, for percentiles + // M-1 (plan 0.0.9): the same requests' time in the engine that answered, and the time outside it, for the ones an + // engine answered. Pairs, so a request's two parts stay together when the oldest leave. + std::deque> recentEngineMs; // {engine, overhead} std::map> answeredBy; std::string lastAt; // UTC, when the latest one was answered (0.0.8 plan E-3) }; @@ -295,6 +298,10 @@ struct Workspace::Impl final : engine::Host { bool coreAnswered { false }; // C-2 (plan 0.0.8 part 2): the job asked nothing of its own and waits for a late completion of the same word. bool waitsForLate { false }; + // M-1 (plan 0.0.9): when the request went to the engine whose answer went out, and when that engine replied. + // The difference is the engine's share of the request's time; the rest is mcppls's own. + std::optional engineSentAt; + std::optional engineRepliedAt; }; std::map jobs; std::uint64_t nextJob { 1 }; @@ -369,6 +376,14 @@ 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 + // D-2 (plan 0.0.9): that load was offline (an online one that fails says producer-install-failed instead), so a client may + // offer to fetch what is missing; and what the load that failed with the network allowed said, named in the status. + bool needsDownloadFromOfflineRun { false }; + std::string installFailed; + // D-5 (plan 0.0.9): how the last description the person asked for with the network (mcppls.describeOnline) ended, + // S3 `onlineRun`: {outcome, message, at}; null before the first. It stays until the next such run ends. + Json onlineRun; + bool loadRunsOffline { true }; // the load running now: the producer is started offline // 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 }; @@ -386,6 +401,11 @@ struct Workspace::Impl final : engine::Host { // 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 }; + // P-2 (plan 0.0.9): what clangd waits beyond FIRST_MODEL_WAIT when this project's build tool is known to answer soon + // after it -- 1.2 times its last measured time, up to CORE_WAIT_CAP. Starting clangd on the scanned model costs a + // preamble built with the wrong commands and a restart when the build tool answers (issue #37: two 3 s preambles on + // vulkan-hpp); mcppls's own engine answers either way. With no measurement it is CORE_WAIT_LIMIT, as before. + static constexpr std::chrono::milliseconds CORE_WAIT_CAP { 5500 }; std::optional coreWaitUntil; bool coreWaitOver { false }; std::optional producerElapsed; // set while the producer is past its soft bound @@ -715,6 +735,7 @@ struct Workspace::Impl final : engine::Host { if (!selection.mergers.empty()) { job.merging = true; job.awaiting = selection.mergers.size(); + job.engineSentAt = Clock::now(); // Semantic tokens: the core engine's own answer arrives in its own legend's indices; // remapped into this server's legend right here, once, so routing::merge_results (and // everything downstream) only ever sees the server's own index space (routing itself @@ -882,6 +903,7 @@ struct Workspace::Impl final : engine::Host { return; } engine::Engine* answerer { job.answerers[job.next++] }; + job.engineSentAt = Clock::now(); answerer->request(job.view, job.message, [this, jobId, engineId = std::string { answerer->id() }](engine::Answer answer) { auto current = jobs.find(jobId); if (current == jobs.end()) { @@ -892,6 +914,7 @@ struct Workspace::Impl final : engine::Host { case engine::Answer::Kind::result: if (coreEngine != nullptr && engineId == coreEngine->id()) current->second.coreAnswered = true; if (!answer.value.is_null()) { + current->second.engineRepliedAt = Clock::now(); current->second.answeredBy = engineId; finish_job(jobId, std::move(answer.value)); return; @@ -924,6 +947,7 @@ struct Workspace::Impl final : engine::Host { } if (--job.awaiting > 0) return; job.answeredBy = "merged"; + job.engineRepliedAt = Clock::now(); if (job.cancelled) { finish_job_cancelled(jobId); } else if (job.error) { @@ -945,6 +969,11 @@ struct Workspace::Impl final : engine::Host { if (stats.recentMs.size() > 256) stats.recentMs.pop_front(); stats.maxMs = std::max(stats.maxMs, ms); if (!job.answeredBy.empty()) ++stats.answeredBy[job.answeredBy]; + if (job.engineSentAt && job.engineRepliedAt && outcome == "result") { + const double engineMs { std::chrono::duration(*job.engineRepliedAt - *job.engineSentAt).count() }; + stats.recentEngineMs.emplace_back(engineMs, std::max(0.0, ms - engineMs)); + if (stats.recentEngineMs.size() > 256) stats.recentEngineMs.pop_front(); + } stats.lastAt = std::format("{:%FT%TZ}", std::chrono::floor(std::chrono::system_clock::now())); if (!job.path.empty() && (fileRequestStats.size() < FILE_STATS_LIMIT || fileRequestStats.contains(job.path))) { auto& file = fileRequestStats[job.path]; @@ -1086,8 +1115,16 @@ struct Workspace::Impl final : engine::Host { if (!options.trusted) return; auto cached = project::load_model(cacheDirectory, detection.kind); + // P-1 (plan 0.0.9): a second instance starts with no cache of its own; the owner's model is read, never written, + // so it plans at once instead of from scanned sources (issue #37: 3-4.4 s of slow first requests and a clangd + // restart when the build tool's model came). The owner writes it atomically. + if (!cached && lease && lease->shared()) { + cached = project::load_model(lease->workspace_directory(), detection.kind); + if (cached) log::info("this instance plans with the model the instance owning {} cached", lease->workspace_directory()); + } if (!cached) { loadGiveUpAt = Clock::now() + FIRST_MODEL_WAIT; + lastProducerMs = read_producer_timing(detection.kind); // P-2 return; } producerPath = cached->producer; @@ -1168,6 +1205,68 @@ struct Workspace::Impl final : engine::Host { // One file per source, so a model built from scanned sources can never replace what the build // tool said (design P5), and a fingerprint of the inputs, so the next session can tell whether // what it has is still current (design 4.1). + // D-5 (plan 0.0.9): the outcome of a description run with the network because the person asked (S3-4-26). It fetched + // what was needed when the build tool described the project and nothing is missing any more; otherwise what failed + // is said, in the build tool's words where it gave some. + void note_online_run(const project::ProjectModel& loaded) { + const std::string tool { project::to_string(detectedSource) }; + const bool fetched { needsDownload.empty() && installFailed.empty() && loaded.source != project::SourceKind::inferred }; + std::string message; + if (fetched) { + message = std::format("{} fetched what the build description needed; the project is described by {} now", tool, tool); + } else if (!installFailed.empty()) { + message = installFailed; + } else if (!needsDownload.empty()) { + message = std::format("{} still needs a download after the run with the network: {}", tool, needsDownload); + } else { + message = std::format("{} did not describe the project with the network either; the log says why", tool); + for (const auto& issue : loaded.issues) { + if (!issue.message.empty()) { + message = issue.message; + break; + } + } + } + onlineRun = Json { { "outcome", fetched ? "fetched" : "failed" }, { "message", message }, + { "at", std::format("{:%FT%TZ}", std::chrono::floor(std::chrono::system_clock::now())) } }; + log::info("the description of {} with the network {}: {}", root, fetched ? "fetched what it needed" : "failed", message); + journal.add("online-run", onlineRun); + } + + // P-2 (plan 0.0.9): what clangd waits for the producer beyond FIRST_MODEL_WAIT (CORE_WAIT_CAP). + std::chrono::milliseconds core_wait_limit() const { + if (lastProducerMs <= 0) return CORE_WAIT_LIMIT; + const std::chrono::milliseconds expected { lastProducerMs * 6 / 5 }; + return std::clamp(expected - FIRST_MODEL_WAIT, CORE_WAIT_LIMIT, CORE_WAIT_CAP); + } + + // P-2: how long each build tool last took to describe this project, kept apart from the model cache: it outlives a + // cache that is gone or of another version, and a second instance reads the owner's (P-1). + std::string producer_timing_path(std::string_view directory) const { return base::join_path(std::string { directory }, "producer-timing.json"); } + + std::int64_t read_producer_timing(project::SourceKind kind) const { + std::vector directories { cacheDirectory }; + if (lease && lease->shared()) directories.push_back(lease->workspace_directory()); + for (const auto& directory : directories) { + const auto text = platform::fs::read_file(producer_timing_path(directory)); + if (!text) continue; + const Json timing = Json::parse(*text, nullptr, false); + if (timing.is_object() && timing.value(std::string { project::to_string(kind) }, std::int64_t { 0 }) > 0) { + return timing.value(std::string { project::to_string(kind) }, std::int64_t { 0 }); + } + } + return 0; + } + + void write_producer_timing(project::SourceKind kind, std::int64_t milliseconds) const { + const std::string path { producer_timing_path(cacheDirectory) }; + const auto text = platform::fs::read_file(path); + Json timing = text ? Json::parse(*text, nullptr, false) : Json::object(); + if (!timing.is_object()) timing = Json::object(); + timing[std::string { project::to_string(kind) }] = milliseconds; + (void)platform::fs::write_file_atomic(path, timing.dump()); + } + void save_model_cache() const { if (!model) return; project::CachedModel cached; @@ -1254,6 +1353,7 @@ struct Workspace::Impl final : engine::Host { if (describingOnline) journal.add("describe-online"); onlineOnce = false; load.offline = !online; + loadRunsOffline = !online; load.runBuildTool = options.buildTool != "off"; load.producerHard = options.producerTimeout.count() > 0 ? std::chrono::milliseconds { options.producerTimeout } : online ? std::chrono::milliseconds { std::chrono::minutes { 10 } } @@ -1346,6 +1446,7 @@ struct Workspace::Impl final : engine::Host { // G-4: what this project's producer takes is what its next deadline is made of. if (loadStartedAt && loadedModel->source != project::SourceKind::inferred) { lastProducerMs = std::chrono::duration_cast(Clock::now() - *loadStartedAt).count(); + write_producer_timing(loadedModel->source, lastProducerMs); // P-2 } loadStartedAt.reset(); // Fix plan F4: the build tool answered, whatever it said; clangd waits no longer. A model kept below @@ -1356,10 +1457,15 @@ struct Workspace::Impl final : engine::Host { } ++snapshotGeneration; needsDownload.clear(); + installFailed.clear(); + const bool askedOnline { describingOnline }; describingOnline = false; for (const auto& issue : loadedModel->issues) { if (issue.code == spec::NEEDS_DOWNLOAD) needsDownload = issue.message; + if (issue.code == spec::INSTALL_FAILED) installFailed = issue.message; } + if (askedOnline) note_online_run(*loadedModel); + needsDownloadFromOfflineRun = !needsDownload.empty() && loadRunsOffline; if (needsDownload.empty()) { downloadRetries = 0; downloadRetryAt.reset(); @@ -1576,9 +1682,9 @@ struct Workspace::Impl final : engine::Host { } const bool coreWaits { core_waits_for_producer() }; if (coreWaits && !coreWaitUntil) { - coreWaitUntil = Clock::now() + CORE_WAIT_LIMIT; + coreWaitUntil = Clock::now() + core_wait_limit(); 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()); + core_wait_limit().count()); journal.add("engine-waits-for-producer", Json { { "detected", std::string { project::to_string(detectedSource) } } }); } for (const auto& engine : engines) { @@ -1930,9 +2036,18 @@ struct Workspace::Impl final : engine::Host { // 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; + // D-2: only a load that was offline has anything to repeat online. + issues.back()["askOnline"] = ask_before_download() && !describingOnline && needsDownloadFromOfflineRun; } } + if (!installFailed.empty()) { + // D-2: the network was allowed and the install failed; the reason is the build tool's own, there is nothing to ask for. + add(std::string { spec::INSTALL_FAILED}, + std::format("the build description could not be made: {}. The project is served from its sources meanwhile; " + "run the build tool in your terminal to see it in full (the description is read again when that is done)", + installFailed), + "mcppls.runBuildToolInTerminal", "Run in Terminal", "environment"); + } 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)), @@ -1947,7 +2062,7 @@ struct Workspace::Impl final : engine::Host { for (const auto& issue : model->issues) { // 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; + if (issue.code == spec::NEEDS_DOWNLOAD || issue.code == spec::INSTALL_FAILED) 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"); @@ -1999,6 +2114,7 @@ struct Workspace::Impl final : engine::Host { { "issues", issues }, }; if (!notices.empty()) params["notices"] = std::move(notices); + if (!onlineRun.is_null()) params["onlineRun"] = onlineRun; // D-5, S3-4-26 if (core && core->toPrepare > 0) params["progress"] = Json { { "done", core->prepared }, { "total", core->toPrepare } }; attach_auto_bundles(params["issues"]); std::string serialized { lsp::dump(params) }; @@ -2069,7 +2185,7 @@ struct Workspace::Impl final : engine::Host { if (modelOrigin == "inferred" && model) { 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() } }); + journal.add("engine-wait-over", Json { { "milliseconds", (FIRST_MODEL_WAIT + core_wait_limit()).count() } }); replan(); } } @@ -2447,9 +2563,28 @@ Json Workspace::report() const { }; Json answeredBy = Json::object(); for (const auto& [engineId, count] : stats.answeredBy) answeredBy[engineId] = count; - requests[method] = Json { { "count", stats.count }, { "empty", stats.empty }, { "errors", stats.errors }, { "cancelled", stats.cancelled }, - { "p50Ms", percentile(0.5) }, { "p95Ms", percentile(0.95) }, { "maxMs", static_cast(stats.maxMs) }, - { "answeredBy", std::move(answeredBy) }, { "lastAt", stats.lastAt } }; + Json entry { { "count", stats.count }, { "empty", stats.empty }, { "errors", stats.errors }, { "cancelled", stats.cancelled }, + { "p50Ms", percentile(0.5) }, { "p95Ms", percentile(0.95) }, { "maxMs", static_cast(stats.maxMs) }, + { "answeredBy", std::move(answeredBy) }, { "lastAt", stats.lastAt } }; + // M-1 (plan 0.0.9): where the time of the requests an engine answered went, in the engine and outside it. + if (!stats.recentEngineMs.empty()) { + std::vector engineMs, overheadMs; + for (const auto& [engine, overhead] : stats.recentEngineMs) { + engineMs.push_back(engine); + overheadMs.push_back(overhead); + } + std::ranges::sort(engineMs); + std::ranges::sort(overheadMs); + const auto at = [](const std::vector& sorted, double fraction) { + return static_cast(sorted[std::min(sorted.size() - 1, static_cast(fraction * static_cast(sorted.size())))]); + }; + entry["engineAnswered"] = engineMs.size(); + entry["engineP50Ms"] = at(engineMs, 0.5); + entry["engineP95Ms"] = at(engineMs, 0.95); + entry["overheadP50Ms"] = at(overheadMs, 0.5); + entry["overheadP95Ms"] = at(overheadMs, 0.95); + } + requests[method] = std::move(entry); } // C-4 (plan 0.0.8 part 2): the ten files whose slowest method is slowest at the 95th percentile. std::vector> files; diff --git a/src/project/cmake.cpp b/src/project/cmake.cpp index d17711ff..b1690b0b 100644 --- a/src/project/cmake.cpp +++ b/src/project/cmake.cpp @@ -452,7 +452,8 @@ Answer CmakeProvider::describe(const Claim& claim, const ProviderContext& contex if (context.offline) { if (auto missing = fetchcontent_missing_dependencies(result->output + "\n" + result->error); !missing.empty()) { return Answer { .outcome = Outcome::needs_download, .code = std::string { spec::NEEDS_DOWNLOAD }, - .reason = std::format("cmake needs {} downloaded, and this run stayed offline (FETCHCONTENT_FULLY_DISCONNECTED)", + .reason = std::format("cmake needs {} downloaded: they are what FetchContent would fetch for this project, and this run stayed offline " + "(FETCHCONTENT_FULLY_DISCONNECTED)", base::join(missing, ", ")), .missing = missing }; } diff --git a/src/project/meson.cpp b/src/project/meson.cpp index 58bce59d..01bb954d 100644 --- a/src/project/meson.cpp +++ b/src/project/meson.cpp @@ -121,8 +121,10 @@ Answer MesonProvider::describe(const Claim& claim, const ProviderContext& contex if (auto missing = meson_missing_subprojects(combined); !missing.empty() || combined.contains("nodownload")) { return Answer { .outcome = Outcome::needs_download, .code = std::string { spec::NEEDS_DOWNLOAD }, .reason = missing.empty() - ? std::string { "meson needs a wrap-based subproject downloaded, and this run stayed offline (--wrap-mode=nodownload)" } - : std::format("meson needs {} downloaded, and this run stayed offline (--wrap-mode=nodownload)", + ? std::string { "meson needs a wrap-based subproject downloaded: meson would fetch it for this project, and this run stayed offline " + "(--wrap-mode=nodownload)" } + : std::format("meson needs {} downloaded: they are the wrap-based subprojects meson would fetch for this project, and this run " + "stayed offline (--wrap-mode=nodownload)", base::join(missing, ", ")), .missing = missing }; } diff --git a/src/project/model.cpp b/src/project/model.cpp index f82e4e58..865d4715 100644 --- a/src/project/model.cpp +++ b/src/project/model.cpp @@ -132,7 +132,7 @@ ProjectModel load_project(std::string_view rootInput, const LoadOptions& options model.detected = detection.kind; if (!options.buildDiscovery) { model.notices.push_back(ModelIssue { "build-discovery-off", - "mcppls.buildDiscovery is off, so nothing was detected, read or run implicitly; only an " + "mcppls.buildDiscovery.mode is off, so nothing was detected, read or run implicitly; only an " "explicitly configured database is used, and the sources are scanned otherwise" }); } const Scanner scanner { options.scanner ? options.scanner : file_scanner() }; diff --git a/src/project/xmake.cpp b/src/project/xmake.cpp index 3a0ca18c..d6fff90b 100644 --- a/src/project/xmake.cpp +++ b/src/project/xmake.cpp @@ -107,6 +107,68 @@ Answer read_project_database(const Claim& claim, const ProviderContext& context) return answer; } +// D-1 (plan 0.0.9): what `xmake f` accepts in this project, from `xmake f --help` run where configure runs (the project's +// directory, the same environment, offline). Kept in the private directory under xmake's path and time and xmake.lua's stamp, so +// it is asked once and again only when either changes. Empty when xmake gave no usable help: the caller keeps the reduced retry. +std::vector accepted_options(const Claim& claim, const ProviderContext& context, const std::string& xmake, + const std::vector& environment) { + const auto toolStamp { fs::stamp(xmake) }; + // An option() may be declared in any xmake.lua the root includes: the newest of them, and how many there are, is in the key. + std::int64_t manifestsModified { 0 }; + const auto manifests { xmake_manifests(claim) }; + for (const auto& manifest : manifests) { + if (const auto stamp = fs::stamp(manifest)) manifestsModified = std::max(manifestsModified, stamp->modified); + } + const std::string key { std::format("{} {} {} {} {}", xmake, toolStamp ? toolStamp->modified : 0, toolStamp ? toolStamp->size : 0, + manifestsModified, manifests.size()) }; + const std::string cachePath { base::join_path(context.privateDirectory, "xmake-help") }; + if (const auto cached = fs::read_file(cachePath)) { + const auto lines { base::split_lines(*cached) }; + if (!lines.empty() && lines.front() == key) { + std::vector names; + for (const auto line : lines | std::views::drop(1)) { + if (!base::trim(line).empty()) names.emplace_back(base::trim(line)); + } + return names; + } + } + const auto help = platform::toolrun::run({ + .program = xmake, + .arguments = { "f", "--help" }, + .workDirectory = claim.root, + .purpose = "xmake-help", + .root = context.rootKey, + .network = platform::toolrun::Network::offline, + .bounds = platform::RunBounds { .hard = std::chrono::seconds { 15 } }, + .environment = environment, + .environmentWait = context.environmentWait, + }); + if (!help || help->timedOut) return {}; + const auto names { xmake_help_options(help->output + "\n" + help->error) }; + // A help text with no `--plat` is not xmake's help of `f`: nothing is learned from it, and nothing is kept. + if (!std::ranges::contains(names, std::string { "plat" })) return {}; + (void)fs::write_file(cachePath, key + "\n" + base::join(names, "\n")); + return names; +} + +// D-2, D-6: the failure of either stage as the user can act on it, when the output names packages xmake could not have: offline, +// the download that the user decides; online, an install that was tried and failed. Empty when the output names no package. +std::optional missing_packages_answer(const std::string& output, bool offline) { + auto missing { xmake_missing_packages(output) }; + if (!offline && missing.empty()) { + // With the network allowed xmake does not say "not found": it says which install failed, and where its log is. + missing = xmake_failed_installs(output); + if (missing.empty() && xmake_install_log(output).empty()) return std::nullopt; + } else if (missing.empty()) { + return std::nullopt; + } + if (offline) { + return Answer { .outcome = Outcome::needs_download, .code = std::string { spec::NEEDS_DOWNLOAD }, + .reason = xmake_needs_download_reason(missing), .missing = std::move(missing) }; + } + return Answer { .code = std::string { spec::INSTALL_FAILED }, .reason = xmake_install_failed_reason(missing, output), .missing = std::move(missing) }; +} + // Runs xmake privately: two commands, in a directory of ours, never the project's (see the comment in the body). Answer run_private(const Claim& claim, const ProviderContext& context, const std::string& xmake) { context.producerUsed = xmake; @@ -139,10 +201,18 @@ Answer run_private(const Claim& claim, const ProviderContext& context, const std if (!hasConfig || stale) { fs::remove_all(configuredMarker); // a failed `xmake f -c` leaves no configuration worth keeping fs::remove_all(leftOutMarker); + // D-1: asked only when xmake.conf holds something that is neither internal nor standard; the answer is kept. + std::vector accepted; + if (user && !xmake_left_out(user->options).empty()) { + accepted = accepted_options(claim, context, xmake, environment); + if (const auto skipped { xmake_not_accepted(user->options, accepted) }; !skipped.empty()) { + base::log::info("xmake f does not take {} of {}; these are keys xmake wrote itself, so they are not passed on", base::join(skipped, ", "), user->path); + } + } const auto configure = [&](bool reduced) { return platform::toolrun::run({ .program = xmake, - .arguments = xmake_configure_arguments(buildDirectory, context.offline, user ? std::span { user->options } : std::span {}, reduced), + .arguments = xmake_configure_arguments(buildDirectory, context.offline, user ? std::span { user->options } : std::span {}, reduced, accepted), .workDirectory = claim.root, .purpose = "configure", .root = context.rootKey, @@ -156,7 +226,8 @@ Answer run_private(const Claim& claim, const ProviderContext& context, const std }; auto configured = configure(false); // X-2: an option the user's xmake.conf still holds, that xmake.lua no longer declares, is refused (exit 255 with v3.1.1). - // One retry with the standard options only; what was left out is said in a notice. + // One retry with the standard options only; what was left out is said in a notice. D-1: with the list `xmake f --help` + // gave there is nothing left to refuse, so this is the fallback for when that list could not be had. if (configured && !configured->timedOut && configured->exitCode != 0 && user && !xmake_left_out(user->options).empty() && xmake_unknown_option(configured->output + "\n" + configured->error)) { const auto leftOut { xmake_left_out(user->options) }; @@ -170,12 +241,7 @@ Answer run_private(const Claim& claim, const ProviderContext& context, const std } if (configured->exitCode != 0) { const std::string combined { configured->output + "\n" + configured->error }; - if (auto missing = xmake_missing_packages(combined); !missing.empty()) { - return Answer { .outcome = Outcome::needs_download, .code = std::string { spec::NEEDS_DOWNLOAD }, - .reason = std::format("xmake needs {} downloaded, and this run stayed offline (network.mode:private)", - base::join(missing, ", ")), - .missing = missing }; - } + if (auto packages = missing_packages_answer(combined, context.offline)) return std::move(*packages); return Answer { .code = "xmake-configure-failed", .reason = std::format("xmake f failed ({}): {}", configured->exitCode, base::trim(configured->error.empty() ? configured->output : configured->error)) }; @@ -202,6 +268,8 @@ Answer run_private(const Claim& claim, const ProviderContext& context, const std .reason = "xmake project -k compile_commands did not finish in time" }; } if (described->exitCode != 0) { + // D-6: the project stage reads the packages too; they can be missing here when `xmake f` did not need them. + if (auto packages = missing_packages_answer(described->output + "\n" + described->error, context.offline)) return std::move(*packages); return Answer { .code = "xmake-configure-failed", .reason = std::format("xmake project -k compile_commands failed ({}): {}", described->exitCode, base::trim(described->error.empty() ? described->output : described->error)) }; @@ -306,6 +374,89 @@ std::vector xmake_left_out(std::span user) { return names; } +std::vector xmake_help_options(std::string_view text) { + std::vector names; + for (const auto line : base::split_lines(text)) { + std::size_t indent { 0 }; + while (indent < line.size() && line[indent] == ' ') ++indent; + if (indent == 0 || indent > 8 || indent >= line.size() || line[indent] != '-') continue; + // `-p PLAT, --plat=PLAT Compile for ...`: the spellings come before the first run of two spaces. + std::string_view spellings { line.substr(indent) }; + if (const std::size_t gap { spellings.find(" ") }; gap != std::string_view::npos) spellings = spellings.substr(0, gap); + for (const auto part : base::split(spellings, ',')) { + std::string_view spelling { base::trim(part) }; + if (!spelling.starts_with("--")) continue; + spelling.remove_prefix(2); + std::size_t end { 0 }; + while (end < spelling.size() && (base::is_identifier_char(spelling[end]) || spelling[end] == '-')) ++end; + if (end > 0) names.emplace_back(spelling.substr(0, end)); + } + } + std::ranges::sort(names); + names.erase(std::ranges::unique(names).begin(), names.end()); + return names; +} + +std::vector xmake_not_accepted(std::span user, std::span accepted) { + std::vector names; + if (accepted.empty()) return names; + for (const auto& option : user) { + if (xmake_option_is_internal(option.name) || xmake_option_is_standard(option.name)) continue; + if (!std::ranges::contains(accepted, option.name)) names.push_back(option.name); + } + return names; +} + +std::vector xmake_error_lines(std::string_view output, std::size_t limit) { + std::vector lines; + for (const auto line : base::split_lines(output)) { + const std::string_view trimmed { base::trim(line) }; + if (base::to_lower_ascii(trimmed).starts_with("error:")) lines.emplace_back(trimmed); + } + if (lines.size() > limit) lines.erase(lines.begin(), lines.end() - static_cast(limit)); + return lines; +} + +std::vector xmake_failed_installs(std::string_view output) { + std::vector names; + for (const auto line : base::split_lines(output)) { + const std::string lower { base::to_lower_ascii(base::trim(line)) }; + if (!lower.starts_with("error: install ") || !lower.contains(" failed")) continue; + const std::string_view rest { std::string_view { lower }.substr(std::string_view { "error: install " }.size()) }; + if (const std::string name { rest.substr(0, rest.find(' ')) }; !name.empty() && !std::ranges::contains(names, name)) names.push_back(name); + } + return names; +} + +std::string xmake_install_log(std::string_view output) { + for (const auto line : base::split_lines(output)) { + for (const auto word : base::split(line, ' ')) { + if (!word.contains("installdir.failed")) continue; + std::string_view path { base::trim(word) }; + while (!path.empty() && (path.back() == '.' || path.back() == ',' || path.back() == ')' || path.back() == '\'' || path.back() == '"')) path.remove_suffix(1); + while (!path.empty() && (path.front() == '(' || path.front() == '\'' || path.front() == '"')) path.remove_prefix(1); + if (!path.empty()) return std::string { path }; + } + } + return {}; +} + +std::string xmake_needs_download_reason(std::span missing) { + return std::format("xmake needs {} downloaded: they are what xmake would fetch or build for this project's requirements (build tools " + "included), and this run stayed offline (network.mode:private); installing them with your system package manager " + "also works, because xmake uses the system's when it finds them", + base::join(missing, ", ")); +} + +std::string xmake_install_failed_reason(std::span missing, std::string_view output) { + std::string reason { std::format("xmake could not install {} (the network was allowed)", + missing.empty() ? std::string { "the packages this project requires" } : base::join(missing, ", ")) }; + for (const auto& line : xmake_error_lines(output)) reason += std::format("; {}", line); + if (const std::string log { xmake_install_log(output) }; !log.empty()) reason += std::format("; its log is {}", log); + reason += "; installing them with your system package manager also works, because xmake uses the system's when it finds them"; + return reason; +} + bool xmake_unknown_option(std::string_view output) { const std::string lower { base::to_lower_ascii(output) }; return lower.contains("invalid option") || lower.contains("unknown option"); @@ -328,13 +479,14 @@ std::string xmake_configuration_key(bool offline, std::optional xmake_configure_arguments(std::string_view buildDirectory, bool offline, - std::span user, bool reduced) { + std::span user, bool reduced, std::span accepted) { std::vector arguments { "f", "-c" }; // X-2: what the user chose with their own `xmake f`. An option that is internal, unnamed in a form `xmake f` takes, or // empty is not passed on; a retry (`reduced`) passes the standard ones only. const auto wanted = [&](const XmakeOption& option) { return !option.name.empty() && !xmake_option_is_internal(option.name) && !(reduced && !xmake_option_is_standard(option.name)) - && !option.value.empty() && std::ranges::all_of(option.name, [](char c) { return base::is_identifier_char(c) || c == '-'; }); + && !option.value.empty() && std::ranges::all_of(option.name, [](char c) { return base::is_identifier_char(c) || c == '-'; }) + && (accepted.empty() || xmake_option_is_standard(option.name) || std::ranges::contains(accepted, option.name)); }; const auto value_of = [](const XmakeOption& option) { return option.flag ? std::string { option.value == "true" ? "y" : "n" } : option.value; }; // plat, arch and mode have short options, given in that order however the file lists them. @@ -437,7 +589,7 @@ Answer XmakeProvider::describe(const Claim& claim, const ProviderContext& contex Answer fallback { read_project_database(claim, context) }; if (!fallback.database) return answer; context.producerUsed.clear(); // the model is the file's, not xmake's - if (answer.outcome == Outcome::needs_download) fallback.database->issues.emplace_back(std::string { spec::NEEDS_DOWNLOAD }, answer.reason); + if (answer.outcome == Outcome::needs_download || answer.code == spec::INSTALL_FAILED) fallback.database->issues.emplace_back(answer.code, answer.reason); return fallback; } diff --git a/src/project/xmake.cppm b/src/project/xmake.cppm index 1156e908..8b405ced 100644 --- a/src/project/xmake.cppm +++ b/src/project/xmake.cppm @@ -50,14 +50,39 @@ bool xmake_option_is_standard(std::string_view name); // What a reduced configuration leaves out of `user`: the names that are not internal and not standard. std::vector xmake_left_out(std::span user); +// D-1 (plan 0.0.9): the long option names `xmake f --help` lists (`--plat=PLAT`, `--ccache=[y|n]`, and the project's own +// `option()`s under "Command options (Project Configuration)"), sorted, without the leading dashes. An option line is indented +// by at most eight spaces; the deeper lines are descriptions and examples (`- xmake f --import=...`) and are not read. +std::vector xmake_help_options(std::string_view text); + +// The keys of `user` that are not internal, are not standard and are not in `accepted`: what xmake wrote into xmake.conf for +// itself (`proxy`, `dotnet`, `dotnet_sdkver`, ...) and `xmake f` refuses. Empty `accepted` (no help text) skips nothing. +std::vector xmake_not_accepted(std::span user, std::span accepted); + +// The last `limit` lines of `output` that say `error:`, in order, trimmed (what a failed install says in xmake's own words). +std::vector xmake_error_lines(std::string_view output, std::size_t limit = 3); + +// The packages xmake says it failed to install: `error: install libtool failed!` names `libtool`. +std::vector xmake_failed_installs(std::string_view output); + +// The `.../installdir.failed/logs/install.txt` path xmake names when a package fails to install, or empty. +std::string xmake_install_log(std::string_view output); + +// The reason of the needs-download outcome (D-3) and of an install that failed with the network allowed (D-2). +std::string xmake_needs_download_reason(std::span missing); +// `missing` may be empty (xmake named no package): the reason then speaks of the packages the project requires. +std::string xmake_install_failed_reason(std::span missing, std::string_view output); + // `xmake f -c [-p plat -a arch -m mode] [--name=value ...] --confirm=no --policies=package.fetch_only,network.mode:private // --builddir=/build` offline (measured: `network.mode:private` is what stops xmake's package repositories being // pulled on a machine that has never fetched them, not `package.fetch_only` alone -- plan §3.5); online, `-y` replaces the // two policy arguments (no fetch-only policy, so a package that is missing installs instead of only being looked for). // `user` is the user's own configuration (X-2): `xmake f` resets every option it is not given, so the private run is given them // all (E5: a debug project described as release otherwise); `reduced` keeps only the standard ones, for the retry. +// D-1: when `accepted` (xmake_help_options) is not empty, a key outside it and outside the standard ones is not passed on. std::vector xmake_configure_arguments(std::string_view buildDirectory, bool offline, - std::span user = {}, bool reduced = false); + std::span user = {}, bool reduced = false, + std::span accepted = {}); // xmake refused an option it does not know ("Invalid option: --x=1", exit 255 with v3.1.1). bool xmake_unknown_option(std::string_view output); diff --git a/src/server/session.cpp b/src/server/session.cpp index f4c60bb3..9f8cef11 100644 --- a/src/server/session.cpp +++ b/src/server/session.cpp @@ -13,6 +13,7 @@ import mcppls.platform.dirs; import mcppls.platform.toolenv; import mcppls.platform.toolrun; import mcppls.platform.fs; +import mcppls.platform.process; import mcppls.platform.stdio; import mcppls.platform.task; import mcppls.config.settings; @@ -115,6 +116,26 @@ class Session { } }.detach(); } + // P-1 (plan 0.0.9): a server whose client is gone leaves as if its input had closed. The LSP gives the + // client's process id in initialize for this; an editor that ends its extension host without closing the + // server's input (issue #37's bundle: earlier servers still held the workspace, so the next one started cold + // in a private directory) is noticed within CLIENT_WATCH. Only a process this one could see at the start is + // watched: in a container or under PRoot the id may name nothing here, and that is not a client gone. + void start_client_watch_(const Json& processId) { + static constexpr std::chrono::seconds CLIENT_WATCH { 5 }; + if (!processId.is_number_integer()) return; + const std::int64_t pid { processId.get() }; + if (platform::process_alive(pid) != std::optional { true }) return; + std::thread { [events = events_, pid] { + while (true) { + std::this_thread::sleep_for(CLIENT_WATCH); + if (platform::process_alive(pid) == std::optional { false }) break; + } + log::info("the client's process {} is gone", pid); + events->push(Event { EventKind::client_closed }); + } }.detach(); + } + void reply_(const Json& id, Json result) { client_.reply(id, std::move(result)); } void reply_error_(const Json& id, int code, std::string_view message) { client_.reply_error(id, code, message); } @@ -308,6 +329,7 @@ class Session { clientInitializeId_ = id; clientParams_ = params; clientCapabilities_ = params.value("capabilities", Json::object()); + start_client_watch_(params.value("processId", Json {})); // config settings §9 T1: initializationOptions layered over the command line, on the very // same registry-backed object that layer already applied to (a value the command line set // is immune to this one, and to every later workspace/didChangeConfiguration). Every field @@ -355,7 +377,7 @@ class Session { // each layer (`apply_initialization_options` above, `apply_configuration_change` below). void sync_settings_() { const auto& settings = options_.settings; - options_.engine = settings.string_value("engine"); + options_.engine = settings.string_value("engine.name"); options_.buildTool = settings.string_value("buildTool"); options_.toolEnvironment = settings.string_value("toolEnvironment"); options_.discoverCompilers = settings.bool_value("discoverCompilers"); @@ -367,7 +389,7 @@ class Session { options_.disabledWorkarounds = settings.list_value("disableWorkaround"); options_.semanticTokensModules = settings.bool_value("semanticTokens.modules"); options_.semanticTokensModuleType = settings.bool_value("semanticTokens.moduleType"); - options_.buildDiscovery = settings.string_value("buildDiscovery"); + options_.buildDiscovery = settings.string_value("buildDiscovery.mode"); options_.buildDiscoveryProviders = settings.list_value("buildDiscovery.providers"); options_.buildDiscoveryAskBeforeDownload = settings.bool_value("buildDiscovery.askBeforeDownload"); options_.primeImplementationUnits = settings.string_value("index.primeImplementationUnits"); diff --git a/src/spec/discovery.cppm b/src/spec/discovery.cppm index e018ce9c..c7c3b2b2 100644 --- a/src/spec/discovery.cppm +++ b/src/spec/discovery.cppm @@ -61,6 +61,9 @@ struct DatabaseDocument { inline constexpr std::string_view OFFLINE_DOWNLOAD_REQUIRED { "MCPP_OFFLINE_DOWNLOAD_REQUIRED" }; // The error code this server fails with when that happened: the model stays, the user gets an action. inline constexpr std::string_view NEEDS_DOWNLOAD { "producer-needs-download" }; +// D-2 (plan 0.0.9): the same packages with the network allowed: the install was tried and failed, so a download is not what is +// missing and offering one again would repeat the failure. The reason names the packages and what the build tool said. +inline constexpr std::string_view INSTALL_FAILED { "producer-install-failed" }; struct ProducerProtocol { std::map> kinds; // kind -> version diff --git a/tests/test_project.cpp b/tests/test_project.cpp index 33383987..99b50245 100644 --- a/tests/test_project.cpp +++ b/tests/test_project.cpp @@ -8,6 +8,7 @@ import mcppls.base.text; import mcppls.platform.fs; import mcppls.platform.dirs; import mcppls.spec.database; +import mcppls.spec.discovery; import mcppls.spec.kit; import mcppls.spec.metadata; import mcppls.toolchain.probe; @@ -473,6 +474,88 @@ int main() { expect(!p::xmake_unknown_option("The packages(fmt) not found")); }; + "D-1: the options `xmake f --help` lists are the ones xmake.conf keys may be passed as"_test = [] { + // Cut from `XMAKE_THEME=plain xmake f --help` (xmake v3.1.1, 2026-10-01) in a project that declares `fancy` and `level`. + const std::string help { + "Usage: $xmake config|f [options]\n\nConfigure the project.\n\nCommon options:\n" + " -q, --quiet Quiet operation.\n" + " -y, --yes Input yes by default if need user confirm.\n" + " --confirm=CONFIRM Input the given result if need user confirm.\n" + " - yes\n" + " - no\n" + " -D, --diagnosis Print lots of diagnosis information.\n" + " And we can append -v to get more whole information.\n" + " e.g. $ xmake -vD\n" + " --import=IMPORT Import configs from the given file.\n" + " e.g.\n" + " - xmake f --import=build/config.txt\n" + " -p PLAT, --plat=PLAT Compile for the given platform. (default: auto)\n" + " - linux\n" + " -a ARCH, --arch=ARCH Compile for the given architecture. (default: auto)\n" + " -m MODE, --mode=MODE Set the given compilation mode. (default: release)\n" + " --toolchain=TOOLCHAIN Set toolchains.\n" + "\nCommand options (Other Configuration):\n" + " --ccache=[y|n] Enable or disable the c/c++ compiler cache. (default: y)\n" + " --tryconfigs=TRYCONFIGS Set the extra configurations of the third-party buildsystem for the try-build mode.\n" + " e.g.\n" + " - xmake f --trybuild=autoconf --tryconfigs='--enable-shared=no'\n" + " -o BUILDDIR, --builddir=BUILDDIR Set build directory. (default: build)\n" + "\n\nCommand options (Project Configuration):\n\n" + " --fancy=[y|n] Fancy\n" + " --level=LEVEL Level of something which has a very long description that wraps around the line (default: 3)\n" }; + const auto names = p::xmake_help_options(help); + expect(names == (std::vector { "arch", "builddir", "ccache", "confirm", "diagnosis", "fancy", "import", "level", "mode", + "plat", "quiet", "toolchain", "tryconfigs", "yes" })) + << b::join(names, " "); + // Nothing of a help text that is not one, nor of the example lines inside one. + expect(p::xmake_help_options("").empty()); + expect(p::xmake_help_options("error: Invalid option: --proxy=x\n").empty()); + expect(!std::ranges::contains(names, std::string { "trybuild" })) << "an example line is not an option line"; + + // What xmake wrote into xmake.conf for itself is skipped; a project option and the standard ones pass. + const std::vector user { + { "plat", "linux", false }, { "mode", "debug", false }, { "proxy", "127.0.0.1:7890", false }, { "dotnet", "8", false }, + { "dotnet_sdkver", "8.0", false }, { "fancy", "true", true }, { "level", "5", false }, { "kind", "static", false } }; + const auto arguments = p::xmake_configure_arguments("/c/build", true, user, false, names); + expect(arguments == (std::vector { "f", "-c", "-p", "linux", "-m", "debug", "--fancy=y", "--level=5", "--kind=static", + "--confirm=no", "--policies=package.fetch_only,network.mode:private", "--builddir=/c/build" })) + << b::join(arguments, " "); + expect(p::xmake_not_accepted(user, names) == (std::vector { "proxy", "dotnet", "dotnet_sdkver" })); + // No help text: the options are passed as before, and the reduced retry stays the fallback. + expect(p::xmake_not_accepted(user, {}).empty()); + expect(p::xmake_configure_arguments("/c/build", true, user, false, {}) == p::xmake_configure_arguments("/c/build", true, user)); + }; + + "D-2, D-3: the missing packages, said for an offline run and for an install that failed"_test = [] { + const std::vector missing { "libtool", "libpthread-stubs" }; + const auto offline = p::xmake_needs_download_reason(missing); + expect(offline.contains("libtool, libpthread-stubs")) << offline; + expect(offline.contains("build tools")) << offline; + expect(offline.contains("stayed offline")) << offline; + expect(offline.contains("system package manager")) << offline; + expect(!offline.contains('\n')) << "one paragraph"; + + const std::string output { + " -> libtool 2.4.7: xmake.lua:3\n" + "error: autoreconf: command not found\n" + "error: install libtool failed!\n" + "note: see /home/u/.xmake/cache/packages/2510/l/libtool/2.4.7/installdir.failed/logs/install.txt.\n" }; + expect(p::xmake_error_lines(output) == (std::vector { "error: autoreconf: command not found", "error: install libtool failed!" })); + expect(p::xmake_error_lines(output, 1) == (std::vector { "error: install libtool failed!" })); + expect(p::xmake_install_log(output) == "/home/u/.xmake/cache/packages/2510/l/libtool/2.4.7/installdir.failed/logs/install.txt") + << p::xmake_install_log(output); + expect(p::xmake_install_log("error: x\n").empty()); + expect(p::xmake_failed_installs(output) == (std::vector { "libtool" })); + expect(p::xmake_failed_installs("error: install libtool failed!\nerror: install libtool failed!\nerror: install zlib failed\n") + == (std::vector { "libtool", "zlib" })); + expect(p::xmake_failed_installs("error: autoreconf: command not found\n").empty()); + expect(p::xmake_install_failed_reason({}, output).contains("the packages this project requires")); + const auto failed = p::xmake_install_failed_reason(missing, output); + expect(failed.contains("libtool, libpthread-stubs") && failed.contains("install libtool failed!") && failed.contains("installdir.failed/logs/install.txt")) << failed; + expect(!failed.contains("stayed offline")) << failed; + expect(s::INSTALL_FAILED == std::string_view { "producer-install-failed" }); + }; + "X-2: the newest .xmake///xmake.conf is the user's configuration, read only"_test = [] { const std::string root { make_root("xmake-conf") }; expect(!p::read_xmake_user_config(root).has_value()) << "no .xmake/: today's behaviour"; diff --git a/tests/test_server.cpp b/tests/test_server.cpp index ef665569..3858f150 100644 --- a/tests/test_server.cpp +++ b/tests/test_server.cpp @@ -1342,6 +1342,11 @@ int main() { std::this_thread::sleep_for(3200ms); engine->handle_timers(); host.pump(*engine); + // P-3 (plan 0.0.9): the old clangd is stopped off the event loop; the new one starts when it is gone. + for (int round { 0 }; round < 40 && starts() < 2; ++round) { + std::this_thread::sleep_for(50ms); + host.pump(*engine); + } expect(starts() == 2) << "restarted once typing paused"; engine->shut_down(); fs::remove_all(root); diff --git a/tests/test_settings.cpp b/tests/test_settings.cpp index ff7d0397..e44655c1 100644 --- a/tests/test_settings.cpp +++ b/tests/test_settings.cpp @@ -134,7 +134,7 @@ int main() { // The whole `initialize` params works too: only its `initializationOptions` key is read. settings::Settings values; values.apply_initialization_options(Json { { "initializationOptions", Json { { "engine", "none" } } }, { "capabilities", Json::object() } }); - expect(values.string_value("engine") == "none"); + expect(values.string_value("engine.name") == "none") << "the earlier name `engine` is an alias"; }; "an unknown enumeration value falls back to the default and is recorded as a problem, never silently"_test = [] { @@ -204,6 +204,7 @@ int main() { seen.insert(key); const settings::Setting* row { settings::find(settings::registry(), key) }; expect(fatal(row != nullptr)) << name << " is in package.json but not the registry"; + expect(row->key == key) << name << " is a registry alias; package.json carries the row's current name"; expect(row->surface == settings::Surface::server || row->surface == settings::Surface::client) << name; expect(row->clientConfigurable) << name << " is in package.json but the registry does not expect it there"; @@ -233,5 +234,84 @@ int main() { } }; + + // S-4 (plan 0.0.9): what made `mcppls.engine.workers` show `undefined` in VS Code's settings UI + // cannot come back unnoticed. + "package.json: no setting is the parent of another unless it is an object, and a pattern is satisfied by its own default"_test = [] { + const auto text = fs::read_file(base::join_path(repository_root(), "editors/vscode/package.json")); + expect(fatal(text.has_value())); + const Json manifest = Json::parse(*text); + const Json& properties = manifest.at("contributes").at("configuration").at("properties"); + std::vector names; + for (const auto& entry : properties.items()) names.push_back(entry.key()); + for (const auto& parent : names) { + if (properties.at(parent).value("type", std::string {}) == "object") continue; + for (const auto& other : names) { + expect(!other.starts_with(parent + ".")) << parent << " is not an object, so " << other << " cannot live under it"; + } + } + for (const auto& entry : properties.items()) { + const Json& schema { entry.value() }; + if (!schema.contains("pattern")) continue; + const std::string& name { entry.key() }; + expect(schema.contains("patternErrorMessage")) << name << " has a pattern and no patternErrorMessage"; + if (!schema.contains("default") || !schema.at("default").is_string()) continue; + const std::regex pattern { schema.at("pattern").get(), std::regex::ECMAScript }; + expect(std::regex_search(schema.at("default").get(), pattern)) << name << "'s default does not match its own pattern"; + } + }; + + "null in initializationOptions is not set, and in didChangeConfiguration puts the key back to its default (S-3)"_test = [] { + settings::Settings values; + values.apply_initialization_options(Json { { "compiler", nullptr }, { "engine.name", nullptr }, { "buildDiscovery", nullptr } }); + expect(values.problems().empty()); + expect(values.origin("compiler") == settings::Origin::defaulted); + expect(values.origin("engine.name") == settings::Origin::defaulted); + + values.apply_initialization_options(Json { { "compiler", "/usr/bin/clang++" }, { "engine.workers", "4" } }); + expect(values.string_value("compiler") == "/usr/bin/clang++"); + auto result = values.apply_configuration_change(Json { { "settings", Json { { "mcppls", Json { { "compiler", nullptr } } } } } }); + expect(values.problems().empty()); + expect(values.string_value("compiler").empty()); + expect(values.origin("compiler") == settings::Origin::defaulted); + expect(std::ranges::find(result.changedKeys, std::string { "compiler" }) != result.changedKeys.end()); + expect(values.string_value("engine.workers") == "4") << "a key the change does not mention is left alone"; + result = values.apply_configuration_change(Json { { "compiler", nullptr } }); + expect(result.changedKeys.empty()) << "clearing what is already the default changes nothing"; + }; + + "the earlier names engine and buildDiscovery are still read, flat or nested, and never collide with their sub-settings (S-1)"_test = [] { + for (const Json& shape : { + Json { { "engine", "none" }, { "buildDiscovery", "off" } }, + Json { { "engine.name", "none" }, { "buildDiscovery.mode", "off" } }, + Json { { "engine", Json { { "name", "none" } } }, { "buildDiscovery", Json { { "mode", "off" } } } }, + Json { { "mcppls", Json { { "engine", "none" }, { "buildDiscovery", "off" } } } }, + }) { + settings::Settings values; + values.apply_initialization_options(shape); + expect(values.problems().empty()) << shape.dump(); + expect(values.string_value("engine.name") == "none") << shape.dump(); + expect(values.string_value("buildDiscovery.mode") == "off") << shape.dump(); + expect(values.origin("engine.name") == settings::Origin::client) << shape.dump(); + } + // A nested object under the old name is the sub-settings' own value, not a wrong kind of value for the parent. + settings::Settings values; + values.apply_initialization_options(Json { { "engine", Json { { "workers", "4" } } }, + { "buildDiscovery", Json { { "providers", Json::array({ "cmake" }) }, { "askBeforeDownload", false } } } }); + expect(values.problems().empty()); + expect(values.string_value("engine.workers") == "4"); + expect(values.string_value("engine.name") == "clangd"); + expect(values.origin("buildDiscovery.mode") == settings::Origin::defaulted); + expect(values.list_value("buildDiscovery.providers") == std::vector { "cmake" }); + expect(!values.bool_value("buildDiscovery.askBeforeDownload")); + + const auto result = values.apply_configuration_change( + Json { { "settings", Json { { "engine", Json { { "workers", "2" } } }, { "buildDiscovery", "off" } } } }); + expect(values.problems().empty()); + expect(values.string_value("engine.workers") == "2"); + expect(values.string_value("buildDiscovery.mode") == "off"); + expect(std::ranges::find(result.reloadKeys, std::string { "buildDiscovery.mode" }) != result.reloadKeys.end()); + }; + return report(); } diff --git a/tests/test_workarounds.cpp b/tests/test_workarounds.cpp index 23395530..3505ae97 100644 --- a/tests/test_workarounds.cpp +++ b/tests/test_workarounds.cpp @@ -3,6 +3,8 @@ import mcppls.testing; import mcppls.engine; import mcppls.engine.clangd; import mcppls.engine.clangd.workarounds; +import mcppls.normalize.plan; +import mcppls.engine.clangd.process; namespace cld = mcppls::engine::clangd; @@ -131,4 +133,86 @@ int main() { expect(!cld::module_not_found_name("Header 'x' not found").has_value()); expect(!cld::module_not_found_name("Module '' not found").has_value()); }; + + "a view without a const begin() is not told to be const (WA-CLANGD-010)"_test = [] { + // What clangd 23.1.0 said on issue #37's code (vulkan-rt) and its reductions, libstdc++ 16 and the kit's libc++. + for (const std::string_view message : { + // libstdc++: `auto v = in | std::views::filter(keep);` then `v | std::views::transform(...)` + "Variable 'device_local_heaps' of type 'typename __invoke_result &, const (lambda at /p/device.cpp:399:58) &>::type' (aka " + "'std::ranges::filter_view>, (lambda at " + "/p/device.cpp:399:58)>') can be declared 'const' (fix available)", + // libc++ prints the bare name after `aka` + "Variable 'v' of type 'invoke_result_t>>, const std::array &>' (aka 'filter_view>, bool " + "(*)(const H &)>') can be declared 'const' (fix available)", + // filter_view spelled as the type, with no `aka` + "Variable 'v' of type 'std::ranges::filter_view>, (lambda at /p/c.cpp:29:35)>' " + "can be declared 'const'", + // a transform over a filter: its const begin() needs a const-iterable filter + "Variable 'v' of type 'typename __invoke_result>, bool (*)(const H &)>, unsigned long H::*const &>::type' (aka 'std::ranges::transform_view<" + "std::ranges::filter_view>, bool (*)(const H &)>, unsigned long H::*>') can be " + "declared 'const' (fix available)", + "Variable 'v' of type 'X' (aka 'std::ranges::drop_while_view>, (lambda at " + "/p/c.cpp:25:35)>') can be declared 'const' (fix available)", + "Variable 'v' of type 'X' (aka 'std::ranges::chunk_by_view>, std::ranges::less>') " + "can be declared 'const' (fix available)", + "variable 'v' of type 'std::ranges::split_view, std::ranges::single_view>' can be " + "declared 'const'", + }) { + expect(cld::const_correctness_on_non_const_view(message)) << message; + } + for (const std::string_view message : { + // a plain variable, and a transform_view over an array: const would compile, so the advice stands + "Variable 'n' of type 'int' can be declared 'const' (fix available)", + "Variable 'v' of type 'typename __invoke_result &, unsigned " + "long H::*const &>::type' (aka 'std::ranges::transform_view>, unsigned long " + "H::*>') can be declared 'const' (fix available)", + // a view over a ref_view of a filter: ref_view's const begin() reaches the filter as it is + "Variable 'v' of type 'X' (aka 'std::ranges::transform_view>, bool (*)(const H &)>>, unsigned long H::*>') can be declared 'const' (fix available)", + // lazy_split_view is const-iterable over a forward range; a filter only inside an argument is no base + "Variable 'v' of type 'std::ranges::lazy_split_view, std::ranges::single_view>' " + "can be declared 'const'", + "Variable 'v' of type 'std::vector>, P>>' can be " + "declared 'const'", + "Variable 'v' of type 'mine::filter_view' can be declared 'const'", + // not this check's message at all + "Pointee of variable 'p' of type 'int *' can be declared 'const'", + "", + }) { + expect(!cld::const_correctness_on_non_const_view(message)) << message; + } + expect(cld::traits_for_version("23.1.0").flagsNonConstViewsConst); + const std::vector off { std::string { cld::CONST_CORRECTNESS_VIEWS } }; + expect(!cld::traits_for_version("23.1.0", off).flagsNonConstViewsConst) << "it can be turned off"; + }; + + "a project without modules gets clangd without its modules support (WA-CLANGD-009)"_test = [] { + namespace nz = mcppls::normalize; + nz::EnginePlan plan; + plan.entries.push_back(nz::EngineEntry {}); + plan.entries.back().file = "/p/device.cpp"; + expect(!cld::plan_uses_modules(plan)) << "headers and sources only"; + const auto with = [&](auto change) { + nz::EnginePlan copy { plan }; + change(copy); + return cld::plan_uses_modules(copy); + }; + expect(with([](nz::EnginePlan& p) { p.entries.back().imports = { "std" }; })) << "import std;"; + expect(with([](nz::EnginePlan& p) { p.entries.back().provides = "hello"; })); + expect(with([](nz::EnginePlan& p) { p.entries.back().module = "hello"; })) << "an implementation unit"; + expect(with([](nz::EnginePlan& p) { p.stdUnits = 2; })); + expect(with([](nz::EnginePlan& p) { p.stubModules = { "missing" }; })) << "an import nothing provides"; + expect(with([](nz::EnginePlan& p) { p.issues.push_back(nz::PlanIssue { .code = "unresolved-module", .module = "missing" }); })); + expect(cld::traits_for_version("23.1.0").scansModulesOnEveryRequest); + cld::ProcessConfig config; + auto arguments = cld::clangd_arguments(config); + expect(std::ranges::find(arguments, std::string { "--experimental-modules-support" }) != arguments.end()) << "on by default"; + config.modulesSupport = false; + arguments = cld::clangd_arguments(config); + expect(std::ranges::find(arguments, std::string { "--experimental-modules-support" }) == arguments.end()); + expect(std::ranges::find(arguments, std::string { "--background-index" }) != arguments.end()) << "the rest stays"; + }; }