Make non-self-contained C/C++ headers parse cleanly under clangd in VS Code.

When a header relies on transitive includes from its TU's preamble (std::string_view
without #include <string_view>, forward-decls without the full type, macros set
upstream of #include "foo.h", etc.), clangd parsed alone produces a cascade of
false-positive errors, broken hover, broken go-to-def, and so on. This extension
hooks the clangd language client to observe outgoing didOpen notifications,
builds a TU→header include graph, synthesizes a fake preamble from a recently-seen
includer, prepends it to the buffer text sent to clangd, and bidirectionally
remaps line/column positions across ~30 LSP request/response methods so the
unmodified header is what you see. Diagnostics whose ranges fall inside the
synthesized preamble are dropped; edits that target the preamble are filtered
out before they hit the buffer.
The synthesized preamble is wrapped in #if __INCLUDE_LEVEL__ == 0 ... #endif,
so it's only active when clangd parses the header as the translation root —
when the same header is later #include'd through some other file's chain the
body is skipped, no redefinition cascades.
This is a port of clangd-preamble.nvim.
Requirements
- VS Code 1.75+
- The official clangd extension (declared as an extension dependency).
Install
Download the latest vscode-clangd-preamble.vsix from the
Releases page and run:
code --install-extension vscode-clangd-preamble.vsix
The extension activates automatically when a C, C++, CUDA, or Objective-C/C++ file is opened. It piggybacks on the running clangd language client — no configuration required for the common case.
Commands
All commands live under the clangd category in the command palette.
| Command | Action |
|---|---|
clangd Preamble: Reattach Middleware |
Re-install hooks on the running clangd client (use after manual restarts) |
clangd Preamble: Refresh Current Header |
Re-pick the includer TU, re-build the preamble, replay didOpen |
clangd Preamble: Disable for Current File |
Strip preamble for the current file; force a clean re-open |
clangd Preamble: Enable for Current File |
Force preamble injection (bypasses the self-contained heuristic) |
clangd Preamble: Dump Include Graph |
Dump the observed TU/header graph into the output channel |
clangd Preamble: Dump State for Current File |
Print state for the current header (preamble text, includer TU, line count) |
clangd Preamble: Dump Suppressed Preamble Diagnostics |
List diagnostics that were suppressed because they fell inside the preamble |
clangd Preamble: Scan Project for Includer TUs |
Walk all workspace folders for .cpp/.cc/... files and observe their includes; the graph is cached for later sessions |
Settings
| Setting | Default | Description |
|---|---|---|
clangd-preamble.enabled |
true |
Master switch. When off, traffic passes through unchanged. |
clangd-preamble.maxPreambleLines |
1500 |
Cap on lines emitted into the synthetic preamble. |
clangd-preamble.maxPreambleBytes |
65536 |
Cap on bytes emitted into the synthetic preamble. |
clangd-preamble.projectScanLimit |
2000 |
Maximum number of TU files visited by Scan Project for Includer TUs. |
clangd-preamble.graphCache |
true |
Persist the observed/project-scanned TU include graph in VS Code workspace storage. Cached entries are validated by file size/mtime and invalidated by file change/delete/rename events. |
clangd-preamble.indirectIncludeDepth |
2 |
Maximum include-chain depth for lazy indirect includer discovery when no direct includer TU is known. Set to 0 to disable. |
clangd-preamble.defaultSelector |
preambleSize |
Default preamble source selector for headers without a per-file override. Use preambleSize for the smallest include prefix before the header, or lastSeen for the most recently observed includer TU. |
clangd-preamble.markerComment |
// __NSC_PREAMBLE_END__ |
Single-line comment appended to the preamble so it ends on a known marker. |
clangd-preamble.log |
false |
Log middleware activity to the clangd Preamble output channel. |
Status bar
When the active editor is a header with an injected preamble, the status bar
shows Preamble: <TU>.cpp with a tooltip listing the includer TU, preamble
line count, direct/indirect flag, and dropped-diagnostic count. Clicking it
runs Refresh Current Header. While a header is waiting for an includer TU
or for the background graph lookup/PCH readiness, a highlighted status item
shows Preamble: pending instead. The editor stays usable and the unmodified
header is sent to clangd during this interval, so diagnostics can be temporarily
inaccurate. Self-contained headers leave the pending state once the background
check completes, and the status item stays hidden.
How it works
- Include graph. Outgoing
didOpenfor.cpp/.cc/.cxx/.c/.C/.mmfiles is intercepted; the file's#includedirectives are parsed into a TU↔header graph indexed by basename. - Non-blocking open. A header's original
didOpenis forwarded immediately. Includer discovery uses asynchronous filesystem operations and cooperative event-loop yields, so a large graph/path search does not block the VS Code extension host, SCM views, or diff editors. Once resolved, the extension replaysdidOpenwith the synthetic preamble. - Includer pick. The graph is queried in the background for the TU
with the shortest prefix-before-this-header (tie-break: most recent
observation) by default.
clangd-preamble.defaultSelectorcan instead make the default follow the most recently observed includer TU. Per-header selector choices override this setting. Companion-TU fallback (Foo.cppnext toFoo.h) covers the header-opened-alone case. If no direct includer is known, the graph lazily walks observed TU include chains up toclangd-preamble.indirectIncludeDepthand can build a preamble from the includes before the chain that reaches the target header. Direct includers always win, so indirect chains are not searched while a direct TU candidate exists. - Self-contained skip. Headers with 3 or more own
#includedirectives are likely self-contained and skipped automatically — the preamble can only introduce conflicts in that case. Manual commands (Refresh Current Header,Enable for Current File) override. If such a skipped header later publishes clangd diagnostics, it is treated as non-self-contained and replayed through the normal includer search. - Cycle filter. Each prefix entry is checked (1 level deep) against the target header's basename — entries that transitively re-include the target are dropped to prevent redefinition cascades.
- Dedup. Prefix entries that appear in the header's own
#includeset are dropped, and within-prefix duplicates are collapsed —bugprone-duplicate-includedoesn't fire. - Synthesis. The remaining entries are wrapped in
#if __INCLUDE_LEVEL__ == 0 ... // __NSC_PREAMBLE_END__ ... #endifand prepended to the header'sdidOpen.text. - Position remap. ~30 LSP methods (hover, definition, references,
completion, semantic-tokens full+range+delta, inlay hints, formatting,
prepare-rename / rename, code-action with
context.diagnosticsback-shift, document-symbol, folding-range, code-lens, selection-range, linked-editing- range, call-hierarchy, type-hierarchy, …) shift positions and ranges in both directions so the user sees user-space coordinates. - Diagnostic suppression. The
handleDiagnosticsmiddleware drops entries whose range is fully in the preamble, clips entries straddling the boundary, and shifts surviving entries to user-space. Suppressed entries are kept forDump Suppressed Preamble Diagnostics. - Pending-header replay. If a header is opened before any matching TU
has been observed, the original
didOpenis stashed; when a later TU'sdidOpenpopulates the graph with a basename match, the stored open is replayed through the wrapped notify so the preamble injection runs against the latest live buffer text, including edits made while resolution was pending. - Pre-wrap catch-up. vscode-languageclient may have already sent
didOpenfor documents that were open at activation, before oursendNotificationwrapper went in. At install we observe open TU buffers into the graph and replay open headers without state. - Source-after-header refresh. When a TU's
didOpenarrives via the editor for the first time, active header state whose includer pick now resolves to that TU is replayed — so a header parsed against a disk-read companion gets re-evaluated when the user finally opens the source. - Graph cache. Observed and project-scanned TU include entries are stored in VS Code workspace storage. On activation the cache is restored only for files whose size and mtime still match; file change/delete/rename events invalidate the affected TU/header entries. Cache validation and saves also use the asynchronous graph path.
- Compile-flags refresh.
workspace/didChangeConfigurationcarries clangd'scompilationDatabaseChangesextension (used by vscode-clangd-cmake to deliver per-file flags). clangd updates its in-memory CDB on receipt but does not reparse already-open files — so we replaydidOpenfor any active header whose includer TU is in the change set.
Caveats
- Header opened alone with no companion
.cppand no observed TU yields a pass-through (no preamble) until either an includer TU'sdidOpenis observed orScan Project for Includer TUsis run. - Pull-diagnostics (
textDocument/diagnostic) is not yet handled; rely on push (publishDiagnostics) for now. - The same header reached through two distinct buffers via different paths is treated as two separate buffers (each gets its own state).
Build from source
npm install
npm run build # esbuild bundle to out/extension.js
npm run typecheck # tsc --noEmit
npx @vscode/vsce package # produces vscode-clangd-preamble.vsix
License
MIT — see LICENSE.