Make non-self-contained C/C++ headers parse cleanly under clangd.
When a header relies on transitive includes from its TU's preamble (std::string_view
without #include <string_view>, CObject forward-decls without the full type, etc.),
clangd parsed alone produces a cascade of false-positive errors, broken hover, broken
go-to-def, and so on. This plugin observes outgoing didOpen notifications to build
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/col
positions across ~30 LSP request/response methods so the unmodified header is what the
user sees. Diagnostics whose ranges fall in 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.
- Neovim 0.10+ (
vim.uv,vim.fs,vim.lsp.handlers). - A working
clangdsetup vianvim-lspconfigorvim.lsp.start.
{
"sr-tream/clangd-preamble.nvim",
ft = { "c", "cpp", "objc", "objcpp", "cuda" },
}use { "sr-tream/clangd-preamble.nvim" }Plug 'sr-tream/clangd-preamble.nvim'The plugin doesn't auto-attach to clangd — you wire it from your existing clangd
config. With nvim-lspconfig:
{
"neovim/nvim-lspconfig",
opts = {
servers = {
clangd = {
on_attach = function(client, bufnr)
require("clangd-preamble").attach(client, bufnr)
-- ...your other on_attach logic
end,
},
},
},
}If you have other middleware that also wraps client.request / client.notify
(e.g. a URI-scheme filter), install clangd-preamble after it so the preamble
layer sits as the outermost wrapper.
Show the active includer TU in your statusline:
-- lualine_x section
local function clangd_preamble()
local ok, m = pcall(require, "clangd-preamble")
if not ok then return "" end
local tu = m.includer_for()
if not tu then return "" end
return "Preamble: " .. (tu:match("([^/\\]+)$") or tu)
end
table.insert(opts.sections.lualine_x, 1, { clangd_preamble, color = { fg = "#7aa2f7" } })| Command | Action |
|---|---|
:NoSelfContainedDisable / :NoSelfContainedEnable |
Global on/off — when off, traffic passes through unchanged |
:NoSelfContainedDisableBuf |
Strip preamble for the current buffer; force a clean re-open |
:NoSelfContainedEnableBuf |
Force preamble injection (bypasses the self-contained heuristic) |
:NoSelfContainedRefresh |
Re-pick the includer TU, re-build the preamble, replay didOpen |
:NoSelfContainedStatus |
Print state for the current buffer (preamble, includer TU, line count) |
:NoSelfContainedDumpGraph |
Dump the observed TU/header graph |
:NoSelfContainedDumpDiagnostics |
Dump diagnostics suppressed because they fell in the preamble |
:NoSelfContainedScanProject |
Walk cwd for .cpp/.cc/... files, observe their includes — useful when no TU has been opened yet |
- Include graph. Outgoing
didOpenfor.cpp/.cc/.cxx/.cfiles is intercepted; the file's#includedirectives are parsed into a TU↔header graph indexed by basename. - Includer pick. When the user opens a header, the graph is queried for
the TU with the shortest prefix-before-this-header (tie-break: most
recent observation). Polluting includers (CEF wrappers, framework files
that put
common.hafter several other headers) are deprioritized. Companion-TU fallback (Foo.cppnext toFoo.h) covers the header-opened-alone case. - 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 (:NoSelfContainedRefresh,:NoSelfContainedEnableBuf) override. - 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, semanticTokens full+range+delta, inlayHint, formatting,
rangeFormatting, prepareRename/rename, codeAction with
context.diagnosticsback-shift, documentSymbol, foldingRange, codeLens, selectionRange, linkedEditingRange, callHierarchy, typeHierarchy, …) shift positions and ranges in both directions so the user sees user-space coordinates. - Diagnostic suppression.
publishDiagnosticsmiddleware 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 for:NoSelfContainedDumpDiagnostics. - 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 via the wrapped notify so the preamble injection runs against the saved text. Promotion runs viavim.scheduleto keep the wrapped notify path responsive on large projects.
- Header-opened-alone with no companion
.cppand no observed TU yields a pass-through (no preamble) until either an includer TU'sdidOpenis observed or:NoSelfContainedScanProjectis run. - Pull-diagnostics (
textDocument/diagnostic) is not yet handled; rely on push (publishDiagnostics) for now. - Headers in two distinct buffers via different paths to the same file are treated as separate buffers (each gets its own state).
MIT — see LICENSE.
