Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
546 changes: 546 additions & 0 deletions .agents/docs/2026-10-13-answer-cache-layer-design.md

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions .agents/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ editor / coding agent / CI
— module-name navigation, `import` completion, the module graph, unresolved / ambiguous /
cross-module-partition diagnostics — from a syntax index it owns. clangd answers C++ semantics from
a database mcppls writes for it. A clice engine was considered and is not implemented.
- **An answer layer in front of clangd** (`src/orchestrator/answer`, `answering`): what clangd answered for a
completion, a hover, signature help or a jump is kept under the place, the receiver's learned type and the
qualifier's spelled-out name, with a certainty (the file's visibility fingerprint, the enclosing text, the other
files' changes, the provider's generation). When clangd is slower than `mcppls.answer.budget` a kept answer goes
out -- marked incomplete, doubted items with a small mark only -- and clangd's own answer replaces it; for agents
every response says where it came from (`provenance`). clangd stays the one source of correctness; the layer only
decides what an earlier correct answer is worth now.
- **One process shape per client**: `mcppls serve` (LSP over stdio), `mcppls mcp` (MCP over stdio, or
`--daemon` to share one workspace between several agents), and one-shot commands (`mcppls check`,
`report`, `cache`, `symbol`, `review`, ...).
Expand Down Expand Up @@ -395,5 +402,11 @@ close at once · C-2 late completions reach their word · C-4 slow files and bui
followed without touching the project · X-3 the build compiler's own standard · X-4 a standard library unit that does
not scan · I-1 include cleaner stays on · CL-1..CL-4 CLion 2026.2.3, one engine per file · Z-1..Z-3 Zed in CI.

**"answer-layer design" — [2026-10-13-answer-cache-layer-design.md](2026-10-13-answer-cache-layer-design.md).** 4 the
keys K1–K3, certainty (4.3 the visibility fingerprint), the budget, dedup and background (4.8 prefetch, 4.10 nothing
kept from a failing engine) · 6.1 marks in the editor · 6.2 provenance for agents and the command line · 6.4 logs · 7
the engine side (E1 module input rechecks, E2 the preamble load, E4 interactive slots, E5 retained ASTs) · R3 the
`cxxModules/completionReady` notification.

**"tooling architecture".** 3.2 the workspace layout · 5.1 what mcpp, mcppls and devtools each do ·
5.5 how devtools finds the server it just built · M0–M6 its migration steps.
54 changes: 54 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,60 @@ 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.15] — 2026-10-13

The bundled engine is [mcppls-clangd 0.0.16](https://github.com/Sunrisepeak/mcppls-clangd/releases/tag/v0.0.16)
(`23.1.0-mcppls.4`): 35 patches, three of them new. This release is about answering at once what was answered before:
clangd stays the one source of correctness, and mcppls decides what an earlier correct answer is worth now.

### Answers in under half a second

- **What clangd answered before stands in while it is slow.** Completion, hover, signature help and go-to-definition
keep clangd's answers under the place they were for, the type of what is before the `.` (learned from clangd in the
background), and the name before the `::` with its aliases spelled out -- so `std::` asked in one file is answered in
another, and a list typed away and asked for again is there at once. When clangd is slower than
`mcppls.answer.budget` (450 ms), the kept answer goes out, and clangd's own replaces it as you type on; where clangd
has lately answered a file within its usual budget, mcppls waits just past clangd's pace instead, so clangd's complete
list is what goes out. A kept list goes out complete only when nothing it depends on changed; a request you cancel
goes on in the background and is kept for the next time. Kept answers survive a restart
(`mcppls.answer.cache`: `on`, `memory`, `off`). While clangd is idle, mcppls asks it in advance for the places after
a `.`, `->` or `::` near the cursor that have no answer yet (`mcppls.answer.prefetch`).
- **You do not see it.** A list given so looks like clangd's own. Only an item mcppls doubts -- a member of a class
whose text changed since -- carries a small `·` after its description, and the members the class's text added or
removed are corrected in the list (`mcppls.completion.provenance`: `subtle`, `off`, `verbose`). A jump is given from
an earlier answer only while the file it lands in is as it was, or the declaration is found there again exactly once.
- **A list that came up empty comes back by itself.** When a completion after `.`, `->` or `::` had almost nothing in
time and clangd's answer arrives while you are still at the same place, VS Code shows the list without a keystroke
(`cxxModules/completionReady`, S3 5.9).
- **Agents and the command line are told which answers are which.** A symbol or references result that rests on a
kept answer says so in `provenance` (`authoritative`, the source, how old, whether the file that declares it
changed). `fresh: true` (MCP) and `--fresh` (`mcppls query symbol|refs|calls`) wait for clangd's own answers;
verify, impact and review always do; `mcppls.answer.agent: authoritative` makes every query wait.

### The engine

- **A check that found a module's inputs as they were holds for 10 s** while no file change is reported
(`--module-input-recheck`): an importer of 25 modules no longer re-checks every input at every completion, which cost
0.7-1.0 s on Windows. A save, a change on disk the editor reports or an edit with dirty headers checks again at once.
- **A file that imports no module completes as without module support.** Sema no longer loads the preamble's
declarations the index already has (a header-heavy file: 0.6-0.7 s per completion to about 0.4 s); an import typed
since, or brought in by a header, is still loaded.
- **Completion and signature help have two slots of their own** (`--interactive-actions`), instead of queueing behind
other files' builds while modules are prepared.
- With 15 GiB or more, eight ASTs of files you switch between are kept; with 31 GiB or more, twelve.

### Seeing what happens

- The log says, at `info`, only the answers worth reading: one given from an earlier answer while something it depends
on changed, and clangd's answer when it differed from what went out (`diff=-<n>/+<n>`). The report's `answers` counts
how often, how fast and how often clangd's answer differed. See the troubleshooting guide.

### Documentation

- A new guide, [Laying out a module project](docs/25-module-projects.md): wrapper modules for heavy headers, each header
in one place, `import std` without standard `#include`s, a minimal global module fragment, bodies in implementation
units, no re-export chains, and why each makes a large mixed project faster to build and to complete in.

## [0.0.14] — 2026-10-13

The bundled engine is [mcppls-clangd 0.0.15](https://github.com/Sunrisepeak/mcppls-clangd/releases/tag/v0.0.15)
Expand Down
91 changes: 91 additions & 0 deletions conformance/traceability.json
Original file line number Diff line number Diff line change
Expand Up @@ -2246,5 +2246,96 @@
"script": "editors/vscode/src/cacheHub.ts",
"contains": "about?.cacheDevDrive === false"
}
],
"S3-5.9-1": [
{
"test": "tests/test_answer_routing.cpp: a client that did not declare completionReady is not sent it"
}
],
"S3-5.9-2": [
{
"test": "tests/test_answer_routing.cpp: a slow engine's answer is kept, and given at the answer budget the next time"
},
{
"test": "tests/test_answer_routing.cpp: once the person typed on, an answer is not called up again"
}
],
"S3-5.9-3": [
{
"script": "editors/vscode/src/completionGate.ts",
"contains": "export function completionStillWanted("
}
],
"S3-5.9-4": [
{
"script": "editors/vscode/src/extension.ts",
"contains": "void vscode.commands.executeCommand('editor.action.triggerSuggest');"
}
],
"S3-6.3-1": [
{
"test": "tests/test_answer.cpp: a kept completion is filtered by the word, placed on it, and incomplete"
},
{
"test": "tests/test_answer_routing.cpp: a slow engine's answer is kept, and given at the answer budget the next time"
},
{
"test": "tests/test_answer.cpp: a kept completion is complete only when certain, complete, and asked for a word the one typed now extends"
},
{
"test": "tests/test_answer_routing.cpp: a complete list nothing changed under is given complete, so the editor filters it"
}
],
"S3-6.3-2": [
{
"test": "tests/test_answer.cpp: hover is placed on the word; signature help on the argument; jumps only when certain"
},
{
"test": "tests/test_answer_routing.cpp: a hover and a jump are kept; a jump only while the file it lands in is as it was"
}
],
"S3-6.3-3": [
{
"script": "src/orchestrator/workspace.cpp",
"contains": "return !status.failed && status.state != \"unavailable\";"
}
],
"S3-6.3-4": [
{
"test": "tests/test_answer.cpp: a class that changed: members added and removed by its outline, marked; only those"
}
],
"S5-2.6-1": [
{
"test": "tests/test_answer_routing.cpp: an agent is told where every answer came from"
},
{
"script": "src/ai/mcp/tools.cpp",
"contains": "result.value[\"provenance\"] = std::move(provenance);"
}
],
"S5-2.6-2": [
{
"test": "tests/test_answer_routing.cpp: an agent is told where every answer came from"
},
{
"script": "src/cli/query.cpp",
"contains": "view.require_authoritative(args.is_flag_set(\"fresh\"));"
}
],
"S5-2.6-3": [
{
"script": "src/ai/mcp/tools.cpp",
"contains": "name == \"cxx_review\" || name == \"cxx_verify\" || name == \"cxx_impact\""
},
{
"script": "src/cli/query.cpp",
"contains": "view.require_authoritative(true);"
}
],
"S5-2.6-4": [
{
"test": "tests/test_answer_routing.cpp: with mcppls.answer.agent authoritative, an agent always waits for the engine"
}
]
}
3 changes: 3 additions & 0 deletions docs/20-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,6 @@ else. The semantic kit provides the semantics and the status says why.
| "needs a download" | Planning offline stopped at something not on the machine; the project is served from its sources meanwhile, and there are actions to fix it (or build in your terminal: it upgrades by itself) |
| "files the build generates … do not exist yet" | A rule's output (a Qt form's header, say) is not built yet; build once and the files that include it get their semantics |
| "implementation unit(s) cannot be read" | An implementation unit does not build (a missing header, usually), so go-to-definition cannot reach the definitions in it |

How to lay out a module project so it builds and completes fast — wrapper modules, each header in
one place, `import std` without standard `#include`s — is in [25-module-projects.md](25-module-projects.md).
199 changes: 199 additions & 0 deletions docs/25-module-projects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Laying out a module project

**English** | [简体中文](zh-CN/25-module-projects.md)

Everything here builds without it. These are the layouts that keep a large project — modules,
headers and third-party code mixed — fast to build, fast to complete in, and easy to keep
consistent. Each comes with the reason, so you can tell when it does not apply.

## What a module costs, and when

A module interface is compiled once into a BMI (a binary module interface). A file that imports it
loads the BMI instead of parsing the source again. Two costs follow from that:

- **Building.** Compiling against a BMI is much cheaper than parsing the headers it was made from:
in a mid-sized project we measured, a unit compiled about four times faster against a wrapper
module than against the same headers included directly. But an edit to an interface rebuilds its
BMI, and the BMIs of everything that imports it, directly or not.
- **Editing.** A completion, a hover or a jump in a file that imports modules loads the BMIs of the
file's whole import closure for that request. The fewer and smaller those BMIs, the faster the
answer. A closure with each heavy header in one place loads each declaration once; a closure where
ten modules carry their own copy of the same header loads ten copies, and the compiler merges them
on every request.

The rules below follow from these two costs.

## 1. Each heavy header in exactly one wrapper module

Put each large third-party header (a JSON or TOML library, a GUI toolkit, `vulkan.hpp`) in the
global module fragment of one module of its own, and export the names your code uses:

```cpp
// src/third_party/toml.cppm
module;
#include <toml.hpp>
export module third_party.toml;

export namespace toml {
using toml::value;
using toml::parse;
using toml::find;
}
```

Every other module imports `third_party.toml` and never includes `<toml.hpp>` itself.

**Why.** A header included in the global module fragment of a module becomes part of that module's
BMI. Included in ten modules, it is in ten BMIs, and a file importing several of them loads every
copy and merges the duplicates — on every build of that file and on every completion in it. In one
wrapper it is parsed once, stored once, loaded once.

## 2. Do not mix `#include` of the standard library with `import std`

In a unit that imports `std`, include no standard header: not directly, and not through a header
that includes one. A third-party header that needs the standard library goes into its own wrapper
(rule 1), where its `#include <vector>` stays in the wrapper's global module fragment.

```cpp
// avoid: the same declarations from two places
module;
#include <vector>
#include "my_lib.hpp" // includes <string>, <map>
export module app.core;
import std;
```

```cpp
// prefer
export module app.core;
import std;
import third_party.my_lib; // the wrapper includes my_lib.hpp, and with it <string> and <map>
```

**Why.** `import std` brings the standard library as one module. An `#include <vector>` in the same
unit brings a textual copy of the same declarations. The compiler has to prove each pair the same
and merge it, which is slow and, with today's compilers, the most common source of "redefinition"
and "ambiguous" errors in mixed projects.

## 3. Keep the global module fragment to what the interface needs

The global module fragment of an interface should hold only the headers its exported declarations
mention. Headers that only the implementation needs go into an implementation unit:

```cpp
// app/net.cppm -- the interface
export module app.net;
import std;
export namespace app::net {
struct Response { int status; std::string body; };
Response get(std::string_view url);
}
```

```cpp
// app/net.cpp -- the implementation
module;
#include <curl/curl.h> // only here: importers never load it
module app.net;
namespace app::net {
Response get(std::string_view url) { /* ... */ }
}
```

**Why.** What an interface includes is in its BMI, and every importer loads it. What an
implementation unit includes costs only that unit. An edit to an implementation unit does not change
the BMI, so nothing that imports the module is rebuilt.

## 4. Put function bodies in implementation units

In a module interface, a member function defined inside its class is not implicitly `inline` as it
is in a header, and changing any body in an interface unit changes the interface. Keep bodies that change often in `module X;` implementation units,
and leave in the interface what importers need to see: declarations, templates, `constexpr`
functions, small inline helpers.

**Why.** Every edit to an interface unit rebuilds its BMI, and with it the BMIs of everything that
imports it. A body edited in an implementation unit rebuilds that one unit.

## 5. Import what you use; avoid re-export chains

`export import` is for a module that is a facade over its partitions, or for a type that is part of
the module's own interface. Do not use it to pass on what a module happens to use:

```cpp
// avoid
export module app.defines;
export import std; // every importer of app.defines now imports std through it
export import third_party.toml;

export module app.tool;
export import app.defines; // ...and every importer of app.tool, everything above
```

```cpp
// prefer
export module app.tool;
import std;
import third_party.toml;
import app.defines; // exports only its own names
```

**Why.** An `export import` puts the whole re-exported closure into every importer's visible scope:
more BMIs loaded for each request, more names in every completion list, and a dependency the
importer never stated, so removing it from the middle of the chain breaks files far away. Never
`export import std`.

## 6. Split large modules along the lines they change

A module whose interface is thousands of lines, and that most of the project imports, is rebuilt
— with all its importers — on every edit to any part of it. Split it into separate modules (or
partitions behind a facade) along the lines that change together: a stable core of types, and the
parts that are worked on.

**Why.** The rebuild after an edit is the size of what imports the edited interface. Small, stable
modules at the bottom of the graph and frequently edited ones near the top keep that small.

## 7. Platform headers get their own wrapper

```cpp
// src/platform/win32.cppm
module;
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
export module platform.win32;

export namespace win32 {
using ::HANDLE;
using ::CreateFileW;
using ::CloseHandle;
}
```

**Why.** `windows.h` and the POSIX headers are large and define macros (`min`, `max`, `ERROR`) that
collide with ordinary code. Macros do not cross a module boundary, so wrapped, they stay inside the
wrapper. If other code needs a macro, put it in a small header of its own that defines only that.

## 8. Names follow directories

Name a module after its directory (`app/net/` holds `app.net`), give each directory one primary
interface, and use partitions (`app.net:http`) for the parts nobody outside imports. A build tool,
mcppls and a person can then find a module's source from its name.

## Header-only code in a module project

Files that import no module (headers, and units that only include) work as before: the engine
completes in them from its index, as without module support, so they do not pay for the project's
modules. A project with no module at all runs the engine without module support. The rules above
matter for the files that import.

## Seeing what it costs

- The report's `slowestFiles` lists the ten files with the slowest completion, and
`engines[].details.buildTimes` where each file's build went: preamble, imported modules, AST
builds.
- The log line `Prerequisites of <file>: <n> modules, <n> reused, <n> built …` says how large a
file's import closure is and what it cost ([50-troubleshooting.md](50-troubleshooting.md)).
- `Rebuilding module <M>: <why>` names the edit that caused a rebuild.

A file whose closure is dozens of modules, or a module that is rebuilt whenever anything changes, is
where these rules pay off first.
Loading
Loading