Skip to content

mcpp-language-server 0.0.4: typing an import no longer freezes the editor, the status says whose problem it is, import is colored - #22

Merged
Sunrisepeak merged 26 commits into
mainfrom
fix/import-hang-status-highlight
Sep 25, 2026
Merged

Sunrisepeak merged 26 commits into
mainfrom
fix/import-hang-status-highlight

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Owner

Typing an import froze the editor. clangd 23.1 never finishes a file in which a module name ends in . at the end of its line (import hello., export module a.), and typing any dotted import passes through that text. The status also said "Some features are limited" for a missing ;, and import was not colored. Plan, measurements and implementation record: .agents/docs/2026-09-25-import-hang-status-highlight.md; release notes: CHANGELOG [0.0.4].

Engine

  • WA-CLANGD-001. clangd is given the line with ; right after the dot, which it reports at once as the error it is. Its diagnostics are mapped back to the editor's text. clangd 22.1.8 and 24.0.0git (510126255) do not have the defect; the likely upstream fix is 6dcfc17b1b, not yet bisected.
  • Workaround registry. Every workaround for a clangd defect is registered in one place (src/engine/clangd/workarounds.cpp), with versions, upstream, removal condition and canary. The engine traits come from it, mcppls report lists the ones in use, and --disable-workaround turns one off.
  • Spin guard. A file whose build runs past five times its own last build (never under 20 s), while the editor waits on it, is set aside with the text it spun on remembered, and clangd is restarted at once, past the cap if need be. An edit to the file itself now counts as a rebuild for 10 s, not 120 s.
  • No stand-in for an import being typed. A file edited in the last 5 s gets no stand-in for an import nothing provides; a unit that provides a module still does. Names that are not module names are never planned.

Status

  • Issues carry a category (code, engine, environment, project; S3-4-10 to S3-4-14). Only non-code issues make a root degraded: a problem in the user's code is a diagnostic where it is.
  • A change to degraded is held for 3 s (S3-4-15).
  • VS Code shows the first non-code issue's message instead of "Some features are limited".

Editors

  • The server sends semantic tokens for module syntax, which clangd 23.1 does not: keyword, plus module or a namespace fallback (S3-6.1). The legend is the server's own, and range is advertised only where clangd answers it.
  • VS Code injects a module grammar (WA-VSCODE-001, with a canary on VS Code's own grammar). It adds the setting mcppls.semanticTokens.modules and the commands Turn Off / Restore Other C++ Language Features, and names a conflict that appears later.
  • Neovim adds semantic_tokens_modules, a default @lsp.type.module link and disable_conflicting.

Tests

  • Conformance kinds type-text and clangd-check, and the fixtures typing-import, typing-import-spin (the real spin, with the workaround off) and workaround-canaries, on every platform.
  • module-faults and failure-at-base now expect ready, with their issues categorized code.
  • Measured locally on linux-x64:
    • unit tests 26/26 in both the dev and release profiles; macOS and Windows cross-builds;
    • check all and the spec validator: 0 failures;
    • VS Code e2e (VSIX): main 24 passing (1 pending, macOS only), conflicts 6 + 3, stress 1;
    • Neovim smoke 28/28 in both modes;
    • fixtures typing-import, typing-import-spin, workaround-canaries, inferred, engine-none, module-faults, untrusted and failure-at-base pass.

Found along the way (known limit)

  • clangd 23.1, and main too, never finishes a file in which import std; comes before an export import of something nothing provides, under a command that names std's unit.
  • Only a stray file outside the database, holding that ill-formed code, gets such a command. The first-diagnostics guard sets it aside after two minutes.
  • It is recorded in §13 and in the design record, to be filed upstream.

…t the end of its line

clangd 23.1 spins forever on `import hello.` (or `export module a.`) when nothing but white space or
a comment follows the dot on that line, and every later version of the file queues behind the spin:
typing any dotted import went through that text, so the editor froze each time. Such a directive is
always an error, and clangd reports the same line with `;` right after the dot at once, so that is
the text it is given; its diagnostics are mapped back to the editor's text, and a unit opened from
disk is rewritten the same way.

The rewrite is WA-CLANGD-001 in a new registry of what the server does only because of a defect in
clangd (mcppls.engine.clangd.workarounds): each entry names the versions that need it, the upstream
defect, the evidence, when it can go and the canary that will say so. The engine's traits are now
read from it, the four existing compensations are registered as WA-CLANGD-002 to 005, and the log
and the report list the ones a clangd version needs.
…angd restarted, even while the user keeps typing

The guards of 0.0.2 and 0.0.3 took a busy clangd for a compiling one: the stuck watch only acts on a
clangd that uses no CPU, and a file was not set aside for 120 s after any edit to it, so a spin
started by an edit, with the user still typing to fix it, was never acted on, and even once typing
stopped recovery took minutes and could hand the same text back.

Now a file's main-file build is given a budget from its own history: five times its last build,
never under 20 s; past it, with a version or a request waiting on it, clangd will not finish it,
busy or not. The file goes to mcppls's engine with the text clangd spun on remembered, and clangd is
restarted at once, past the restart cap if need be, since a clangd left spinning answers nothing and
holds a core; the file goes back the moment its text is any other, and the same text never does. An
edit to the file itself now counts as a rebuild for 10 s, not 120 s; a change to a module it imports
keeps the long patience. The file-quarantined issue names the files. `--disable-workaround` turns a
registered workaround off, which is how the spin is reproduced on purpose.
…o module can have is never planned

With autosave, each half-typed import reached the plan: `import hello` got an empty stand-in module
and a rewritten engine database, and a build tool's scan of `import hello.` even got one for the
module `hello.`. A name nothing provides, imported by a file the editor changed within the last five
seconds, now waits for its stand-in until the file is quiet, and the workspace plans again then; a
unit that provides a module still gets its stand-in at once, since building it with an import it
cannot resolve is what stalls clangd. Names that are not dotted identifiers with at most one
partition are dropped from what a unit imports. Plan issues now say whose problem they are: an
unresolved import and a module that does not build are the code's, a missing SDK or toolchain the
environment's.
…ver that lost a feature

A missing `;` or an import still being typed turned the status to "Some features are limited": any
plan issue made the state degraded, so a typo, a slow rebuild and a clangd that lost the file all
looked alike, and with autosave the warning flickered as the user typed. Every status issue now
carries a category -- code, engine, environment or project -- and only the categories other than
code make the state degraded: an import nothing provides and a module that does not compile are the
code's and are told where they are, as diagnostics; a missing SDK, an untrusted workspace or a
clangd that cannot run are the environment's. A change from ready to degraded goes out only once it
has lasted three seconds, so a file set aside and handed back as the user types never reaches the
editor; error goes out at once.

Conformance gets the checks that prove the import-hang plan on every platform: `type-text` types a
line one key at a time and requires every step to be answered in time, `clangd-check` is a
workaround's canary, and the fixtures typing-import, typing-import-spin (the real spin, with
WA-CLANGD-001 turned off) and workaround-canaries run in CI; module-faults and failure-at-base now
expect ready, naming their code issues.
…e stopped for you

The Neovim plugin now asks the server for module-syntax semantic tokens
(initializationOptions.semanticTokens = { modules, moduleType = true }), where the new
semantic_tokens_modules setup() option (default true) feeds `modules`; a user's own
init_options.semanticTokens, if set, replaces the plugin's default outright, and
conflictArbitration stays forced afterwards as before.

@lsp.type.module is linked to @module, and @lsp.type.keyword to @Keyword, both as Neovim
`default` links so a colorscheme or the user's own nvim_set_hl wins; they are set at setup
and put back on every ColorScheme, since colorschemes clear existing links when they load.
@lsp.type.keyword already links to @Keyword on Neovim 0.10 through 0.12 on its own (checked
on 0.10.4 and 0.12.5 locally), so this is mostly a safety net for @lsp.type.module, the
custom type this plugin's moduleType = true asks the server to send.

A new disable_conflicting option (default false, unchanged behavior) has the plugin stop a
clangd or ccls client itself when it attaches to a buffer mcppls also serves, instead of
only saying so once: a client serving only such buffers is stopped outright, one also
serving another buffer is only detached from this one, and either way the plugin says,
once per client, which one it stopped. The default notice now mentions the option. The
stop/detach is deferred one tick (vim.schedule): Neovim's own Client:on_attach() re-sets
attached_buffers[bufnr] right after firing the same LspAttach event, so detaching
synchronously from inside that handler would be undone as soon as it returns.

editors/nvim/tests/smoke.lua covers all of this: the init options plugin sends and how a
user's own init_options and semantic_tokens_modules interact; the highlight defaults and
that they don't override a user's own; and both disable_conflicting branches (stop and
detach-only), against the existing in-process fake clangd/ccls pattern. README documents
the new options and the highlight groups, with how to recolor them.

Ran editors/nvim/tests/smoke.lua (setup and enable modes) locally on Neovim 0.12.5 and
0.10.4; all checks pass on both. CI also runs 0.11.5, which was not available to test here,
but nothing in this change uses APIs newer than 0.10.
… limited, and let people manage other C++ extensions

Three pieces of the 0.0.4 import-hang-status-highlight design (§6, §7, §9, §10), scoped to
editors/vscode: the server-side halves (issue categories, module semantic tokens) are separate,
parallel tracks.

- An injected grammar (syntaxes/mcppls-modules.tmLanguage.json, WA-VSCODE-001 in the new
  src/workarounds.ts registry) colors module, import, export and module names on every keystroke,
  with no server involved, because VS Code's own cpp grammar defines the rule for this but never
  uses it. It accepts a name that is still being typed (import hello. colors import and hello) and
  never matches import or module used as an ordinary identifier.
- package.json declares the semanticTokenTypes/Modifiers/Scopes contributions and the
  mcppls.semanticTokens.modules setting the S3 extension's semantic-token contract needs, and the
  client now sends initializationOptions.semanticTokens accordingly, so the server's own module
  semantic tokens (a parallel track) have somewhere to land.
- The status bar no longer says the generic "Some features are limited": src/statusText.ts (kept
  free of vscode, like serverLog.ts, for unit testing) picks the first non-code issue's message
  instead, shortened for the bar and in full in the tooltip/detail, and skips any issue categorized
  "code" (the user's own text being wrong, which becomes a diagnostic instead). An issue with no
  category -- an older server -- is treated as non-code, as every issue was before this field
  existed. The error state keeps its sentence and appends why.
- mcppls.turnOffOtherCppFeatures and mcppls.restoreOtherCppFeatures (src/conflicts.ts,
  src/conflictCandidates.ts) let a person turn other C++ extensions' language features off, or put
  them back, in this workspace or everywhere, at any time -- not only through the one-time question.
  A conflict becoming active again after activation (reinstalled, re-enabled, or its setting turned
  back on) gets a non-modal notice, once per conflict per session. ccls and any other extension with
  no enable setting can only be pointed at, in the Extensions view.

Nothing here changes another extension's settings without the user choosing it, and every path is
reversible.
…us wording and the conflict commands

Unit (mocha, plain Node, no VS Code):
- test/unit/statusText.test.ts: category filtering, the shortened-vs-full wording rules, and the
  removal of the generic "Some features are limited" phrase.
- test/unit/conflictCandidates.test.ts: which extensions count as active conflicts, and the
  restore snapshot (an unset override is remembered as undefined, not as a default value).

End to end (main suite):
- test/suite/grammar.test.ts: opens a scratch .cpp file and reads back
  _workbench.captureSyntaxTokens for every form the grammar covers, including import hello. still
  being typed, and the two required negatives (x = import;, obj.import(1);, int module = 5;).
- test/suite/workaroundCanary.test.ts: WA-VSCODE-001's canary -- fails, naming the workaround, the
  day VS Code's own cpp grammar includes its module_import rule from anywhere. (That rule ships
  under a hash-prefixed repository key, e.g. d9bc4796b0b_module_import, not the bare name the design
  doc found in the unmangled upstream source, so the check matches by suffix.)
- test/suite/semanticTokens.test.ts: decodes the server's semantic tokens and checks the keyword and
  module types on the fixture's import lines. mcppls 0.0.3's legend has neither yet (confirmed
  live), so every assertion here skips, not fails, until the parallel server track lands them.

Conflicts scenario (test/suite-conflicts): re-enabling clangd.enable after the first-run answer
disabled it gets a non-modal notice; mcppls.turnOffOtherCppFeatures (workspace scope, substituted
the same way as the conflictAnswer prompt) turns it back off; mcppls.restoreOtherCppFeatures puts
back exactly the value from right before that turn-off (true, what the test set it to) rather than
the very first answer's value, and removes cpptools's override entirely since it never had one.
…the new conflict commands

README: a Highlighting section explaining the two layers and how to customize the color, the new
mcppls.semanticTokens.modules and detectConflicts wording in the settings table, the two new
commands, and the conflicts section covering turn off/restore at any time, the re-check notice, and
that an extension with no enable setting can only be pointed at.
clangd 23.1 has no keyword type and tokenizes no import/module/export line at all
(measured 2026-09-25); its own legend also has duplicate names (variable appears twice).
mcppls.orchestrator.tokens is a new, pure module: a fixed base legend (the LSP standard
types and modifiers, clangd's own extras, and mcppls's own module/partition), a mapping
from a core engine's own token-type indices and modifier bits into it by name (any other
name the core engine declares is appended, and duplicates collapse to the first entry with
that name), the LSP relative encode/decode, and a merge where the core engine's tokens win
every position they cover and native tokens only fill the gaps.

Covered by tests/test_tokens.cpp: legend construction and its indices, mapping with
duplicates and an appended name, the encode/decode round trip (sorted, relative), and the
merge (overlap dropped, disjoint gap kept, null only when neither engine answered).
…ntax, complete or not

project::scan_syntax_tokens is a new tokenizer, next to scan_source's lexer: export, module
and import keywords, and the module or partition name that follows each, from the text
alone -- even a name still being typed (import hello. keeps its keyword and moduleName
tokens; module :private and a plain module; produce only the keyword). Unlike scan_source
it needs no terminating ';', and a dotted name never crosses a line, so a name cut off by a
trailing dot or the end of a line still gives a valid token.

The native engine now declares textDocument/semanticTokens/full and /range (merge role,
gated by a new TokenOptions.modules) and answers with those tokens: module names as the
custom `module` type with a `declaration` modifier for the unit's own declaration and a
`partition` modifier on partitions, or as `namespace` with no partition modifier when
TokenOptions.moduleType is off -- the fallback for a client that never asked for the custom
type.

engine::Host gets a new semantic_tokens_changed() hook (default no-op, so nothing else
implementing Host needs to change) for the workspace's coalesced
workspace/semanticTokens/refresh, wired up in the next commit.

Covered by tests/test_scan.cpp (every form in the design doc, including the incomplete and
pathological ones, and that no token spans two lines) and a new case in tests/test_server.cpp
(moduleType on/off, modules=false, range filtering, and a merge through routing::merge_results).
…and refreshes them on change

merge_capabilities now advertises semanticTokensProvider from the server's own legend
(full: true, range: true, no delta), built from whatever the core engine declared --
never appended to the core engine's own legend, so a restart, a --clangd override or no
core engine at all cannot shift an index a client has already seen. routing::merge_results
merges textDocument/semanticTokens/full and /range through tokens::merge, kept pure: the
workspace remaps the core engine's raw answer into the server's legend right where it
arrives (route_client_request), before it ever reaches merge_results, so routing itself
only ever combines two answers already in the same index space. A full/delta request is
rewritten to full before routing sees it, since this server hands out no resultId a delta
could build on.

initializationOptions.semanticTokens ({modules, moduleType}) is read in session.cpp and
carried on SessionOptions to the native engine's factory in cli/options.cpp, the same path
compiler/kit/engine already take.

Workspace gains semantic_tokens_changed(): coalesced to at most one
workspace/semanticTokens/refresh every ~500ms, sent only once initialize is answered and
only when the client declared workspace.semanticTokens.refreshSupport. clangd calls it at
the one place that already tracks every set-aside and hand-back (update_quarantine_issue_),
at its own handshake, and when module preparation goes idle -- three one-line calls, no
other change to clangd.cpp, since another track is changing that file in parallel.
… types

The runner decodes textDocument/semanticTokens/full (or /range, with "range") with the
legend initialize gave, and passes when every entry of "expect"
({"line", "text", "type", "modifiers"?}) is among the decoded tokens. A scenario's own
"initialization-options" object is now merged into the runner's initializationOptions, so
a fixture can ask for something no --client profile sends.

inferred (with the real bundled clangd) asks for {"semanticTokens": {"moduleType": true}}
and checks a declaration's module and partition names come back as the custom `module`
type with `declaration`/`partition` modifiers, merged with clangd, which contributes
nothing on those lines. engine-none (no core engine) keeps the default and checks the
`namespace` fallback instead, so both forms in the design doc are exercised.

conformance/README.md's kind table documents the new kind and the new scenario field.
…answers it, and refresh is a request

clangd 23.1 declares no semantic-tokens range request, so advertising `range` had VS Code send ones
clangd rejected with "method not found"; `range` is now advertised only when the core engine
declares it too (mcppls's own engine alone always answers it). `workspace/semanticTokens/refresh`
is a request in LSP, not a notification: it goes out with an id, and its empty answer is dropped
like the watcher registrations'.

The grammar test's probe imported a partition by its qualified name after `import std;`, which is
ill-formed and which clangd 23.1 (and main at 510126255) never finishes under a project's module
command: the probe, opened in a real editor, held the session in `preparing` for two minutes. It
now imports `:part`, the form partitions are imported by, and is closed once its tokens are read.
…td, which clangd 23.1 never finishes otherwise
…clangd at once and remembers the text

Review found that when the timeouts of a file's requests set it aside before its spin was found,
the spin was ignored: the restart that setting-aside had put off stayed put off, and without the
text clangd spun on remembered, the end of the file's term could hand that same text back. A spin
found on a file set aside for anything else now records the text and restarts clangd at once, past
the cap if need be, the same as one found first. A build tool's scan naming `export module hello.`
no longer plans a provider of `hello.` either.
…nstead of spinning, and the checks say so

CI found the first defect is a crash on Windows (exit 0x80000003) rather than a spin: the canary
took the quick exit for a fixed clangd, and typing-import-spin waited for an engine-spin event that
cannot come there. The canary now counts a crash as the defect still being there, a check can name
the operating systems it holds on (`only-on`), and typing-import-spin checks the crash restart on
Windows and the spin elsewhere. WA-CLANGD-001 keeps clangd from crashing there too.
@Sunrisepeak
Sunrisepeak merged commit 04b186a into main Sep 25, 2026
47 checks passed
@Sunrisepeak
Sunrisepeak deleted the fix/import-hang-status-highlight branch September 25, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant