cppl-lsp is the language server for C++L - C++ with Laws.
It provides editor tooling for C++L while preserving the fundamental C++L architecture:
cppl-lspowns the whole source file and internally delegates ordinary C++ semantic work to Clang/clangd-compatible infrastructure.
The editor talks to one language server: cppl-lsp.
cppl-lsp understands C++L extensions, proofs, verification, source projection, and C++L diagnostics. Ordinary C++ parsing, typing, lookup, overload resolution, templates, completion, navigation, and related compiler semantics remain the responsibility of Clang.
The goal is not to build another C++ language server.
The goal is to make C++L feel like a native extension of C++.
Most of this document is design, not a description of what is built. What ships today is:
initialize / initialized / shutdown / exit
textDocument/didOpen, didChange, didClose incremental sync
textDocument/publishDiagnostics from the real compile pipeline
textDocument/formatting canonical C++L clause placement
textDocument/rangeFormatting scoped to the requested range
textDocument/onTypeFormatting scoped to the smallest safe unit
textDocument/completion C++ from Clang; C++L where the grammar
admits it; case labels still owed
textDocument/hover C++ from Clang; C++L as written;
a case subject's state partition
textDocument/codeAction syntax migrations; canonical fix-all
textDocument/semanticTokens/full every name, from Clang; C++L words
and names, from the frontend
textDocument/definition Clang, over the document's projection
textDocument/declaration the first declaration
textDocument/typeDefinition through pointers, references, `auto`
textDocument/implementation overrides and derived classes
textDocument/references across the workspace: open documents
as held, other files from the index
textDocument/documentHighlight declarations, reads and writes
textDocument/codeLens each Law's, proof's, function's verdict
textDocument/signatureHelp the call being written, from Clang
textDocument/documentSymbol C++ from Clang; Laws, proofs,
refinement types from the frontend
textDocument/foldingRange C++ bodies, includes, conditionals
from Clang; C++L from the frontend
textDocument/selectionRange Clang's constructs and the
recognizer's C++L spans, nested
textDocument/inlayHint parameter names, deduced types,
from Clang
workspace/symbol every declaration in the workspace,
from the index and open documents
textDocument/prepareRename, rename every place references finds, whole
or refused with the reason
Diagnostics come from driver::compile_buffer over the live buffer — the same
pipeline the CLI runs — so the server holds no decomposition, exhaustiveness or
verification logic of its own. A structural linter adds contextual C++L checks
over the syntax that pipeline already recognized, rather than recognizing it a
second time. publishDiagnostics also reports canonical-formatting style
violations (severity Warning, category Style) from the same formatter
engine used to fix them, so an editor sees a clause-placement problem before
the user ever asks to format.
Code actions come from that engine too. Each syntax migration it knows — a Law
ensures that is now proves, a case that is now cases — is offered as a
quickfix where its edit would land, not everywhere in the file. Canonical
formatting of the whole document is offered as source.fixAll.cppl, which an
editor can run on save. Formatting runs clang-format, so it is computed only
when asked for by kind or by the user invoking code actions, never on the
automatic requests an editor sends as the cursor moves.
Completion and hover follow the same rule, and it is the important one: they
answer from the states the compiler's own case engine recorded while
elaborating this buffer (elaboration::SubjectStates), never from a
decomposition the server performed. There is exactly one case engine and it is
the compiler's (AGENTS.md 39). Where the compiler has not confirmed a
subject's states — the pipeline could not reach elaboration, or no provider
models the type — the server offers nothing rather than guessing, because a
suggested label that the compiler would reject is worse than no suggestion.
One consequence is worth stating plainly: recognition reruns on every edit, but elaboration runs only where the server already pays for it, on publish. So the offered labels can lag the buffer by an edit. The alternative is a Clang round trip per keystroke or a second decomposition engine, and the second is forbidden.
Completion offers each state the subject's provider lists and this statement
has no arm for yet, including the residual one — a residual state is a real
semantic state, not a catch-all, so it is offered like any other (AGENTS.md
39, no wildcard). Each item inserts an arm skeleton carrying the provider's own
binder names, so an accepted completion already has the right binder count for
that state's payload. Hover names the subject's resolved representation, which
provider modeled it, and the full partition with the written arms checked off.
An omitted case is marked as claimed impossible, not as proven: hover reads the
written syntax, and whether the claim checks is reported as a diagnostic. A case
split written in a verified body is a case site the same way, served from the
states the compiler recorded while elaborating the body.
Semantic tokens color every name by what it names (see "Semantic tokens"). C++ names are Clang's, and C++L's words and names are the recognizer's.
A proof statement is spelled like a C++ declaration, as in exact h;,
assume h : P; or contradiction name;. That is what the editors' TextMate
grammar cannot tell apart, and only the recognizer knows which spellings are
statements.
A contradiction statement in a verified body is a claim only where the whole
translation unit, headers included, uses the word for nothing else (SPEC.md
WORD-002). Only the compile of the preprocessed unit sees the headers, so such
a claim is reported only when that compile recognized claims too. Where it did
not, or could not run, the statement is left uncolored, as the grammar leaves
it.
A cases or decompose statement in a verified body is a case split on the
same terms (SPEC.md WORD-012). It and the keywords in its arms are reported
only when that compile recognized splits. A claim in an arm is reported once,
as the split's.
Navigation is Clang's answer, read back through the projection. Each document
gets an editor unit: the analysis projection the compiler makes
(frontend::project), made from the buffer as written rather than from the
preprocessed unit, parsed by libclang and kept with a precompiled preamble, so
the request after an edit costs a reparse of the document and not of its
headers. Because the projection is made from the text as written, its
#includes stay directives: Clang reads the headers themselves, and a position
it reports in one is exact. A header that holds C++L is read as its projection
too, and so is every other open buffer, as the editor holds it.
What Clang reports is shown only where it can be traced to text an author
wrote. The projection records every run of the written text it kept in place
(Projection::segments) and every run it copied into a declaration it
generated (Projection::copies): a Law's and a proof's parameters, the
expression a clause states, a refinement's predicate. So a name inside a Law's
proposition leads to the C++ it names, a Law's parameter used in its
proposition leads to the parameter as written, and a verified function's
parameter named in its expects or ensures leads to that parameter. The
declaration generated for a Law stands for the Law's name and the alias
generated for a refinement type for the refinement's, so a proof's proves
clause leads to the Law, and a use of a refinement type to its declaration.
Anything else Clang reports in generated text, such as result or a probe, has
no written position and is not shown: no answer is better than one pointing at
text nobody wrote.
Proof statements are not C++, and Clang says nothing about the names they use.
The compiler resolves those while it elaborates the buffer -- to a proof, to a
trusted Law, or to a name an earlier assume bound -- and records each
resolution with where the name is written and where what it names is declared
(elaboration::ResolvedName). So exact p;, apply p;, rewrite h;,
contradiction e; in a proof or a verified body, and the evidence of
omit label by contradiction e;, lead to what they name, and references to a
proof or an assumption list every statement that names it. The server answers
from those records and never resolves a name itself. A record comes from the
last compile, so it is used only where the buffer still spells the name at the
recorded position.
References come from the same units. A name is identified as Clang identifies
it, by its USR, so every open document's unit finds it in itself and in the
headers it includes, and the answers are merged. A parameter the projection
repeats -- a proof's, copied into the declaration for its claim and into each
probe of its statements -- is several parameters to Clang and one to its
author, so a declaration Clang reports at the same written position is the
same name. A reference is reported only where its name is written: an
implicit call, or a use spelled by a macro's body, has no written name and is
not an occurrence. documentHighlight marks each occurrence in the document
as its declaration, a read, or a write, where a write is the target of an
assignment, a compound assignment or an increment. Files no open document holds
answer from the workspace index (see "Workspace index").
The editor unit reads text as written, which is what makes its positions exact,
and it is also its one limit: a C++L construct spelled through a macro, such as
#define V verified, is recognized by the compiler, which reads the
preprocessed unit, but not here. The unit Clang parses then keeps the
construct's clauses as written, Clang recovers around them, and navigation near
them finds what that recovery left. Diagnostics are unaffected: they come from
the compile, never from the editor unit.
The server advertises textDocumentSync, documentFormattingProvider,
documentRangeFormattingProvider, documentOnTypeFormattingProvider,
completionProvider, hoverProvider, codeActionProvider (kinds
quickfix and source.fixAll.cppl), semanticTokensProvider (whole
document; the legend under "Semantic tokens"), definitionProvider,
declarationProvider, typeDefinitionProvider, implementationProvider,
referencesProvider, documentHighlightProvider, codeLensProvider,
signatureHelpProvider, documentSymbolProvider, foldingRangeProvider,
selectionRangeProvider, inlayHintProvider, workspaceSymbolProvider and
renameProvider (with prepareProvider for a client that can prepare).
The rest of navigation specified below is not implemented and not advertised:
an editor is told what the server can do, never what it intends to do.
docs/STATUS.md tracks this.
Editor
│
│ LSP
▼
┌──────────┐
│ cppl-lsp │
└────┬─────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
C++L frontend C++ semantics
Clang / clangd
│ ▲
│ │
├─ parse C++L │
├─ laws / proofs │
├─ obligations │
├─ verification │
├─ erasure/projection ──┘
└─ source mapping
cppl-lsp is the owner of the document from the editor's point of view.
Clang is the authority for ordinary C++ semantics.
The C++L compiler/frontend is the authority for C++L semantics.
The language server coordinates the two.
C++L is a superset of C++.
Therefore ordinary C++ must not require a second implementation inside the language server.
For ordinary C++:
C++ source
│
▼
cppl-lsp
│
▼
Clang-compatible semantic engine
For C++L:
C++L source
│
▼
C++L frontend
│
├── verification
│
├── C++L diagnostics
│
├── source map
│
▼
projected / erased C++
│
▼
Clang-compatible semantic engine
Results produced against projected C++ are mapped back to locations in the original C++L source before being returned to the editor.
The editor should not have to understand which portions of a file belong to C++ and which belong to C++L.
Running independent C++ and C++L language servers over the same document would create competing views of the source:
- duplicate diagnostics;
- invalid diagnostics on C++L syntax;
- inconsistent source locations;
- conflicting completions;
- conflicting semantic tokens;
- duplicated parsing;
- fragile coordination between tools.
Instead:
Editor
│
▼
cppl-lsp
│
├── C++L semantics
│
└── C++ semantics ──► Clang
There is one owner and one coherent view of the source file.
cppl-lsp is responsible for the IDE-facing semantics of C++L.
This includes:
- recognizing C++L source;
- understanding C++L-specific syntax;
- exposing laws, proofs, obligations, and verification information;
- reporting C++L diagnostics;
- maintaining C++L-to-C++ source mappings;
- projecting or erasing C++L constructs when ordinary C++ semantics are required;
- forwarding appropriate semantic operations to Clang-compatible infrastructure;
- translating delegated locations and diagnostics back to the original C++L document;
- providing C++L-aware hover information;
- providing C++L-aware navigation;
- providing C++L semantic highlighting;
- exposing verification state to editors.
The LSP layer must reuse the same C++L frontend and semantic implementation used by the compiler.
It must not invent an independent interpretation of the language.
cppl-lsp does not reimplement ordinary C++ semantics.
In particular, it should not grow its own implementations of:
- the C++ type system;
- overload resolution;
- template instantiation;
- concepts;
- name lookup;
- implicit conversions;
- C++ constant evaluation;
- standard-library awareness;
- C++ diagnostics already correctly handled by Clang;
- C++ code completion;
- C++ reference discovery;
- C++ AST semantics.
A useful rule is:
If the question would exist unchanged in an ordinary
.cppfile, Clang should usually answer it.
If the question exists because the program contains C++L language semantics, the C++L frontend should answer it.
The compiler and language server must not develop separate parsers or semantic models.
The intended architecture is:
C++L frontend
/ \
/ \
▼ ▼
compiler cppl-lsp
The shared frontend owns:
parsing
↓
C++L semantic analysis
↓
proof obligations
↓
evidence checking
↓
verification kernel
↓
erasure / projection
↓
source mapping
cppl-lsp consumes those results.
It does not reproduce them.
This prevents compiler/editor semantic drift.
C++L deliberately does not attempt to replace Clang as a C++ compiler frontend.
For operations involving ordinary C++ semantics, cppl-lsp delegates to Clang/clangd-compatible infrastructure.
Examples include:
completion
go-to-definition
find references
ordinary C++ hover
type information
template diagnostics
overload diagnostics
include handling
standard-library symbols
C++ semantic tokens
The exact implementation boundary may evolve, but the architectural invariant does not:
C++ semantics have one authority: Clang.
cppl-lsp must not become a parallel C++ compiler frontend.
C++L contains syntax which Clang does not understand directly.
For Clang-backed operations, the C++L frontend can produce an ordinary-C++ semantic projection of the source.
Conceptually:
original.cppl
│
│ C++L frontend
▼
projected.cpp
source-map
│
│ Clang
▼
semantic result
│
│ source-map
▼
original.cppl location
The projection exists for tooling and compilation infrastructure.
It is not a second source of language semantics.
Source mapping must remain stable enough that diagnostics, hovers, definitions, references, and other semantic results can be presented against the source the developer actually wrote.
There are two major classes of diagnostics.
Produced directly by the C++L implementation.
Examples include:
- malformed C++L declarations;
- invalid proof structure;
- unsatisfied proof obligations;
- invalid evidence;
- failed verification;
- invalid C++L-specific semantics.
These diagnostics refer directly to the original source.
Produced by Clang against the semantic C++ projection.
For example:
Clang diagnostic
│
▼
projection location
│
▼
C++L source map
│
▼
original source location
Users should see one coherent diagnostic stream regardless of which subsystem discovered the problem.
The buffer is compiled from a scratch copy, which a #line directive names by
the document's own path, so every location the compile reports names the
document or a file it includes, never the copy. A location in the document is
shown where it was written, at the column its author wrote it at, counted in
UTF-16 as LSP requires. A location in an included header is shown on the
document's #include that brought the header in, directly or through other
headers, as in included file: <message>, with the header's own location as
related information. A note links to its own location, in the document or in
the header it names.
A header's C++L structure and layout are the header's to report, where it is itself open, so the structural linter and the style check report only the document's own constructs.
A refinement type is a C++L declaration that also bears a C++ type, so it is a case where both authorities are involved at once:
type Percentage = int where (self >= 0 && self <= 100);The division follows the ownership rule. type, where, self, the index
binders and the predicate are C++L, and the C++L frontend supplies them: the
declaration's ranges, the refinement's name, its base type, its indices and the
membership obligations a value owes where it enters the type. The base type itself,
every name inside the predicate and every ordinary use of the refinement name are
C++, and Clang answers for them against the projection, where the declaration
appears as the alias it lowers to.
What the language server should therefore be able to show:
Percentage
refinement of int
where self >= 0 && self <= 100
erases to int
That last line matters: the hover must not present a refinement as a distinct
runtime C++ class, because it is not one. It is verification-level identity over
the base type (SPEC.md 17.8).
The capabilities this touches are declaration highlighting for type and where,
hover and type information combining the refinement with its base, go-to-definition
from a use of the name to the declaration, completion of refinement names where a
type is expected, and the membership diagnostics the frontend produces. Ordinary
C++ completion and navigation inside the predicate and the base type stay Clang's.
Source mapping is what keeps this usable. A refinement declaration lowers to an alias in place, carrying one newline per newline of the declaration, so a Clang diagnostic on any later line still maps to the line the author wrote.
cppl-lsp and the standalone cppl-format CLI (tools/cppl-format/) share
one canonical-formatting engine, compiler/formatter: both call the same
format_document/format_ranges/format_on_type functions and produce
byte-identical output, the same way both already share one compile pipeline
(cppl::driver::compile_buffer).
Ordinary C++ formatting is delegated to clang-format via the repository's
existing subprocess/tool-driver abstraction — the formatter does not link
LibFormat or reimplement clang-format's own layout rules. The engine only
relocates C++L-specific clauses:
expects(...)
ensures(...)
invariant(...)
proves(...)
Each begins its own continuation line, indented one level from the enclosing
declaration or loop header, with the opening { on its own separate line back
at the declaration's column, and no whitespace between the clause keyword and
its (:
verified int fifty(int x)
ensures (result == 50)
{
return 50;
}Refinement where clauses stay inline and are never relocated:
type Percentage = int where (self >= 0 && self <= 100);textDocument/rangeFormatting and textDocument/onTypeFormatting are scoped:
a requested range or cursor position expands only to the complete C++L clause
or ordinary-C++ line it touches, never to the whole document, and clauses
outside that region are left untouched.
The same clause-placement rule is reported as Style-category warnings
through publishDiagnostics (formatter::check_style) — a pure token-position
check with no clang-format subprocess, so it is cheap enough to run on every
edit.
C++L introduces information that ordinary C++ language servers cannot expose.
cppl-lsp should make verification state visible directly in the editor.
For example, a developer should be able to inspect:
law
proof
proof obligation
available evidence
verification result
failed obligation
source of discharged evidence
The exact editor presentation is separate from the language semantics.
The compiler determines what is true.
The LSP determines how that information is presented.
Implemented today: the compile of the buffer copies out, after the kernel has
decided, what became of every obligation it verified -- its origin, where it
is stated, its status, the trusted Laws a proven claim rests on, the goal the
kernel was given, what produced the evidence, why it is not proven when it is
not, and the proof written for a Law (driver::ObligationRecord). The server
shows these and decides nothing:
- a code lens over each Law, proof and verified function the document writes
states its verdict:
PROVEN,PROVEN relative to trusted a, b,TRUSTED,UNRESOLVEDand why, or for a verified function how many of its obligations are proven. A proof of a Law has no obligation of its own, so its lens states the Law's verdict on the evidence it supplied, or the verdict of the Law it names when another proof's evidence decided it. A compile that stopped before verification says so rather than showing nothing; - hover over any of these, or over a name that stands for one, lists each of its obligations with its status, its goal, and its evidence or the reason it is not proven.
A lens runs no command. A verdict is shown only for the version of the buffer it was computed for, so text edited since the last compile shows none rather than one that no longer applies. Counterexamples are not shown: no part of the compiler produces one.
Hover information should combine ordinary C++ information with C++L information where appropriate.
For an ordinary C++ entity, the result should primarily come from Clang.
Implemented today: hover over a C++ name shows what Clang knows of it -- its
kind and qualified name, its declaration without a body, a variable's type, a
constant's or enumerator's value, a type's size and alignment, the comment
written for it, and the file it is declared in when that is another. Over
auto it shows the type deduced. Over a name that stands for a C++L
declaration -- a Law named in a proof's claim or by a proof statement, a proof,
a refinement type, a verified function, a name assume binds -- it shows that
declaration as written, never what the projection generated for it: a Law is
its law declaration, not a function returning bool, and a refinement type
says what it refines and that it erases to its base type. result in a
postcondition and self in a refinement predicate are declared only by
generated code, so hover says what they mean. Inside a cases or decompose
block, hover still shows the subject's states (see "Case arms").
For C++L entities, hover may expose:
declaration kind
proposition
verification state
proof relationship
generated obligation
source evidence
The LSP must not independently recompute whether a proof is valid.
It displays the result of the C++L semantic pipeline.
Navigation should work across both ordinary C++ and C++L constructs.
Examples include:
use → C++ declaration
proof → proposition
proposition → proof
evidence use → evidence declaration
C++L declaration → referenced C++ symbol
Ordinary C++ symbol navigation uses Clang semantics.
C++L relationships are supplied by the C++L frontend.
Implemented today: definition, declaration, type definition and implementation
(see Implementation status). A definition request at a definition answers the
earlier declaration, as clangd does; auto leads to the type it was deduced
as; an #include leads to the file it includes; an overloaded operator leads to
the operator called. Implementation answers every override of a virtual method
and every class derived from a class, within the unit.
The document's outline follows the same split. Clang outlines the C++: every
declaration whose name the document writes outside a function body --
namespaces, classes, structs, unions, enums and their enumerators, functions,
methods, constructors, fields, variables, aliases, macros and concepts -- nested
as declared, a member defined outside its class named with its class. An entry
is kept only when its name is text the projection kept where it was written, so
nothing the projection generated appears, whether its name is generated or
copied from C++L. The C++L frontend supplies the rest from the syntax the
compiler recognizes: each Law (law or trusted law), each proof with what it
proves, and each refinement type with what it refines, placed inside the
innermost namespace or type written around it. A verified or pure function is
Clang's entry, marked verified or pure.
A client that cannot nest an outline
(hierarchicalDocumentSymbolSupport unset) gets the same entries as a flat
list, each naming the entry it is nested in.
Files no document holds open answer too. The server indexes every file under
each folder the client opened (workspaceFolders, or else rootUri or
rootPath): each C++ and C++L source and header (.cppl, .cpp, .cc,
.cxx, .c++, .h, .hh, .hpp, .hxx, .h++, .ipp, .inl), and each
file a compilation database at a root (compile_commands.json or
build/compile_commands.json) lists, wherever that file lives. The walk passes
by hidden directories, node_modules, build trees (a directory holding
CMakeCache.txt) and repositories nested in the workspace (a directory holding
.git). It stops at 20,000 files and 32 directories deep, so a root that is
not a project costs a bounded amount.
Each file is read from disk the way an open document is read. Clang reads it
through its projection, with the flags its build gives it (ARCH-LSP-008).
The compile reads it as far as elaboration, which says what each name a proof
statement uses resolves to. The index never verifies, publishes no diagnostic
and shows no verdict. It reads every file once, then looks every 2 seconds for
files added, removed or edited on disk, and reads again only those.
The index reads several files at once, on half the processors. Each thread that reads runs at a lower priority than the thread answering requests: a utility quality of service on macOS, a nicer niceness on Linux. A file the index reads is parsed once, without the precompiled preamble an open document keeps for its next edit. What a file's reading finds in a header that the index also reads on its own is left to that header's reading, so each place is kept once.
An open document always answers as the editor holds it, and the index answers
only for the other files (ARCH-LSP-009). So an edit not yet saved is what
workspace symbols and references see.
- Workspace symbols (
workspace/symbol) are the declarations the outline shows, in open documents and indexed files, whose name holds the query's characters in order, ignoring case. The name itself ranks first, then a name it begins, then one it is part of, then one it is scattered through, each rank by name. At most 256 are returned, each with the declarations it is nested in as its container (a::b). - References reach every indexed file: a C++ name by its USR, and a Law or a proof through each proof statement the compile resolved to it.
- Progress. A client that shows progress sees each pass that reads files as
work in progress titled
Indexing, with how many files are read out of how many it found.
A rename rewrites every place references finds the name written, its
declarations included: in each open document as the editor holds it, and in
each file the workspace index reads. A class's constructors and destructor are
spelled with its name and are renamed with it, from the class or from any of
them; a destructor keeps its ~. A Law, a proof or an assumption is renamed in
each proof statement the compile resolved to it too.
A rename is made whole or not at all. It is refused, with the reason, when:
- the new name is not a C++ identifier, or is a C++ keyword or an alternative
token (
and,xor_eq); - the name is written in a file the editor does not hold open and the index
does not read, such as a system header, as for
std::abs; - a place no longer spells the name, as a file edited on disk since it was read;
- the name is used through a macro whose body spells it. Rewriting the body would rename whatever else the macro names, and leaving it would break the use. A name passed to a macro as an argument is written where the macro is used, and is renamed there;
- the new name is a C++L word (
frontend::cppl_words: SPEC.md 3, WORD-001, WORD-002, WORD-010) and a place to rename lies inside a C++L construct, where the word has its C++L meaning. Outside every C++L construct it is an ordinary identifier and may be written; - the recognizer would read any edited file's C++L differently afterwards:
other constructs, or a proof with other statements. Besides C++L's words,
only the arm labels a representation reserves (WORD-005) change what it
reads. An unqualified case label is read only as such a state, so renaming an
enumerator written as one to
noneturns a statement the recognizer refused into one it reads, and is refused. The frontend decides this, never the server (ARCH-LSP-010); - the index is still reading the workspace after 30 seconds, since a rename that missed a file would not be whole.
textDocument/prepareRename names the place under the cursor and the name
there, or refuses as a rename would, except that it neither checks a new name
nor waits for the index. Refusals are answered as RequestFailed (-32803)
with the reason as the message. A rename to the same name changes nothing.
Whether the new name collides with another declaration, or would be captured
by one, is not checked; the compile reports what it breaks.
Folding follows the same split, and the server reads neither C++ nor C++L structure from the text itself.
- C++ structure comes from Clang. A body folds between its braces: a block,
and a function's, a lambda's, a class's, an enumeration's, a namespace's, an
externblock's or a braced initializer's. A run of consecutive#includes folds as imports. - Conditional directives are paired from Clang's tokens. Each branch of a conditional directive folds as a region, up to the directive that ends it. Clang's cursors do not cover conditional directives, so these are the one fold built from tokens.
- C++L structure comes from the recognizer
(
compiler/frontend/include/cppl/frontend/structure.hpp):- a proof's body, a Law's body, the arms of a
cases,decomposeorinductionstatement, and each arm's body fold between their braces; - a Law with no body and a refinement type fold as whole lines.
- a proof's body, a Law's body, the arms of a
- Comments come from the frontend's lexer. A block comment folds as a comment, and so does a run of line comments that each start their line. Code on a comment's line is never folded away with it.
Braces the projection generated are never written, so they never fold.
A client that folds only whole lines (lineFoldingOnly) keeps each closing
brace's line in view.
Expanding a selection (textDocument/selectionRange) grows from the token under
the cursor. Each step is a construct that holds the one before it:
- what Clang parsed, traced back through the source map, so a Law's proposition selects as the expression Clang read where the projection copied it;
- and the recognizer's C++L spans, in this order: a clause's expression, the clause, a proof statement, an arm's body, the arm, the arms, the statement, a proof's body, and the declaration.
Inlay hints are Clang's, read back through the source map. Each argument of a
call is labelled with the name of the parameter it is passed to, and each
variable declared auto with the type it was deduced as. A call inside a Law's
proposition or a contract is labelled where the author wrote it. The projection
repeats that text in generated declarations, but each argument is labelled
once, and nothing generated is labelled.
Some arguments are not labelled:
- one that already spells its parameter's name;
- a default argument, which is written nowhere;
- the operands of an overloaded operator;
- the arguments of a call that a macro's body writes.
A macro used as an argument is labelled where it is used. A lambda's type, a type still to be deduced in a template, and a type longer than 32 characters are not shown.
Completion follows the same ownership rule.
Ordinary C++ completion:
cppl-lsp
│
▼
Clang
C++L-specific completion:
cppl-lsp
│
▼
C++L frontend
The final completion list may combine results from both sources before returning them to the editor.
cppl-lsp adds C++L knowledge rather than replacing Clang's understanding of C++.
Implemented today: completion of ordinary C++ is what Clang would accept at the
position (clang_codeCompleteAt over the editor unit), after ., -> and
:: as well as while a name is typed. Candidates are matched against what has
been typed of the name, in the same case first, then in any case, then as the
typed letters in order, and ranked within each by Clang's own priority; a
function or method is inserted with its parameters as snippet placeholders
where the client takes snippets. A name the projection generated is never
offered, and neither is a reserved name (__x) unless what was typed starts
with an underscore. At most 150 items are sent; a longer list is marked
incomplete, so the client asks again as the user types more.
C++L's own words are offered where the compiler's recognizer says they may be
written, as snippets laid out as the formatter lays them out: law,
trusted law, proof, verified, type and pure where a declaration may
begin at namespace scope; the proof statements where a statement may begin in
a proof body; after exact, apply, rewrite or contradiction, the
evidence it may name, looked for as elaboration looks for it -- the premises
this body assumed in scope, innermost first, then the trusted Laws, then the
other proofs; the clauses a Law, a proof or a verified function may still take
after its parameters or one of its clauses, in the order the recognizer
checks. Text being written seldom parses whole, so the server asks the
recognizer's draft of it (RecognitionMode::Draft, frontend::admissible_at),
which keeps a Law or a proof still being written and reads past a statement it
cannot read. The server holds no copy of C++L's grammar: each statement's and
clause's word is the recognizer's own, and a test checks that every snippet
offered is what the recognizer reads it as. Nothing here resolves a name: a
suggestion the compiler would reject is only a suggestion.
A draft only says where the author is, so it decides nothing (ARCH-LSP-007).
Evidence is offered only from declarations written whole and from assume
statements that could be read. The compiler never recognizes a draft, and
elaboration refuses any syntax that holds a node only a draft keeps.
Signature help shows, while a call's arguments are written, every declaration Clang says it could resolve to, with the argument being written marked. Clang decides all of it: whether the position is inside a call's argument list, which overloads can still take the arguments written, best first, and which parameter the argument being written stands for. A call inside a Law's proposition or a contract is helped like any other.
Arm completion, residual arms, binder completion, duplicate-arm and missing-arm
diagnostics, invalid-label diagnostics, hover, and go-to-definition for a
referenced state all read the same decomposition model the verifier uses
(compiler/decomposition, SPEC.md 20). There is no second list of a
representation's states anywhere in the editor path: a provider describes a
partition once, and both verification and the editor consume it.
This means arm support is not written per representation. Completion offers
whatever cases the subject's provider lists, plus its residual label when its
exhaustiveness model has one — unnamed for a scoped enumeration today, and
whatever a later provider names. Stale exhaustiveness after a source change
falls out of the same model: a representation that gains a state gains a case,
and the proof that wrote no arm for it reports a missing case.
Editor support uses the arm syntax the specification already defines
(docs/GRAMMAR.md 5.7). It invents no editor-only syntax.
Syntax highlighting should remain structurally aware of both languages.
C++ entities may use Clang semantic information while C++L adds token categories for language concepts that do not exist in ordinary C++.
Potential C++L categories include:
law
proof
proposition
evidence
verification construct
Token classification must follow the actual language grammar rather than editor-side textual heuristics.
What is implemented has two sources, and neither reads the text itself.
C++ names come from Clang. Every identifier the document writes that resolves to something is a token of that thing's kind:
namespace,type(an alias or a concept),class,struct(a union too),enum,typeParameter;parameter,variable,property(a field),enumMember;function,method,macro(only the macro's own name).
The modifiers are:
declarationwhere the name is declared;readonlyfor aconstobject or an enumerator;staticfor a static member of a class;deprecated;defaultLibraryfor a name declared in a system header.
A name inside a Law's proposition, a contract or a proof's claim is where the author wrote it, read through the source map. The generated declaration for a Law is the Law's written name. Nothing else the projection generated is a token.
C++L's words and names come from the recognizer
(src/lsp/include/cppl/lsp/semantic_tokens.hpp):
keywordfor every C++L word:law,trusted,proof,proves, each clause's keyword,verified,pure, a refinement type'stypeandwhere, each proof statement's keyword, andomitandby;functionfor each Law's and proof's name;typefor each refinement type's name;- a constant
variablefor each nameassumebinds; - for each name a proof statement uses, what the compile resolved it to: an assumption as a variable, a proof or a trusted Law as a function.
A word the recognizer left to C++, such as int law = 1;, is never a C++L
token. The gating of runtime claims and splits is described under
"Implementation status".
Editor extensions should remain thin.
For VS Code:
editors/
└── vscode/
The extension is responsible for:
- starting
cppl-lsp; - connecting through LSP;
- registering C++L file types;
- editor commands;
- editor presentation where LSP alone is insufficient.
It must not implement C++L semantics.
The relationship is:
VS Code extension
│
│ LSP
▼
cppl-lsp
│
▼
C++L frontend
Therefore:
the VS Code extension is a client.
cppl-lspis the language server.
the C++L frontend is the language authority.
The intended layout is:
src/
└── lsp/
├── server.*
├── document.*
├── diagnostics.*
├── projection.*
├── source_mapping.*
├── hover.*
├── completion.*
├── navigation.*
└── semantic_tokens.*
tools/
└── cppl-lsp/
└── main.*
editors/
├── shared/ TextMate grammar shared by every client
├── vscode/
├── jetbrains/
├── visual-studio/
└── neovim/
The executable under tools/cppl-lsp/ should remain thin.
Reusable LSP implementation belongs under:
src/lsp/
Compiler/frontend semantics remain in the compiler layers rather than being moved into the LSP.
A typical semantic request looks conceptually like this:
1. Editor sends request for a C++L document.
2. cppl-lsp identifies the relevant source and document revision.
3. The C++L frontend supplies the C++L semantic model.
4. If the request is C++L-specific:
answer from C++L semantics.
5. If ordinary C++ semantics are required:
use the C++ projection;
delegate to Clang;
translate the result through the source map.
6. Merge information where necessary.
7. Return one LSP response to the editor.
The editor should never need to coordinate these layers itself.
cppl-lsp owns the live editor document.
Changes flow through one document state:
editor text
│
▼
cppl-lsp document
│
├── C++L frontend state
│
└── projected C++ state
│
▼
Clang state
The projected C++ document must correspond to the same source revision as the C++L semantic state.
Results from stale projections must not be mixed with newer C++L document revisions.
The editor communicates with cppl-lsp, not competing semantic servers for the same file.
Clang owns ordinary C++ semantics.
The C++L compiler/frontend owns C++L semantics.
Compiler and LSP reuse the same frontend implementation.
The LSP presents semantic results; it does not redefine the language.
Diagnostics and semantic results must map back to the exact C++L source developers are editing.
Valid ordinary C++ inside C++L must receive the same underlying C++ semantic treatment it would receive through Clang.
Adding hover, completion, code actions, or visualization must never require a second interpretation of proof validity or program semantics.
Interactive editor tooling has different latency requirements from batch compilation.
cppl-lsp should therefore reuse cached semantic state wherever correctness permits.
Important requirements include:
- incremental document updates;
- reuse of parsed C++L state;
- reuse of projections;
- avoiding whole-project verification for unrelated local operations;
- cancellation of obsolete requests;
- avoiding duplicate Clang work;
- revision-consistent caches;
- background indexing where appropriate;
- no blocking full rebuild for common editor interactions.
Performance optimizations must not create a second semantic implementation.
Incrementality is an execution strategy, not a different definition of the language.
tools/cppl-lsp/bench.py drives any language server over stdio as an editor
would: it opens one file of a workspace and times each request (see its
docstring). Measured on 2026-09-24 on an Apple M3 Max (16 cores, 48 GiB, macOS
26.6), against clangd 22.1.8, the server Clang itself provides. The workspace
was this repository at e0287f0, configured so that build/ holds its
compilation database of 209 commands. The file was src/lsp/src/server.cpp,
with 20 repetitions at each of three names. Each server ran three times,
alternating and cold: clangd's saved index was moved away before each run.
Each figure is the median of the three runs.
| cppl-lsp | clangd | |
|---|---|---|
initialize |
13 ms | 45 ms |
| first hover, sent as the file opens | 2.70 s | 1.84 s |
| first diagnostics | 2.70 s | 1.84 s |
| hover | 0.2 ms | 0.6 ms |
| definition | 0.1 ms | 0.1 ms |
| references | 0.7 ms | 0.5 ms |
| document symbols | 0.3 ms | 0.4 ms |
completion after documents_. |
26.6 ms | 28.1 ms |
| workspace symbols | 0.6 ms | 0.3 ms |
| whole workspace indexed | 22.3 s | 27.3 s |
| memory once indexed (physical footprint) | 389 MiB | 536 MiB |
| peak memory (physical footprint) | 1.6 GiB | 1.7 GiB |
Every request above was answered with a result, none empty.
- The two indexes read different files. cppl-lsp read 342 files: every source and header under the root, headers on their own too, and the C++L fixtures, which the compile reads as well. clangd read the 209 files its compilation database lists and reached headers through them.
- Opening a file is slower. cppl-lsp reads an opened file twice: the compile reads the preprocessed unit, and the editor unit reads the projection with a precompiled preamble. The editor unit is built on the thread that answers requests, when the first request arrives, and the compile's diagnostics are published only once that thread is free again. clangd builds one tree for both.
- Memory is the physical footprint the system charges, as Activity Monitor shows it. The largest resident set is about 2.1 GiB for either server, most of it memory freed and kept by the allocator.
Microsoft's C/C++ extension is not in the table. Its language server,
cpptools, answers initialize, then refuses to run outside Microsoft's
products, so it can only be measured inside VS Code itself.
To measure again:
git archive HEAD | tar -x -C /tmp/ws
cmake -S /tmp/ws -B /tmp/ws/build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCPPL_BUILD_TESTS=ON
python3 tools/cppl-lsp/bench.py --name cppl-lsp --workspace /tmp/ws --file src/lsp/src/server.cpp \
--at 'view_for(id.uri)' --at 'documents_.get(' --at 'index_->mentions' \
--complete-after 'documents_.' --query Server -- build/dev/bin/cppl-lsp
python3 tools/cppl-lsp/bench.py --name clangd --workspace /tmp/ws --file src/lsp/src/server.cpp \
--at 'view_for(id.uri)' --at 'documents_.get(' --at 'index_->mentions' \
--complete-after 'documents_.' --query Server -- clangd --compile-commands-dir=/tmp/ws/buildA failure in one semantic layer should not unnecessarily destroy all editor functionality.
For example, a C++L proof error should not prevent unrelated ordinary C++ navigation from working when a usable projection exists.
Likewise, a C++ compile error should not prevent the LSP from presenting structurally valid C++L information that is already known.
Partial editor results are acceptable.
Contradictory semantic models are not.
C++L source is associated with cppl-lsp.
Ordinary C++ projects may continue using their normal C++ tooling.
Because C++L is a superset of C++, a C++L file may contain entirely ordinary C++:
#include <iostream>
int main() {
std::cout << "hello\n";
}That does not cause cppl-lsp to implement the semantics of this program itself.
It routes the relevant semantic work to Clang.
A file with no C++L at all is handed to Clang exactly as written, so every
editor service works on plain C++ as it does on C++L. Which files the server
owns is still the editor's choice, made per file or per project. The clients
register it for the cppl language, and a project that wants its .cpp files
served by cppl-lsp maps them to that language. VS Code's cppl.serveCpp does
this for every C++ file. By default nothing claims every C++ file, since two
servers on one file would publish two diagnostic streams for it (see "Why
cppl-lsp owns the whole file").
cppl-lsp sits above the compiler architecture.
It depends on compiler concepts such as:
C++L parsing
↓
semantic analysis
↓
proof obligations
↓
evidence
↓
verification kernel
↓
erasure / projection
↓
Clang
The language server follows language semantics rather than defining them.
New syntax or proof behavior belongs in the language specification and compiler/frontend first.
Once that behavior exists in the semantic pipeline, cppl-lsp can expose it to the editor.
cppl-lsp should be built on stable core language slices rather than being used to prototype language semantics.
The dependency direction is:
stable C++L syntax
↓
stable semantic model
↓
stable proof / obligation pipeline
↓
stable verification semantics
↓
stable erasure / projection
↓
stable source mapping
↓
stable compiler diagnostics
↓
cppl-lsp
The initial LSP does not require every future C++L feature to exist.
It does require the semantic boundaries it consumes to be stable.
Richer IDE functionality can then be added incrementally.
The first useful cppl-lsp does not need to reproduce every feature of clangd.
Its first responsibility is to establish the architecture correctly.
A useful initial slice is:
LSP transport
↓
document ownership
↓
C++L frontend integration
↓
projection
↓
source mapping
↓
C++L diagnostics
↓
Clang-backed C++ diagnostics
↓
mapped unified diagnostics
Once this boundary is reliable, additional capabilities can build on it:
hover
navigation
completion
semantic tokens
references
code actions
verification visualization
cppl-lsp is not:
- a replacement for Clang;
- a fork of clangd containing a second definition of C++L;
- a second C++L compiler;
- an editor-specific parser;
- a regex-based recognizer for C++L;
- a place to prototype language semantics;
- a reason to duplicate compiler logic;
- a requirement for compiling C++L outside an editor.
C++L must remain usable from the command line independently of the language server.
When adding a feature to cppl-lsp, first ask:
Who owns this fact?
If the answer is:
C++
delegate to Clang.
If the answer is:
C++L
obtain it from the C++L frontend/compiler.
If the answer is:
editor presentation
implement it in cppl-lsp or the thin editor client.
This boundary is the central architectural rule of the project.
cppl-lsp owns the document.
C++L owns C++L semantics.
Clang owns C++ semantics.
The editor owns presentation.
Or, structurally:
Editor
│
▼
cppl-lsp
│
├── C++L semantic model
│
├── projection + source map
│
└── Clang semantic model
cppl-lsp exists to compose those systems into one coherent developer experience.
It adds the tooling required by C++L while continuing to inherit the mature C++ understanding of the Clang ecosystem.
cppl-lsp builds as part of the ordinary CMake project; there is no separate
build step.
make buildor, directly:
cmake --preset dev
cmake --build build/devThe executable is produced at build/dev/bin/cppl-lsp.
cppl-lsp speaks LSP over stdio: Content-Length-framed JSON-RPC 2.0 on
stdin, the same framing on stdout, and nothing else on stdout (its own
diagnostics about malformed requests or a missing Clang go to stderr).
build/dev/bin/cppl-lsp --clang /path/to/clang++ --clang-arg -std=c++20--clang selects the Clang driver executable used to preprocess and
semantically check documents. When it is omitted, the toolchain's clang++ is
used. Repeat --clang-arg for each extra flag (include paths, defines, target
flags) that every document should be read with. These come after the flags the
document's build gives it (see "Compile flags"). --clang mirrors cppl's own
--clang option.
A document is read with the flags its build compiles it with. The server looks
for a compile_commands.json in the document's directory, then in a build
directory beside it, then in each directory above in turn. CMake writes one with
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON, and Bear or a build tool's own export
writes one for other builds.
The document's own entry gives its flags. A header, or a file the build does not compile, takes the flags of the entry most like it: first one with the same name and another extension, then the one sharing the most directories with it.
Only flags that change how the text reads are kept:
- include, system and framework paths, made absolute against the entry's directory;
- macros defined and undefined, and forced includes;
- the language standard and standard library;
- the target and system root;
- optimization levels, which define
__OPTIMIZE__; - the few
-fand-mswitches that define macros or change the language.
Output, dependency, warning and code-generation flags are dropped, and so is the input.
The database belongs to the project, and opening a file must not run the
project's code. The compiler an entry names is never run: the server always
uses its own Clang. Only the flags above are passed on. A plugin (-fplugin=,
-Xclang -load), a tool search path (-B) or a toolchain elsewhere never
reaches Clang.
The document's editor unit and its compile read it with the same flags
(ARCH-LSP-008), so navigation and diagnostics never see two different
programs. An edited database is read again on the next request. A document with
no database is read with --clang-arg alone.
A quoted #include is looked for beside the document, as a compiler looks for
it beside the file that writes it. This holds even though the compile reads a
copy of the buffer from a scratch directory.
There is normally no reason to type LSP JSON-RPC by hand; an editor extension
(see below) does this. To confirm the process itself starts and speaks the
protocol, an initialize request can be piped in directly:
printf 'Content-Length: 100\r\n\r\n{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}' \
| build/dev/bin/cppl-lspmake testor, to run only the LSP suites:
ctest --test-dir build/dev -R '^lsp_'This runs, among others, lsp_fixtures_test, which drives the same
buffer-compile pipeline the server uses against real files under
tests/fixtures/ and asserts the primary acceptance invariant: a valid C++L
fixture produces zero Clang/C++-semantic diagnostics, while a genuine
ordinary-C++ error and a malformed C++L construct are still reported.
make check additionally runs formatting and linting over the whole
repository, including src/lsp, tools/cppl-lsp, and the driver's shared
pipeline.
A thin client extension lives at editors/vscode/. It starts
build/dev/bin/cppl-lsp as a child process and registers it for the cppl
language ID. It takes over ordinary .cpp files only when cppl.serveCpp is
on.
To try it against the repository's own fixtures:
- Open this repository in VS Code.
- Open the Run and Debug view, select "Launch cppl-vscode Extension", and press F5. This opens a second VS Code window ("Extension Development Host") with the extension active.
- In that window, open a file under
tests/fixtures/. The repository's.vscode/settings.jsonalready associatestests/fixtures/**/*.cpp(and, for the future native extension,*.cppl) with thecppllanguage mode, so these files activatecppl-lspinstead of the ordinary C++ tooling. Every other.cppfile in the repository keeps using normal C++ tooling.
See editors/vscode/README.md for extension-specific configuration (pointing
it at a non-default cppl-lsp binary or Clang installation), and
editors/README.md for the other editors and the publishing process.
The shared compiler bridge handles scalar reference parameters, verified void
functions, reference writes and verified call post-state. compile_buffer
reports the same refinement-crossing and stale-alias proof failures as the CLI;
the editor does not infer a separate alias model. Source locations come from the
write, call or return that produced the obligation. lsp_fixtures_test covers
successful storage flows and a call invalidating a possibly aliased const
reference. Detailed pointer-state and effect hovers are not implemented.
Transport, document synchronization, diagnostics, canonical C++L formatting, code actions, proof-decomposition completion and hover, semantic tokens for every name and C++L word, definition, declaration, type definition and implementation, references and document highlights, workspace symbols, and rename are implemented. The following are explicitly out of scope for this milestone and are not implemented:
semantic tokens for a range or as a delta (whole documents only)
proof search / interactive proof state
verification interfaces (--cppl-import-interface)
A call to a verified function another unit defines is therefore shown refused,
with the cppl.verification.interface code, exactly as the CLI refuses it when
no interface is imported (docs/DEVELOPER_GUIDE.md 15.4).
Completion and hover cover every name, from Clang for C++ and from C++L's own syntax and declarations (see "Completion" and "Hover").
A compile runs on a thread of its own, so the server answers requests while one runs.
- When compiles run. A document is compiled as soon as it opens. A change is compiled once typing has paused for 300 ms, and a later change replaces one still waiting, so typing costs one compile per pause, not one per keystroke.
- Stale results are dropped. A compile's result is applied only while the
document still holds exactly the text it compiled. A compile of text since
edited is dropped, never published: a compile of the edit is on its way.
This is what keeps a verdict from describing text other than the text shown
(
ARCH-LSP-005). - Cancellation. The input is read on a thread of its own too, so a request
the client withdraws (
$/cancelRequest) while it waits its turn is answered as cancelled (-32800) instead of run. A withdrawal of a request already answered is ignored. - Progress. A client that shows progress (
window.workDoneProgress) sees each compile as work in progress, titled with the file it checks, and each pass of the workspace index (see "Workspace index"). Each is reported on a token the server asks the client to create ahead of time: one for compiles, and one more while a workspace is indexed. - Refresh. After a compile, a client that supports it
(
workspace.codeLens.refreshSupport,workspace.semanticTokens.refreshSupport) is asked to fetch its code lenses and semantic tokens again. Those are what a compile decides: each verdict, and which statements are claims.
run_transport compiles in the foreground unless TransportOptions asks
otherwise, so a test can read what a notification produced as soon as it
returns. cppl-lsp itself always compiles in the background.
textDocument/didChange is handled under incremental document sync
(TextDocumentSyncKind.Incremental). The client sends only what changed. Each
change with a range replaces what the range covers, counted in UTF-16 units.
One without a range replaces the whole document, so a client that sends whole
documents is served too. A notification's changes apply in order, each to the
text the last one left, and the result is recognized once. A change whose range
is malformed is skipped and logged. Every position, range and
version a client sends is checked before it is narrowed; one out of range is
refused as invalid params, or, for a version, logged and recorded as 0.