Add hover cards and jump to declaration / definition / reference for the formatted SemIR that appears in check tests. This is done by adding a heuristic "parser" for SemIR to the language server. The cross-references are strictly best-effort, since this is just a tool for Carbon developers, not a user-facing facility. A couple of other changes made along the way: * file_test tests with an AUTOUPDATE-SPLIT no longer look for CHECK: lines outside that split. This was motivated by the tests for this new facility including CHECK: lines as part of the test input. * An agent skill for working on the language server, tracking some things that cost Claude time when working on this. Assisted-by: Claude via Antigravity
8.3 KiB
name, description
| name | description |
|---|---|
| Language server | Instructions for working on Carbon's LSP language server, including its architecture, its file_test-based tests, and the VS Code extension. |
Language server
Introduction
This skill covers toolchain/language_server/,
which implements carbon language-server, and
utils/vscode/, the VS Code extension that launches it.
Architecture
The server is built on clangd's LSP transport (clang::clangd), not on a
Carbon-specific one. That means clangd's Protocol.h types (Position,
Range, Location, Hover, MarkupContent) are the interface currency.
server.cpp/incoming_messages.cpp: message dispatch. A handler must be registered inincoming_messages.cppbefore it can be called.handle_*.cpp: one file per request family, each declaring its entry point inhandle.h.handle_initialize.cpp: the advertised capabilities. Adding a capability changesContent-Lengthin every test that callsinitialize, so expect a large autoupdate diff.context.h/context.cpp:Context::Fileper open document, plus the compile driver.Context::File::unit()has aCARBON_CHECKon the compile driver, so any handler that reaches for the parse tree must first rule out documents that were never compiled.position.h,sem_ir_index.h: mapping source positions to SemIR instructions for real Carbon files.sem_ir_text.h,handle_sem_ir_text.h: navigation within the formatted SemIR in a test file's// CHECK:STDOUT:lines. This is a heuristic text index, deliberately independent of the real SemIR data structures. See the SemIR text reader.
Document kinds
The server handles two kinds of document, distinguished by the languageId
from textDocument/didOpen, with a content sniff as a fallback:
carbon: a real Carbon file. Compiled; diagnostics published.carbon-testdata: a test file. Not compiled, because we lack logic to split it into one file per// ---split marker.
Important
A handler that assumes every file was compiled will crash on a test file. When adding one, give the test-file path an explicit early return.
Tests
There is no language-server-specific test target.
toolchain/language_server/BUILD only declares
filegroup(name = "testdata"), which is pulled into
//toolchain/testing:all_testdata and run by //toolchain/testing:file_test.
# Run just the language server tests (or any subset).
bazelisk test //toolchain/testing:file_test \
--test_arg=--file_tests=toolchain/language_server/testdata/position/hover_and_goto.carbon
# See the raw output, which is much easier to read than a test failure.
bazelisk run //toolchain/testing:file_test -- --dump_output \
--file_tests=toolchain/language_server/testdata/position/hover_and_goto.carbon
# Update expectations. Never hand-write CHECK lines.
./toolchain/autoupdate_testdata.py toolchain/language_server/testdata/...
These tests run serially, because clangd's logging is a global singleton.
Test file shape
The request stream is a // --- STDIN split written with the [[@LSP-*]]
keywords, and the responses land in a trailing // --- AUTOUPDATE-SPLIT.
Documents come from other splits by way of "text": "FROM_FILE_SPLIT", which
is substituted with the content of the split whose name matches the uri.
// --- position.carbon
fn Abs(n: i32) -> i32 { return n; }
// --- STDIN
[[@LSP-CALL:initialize:"capabilities": {}]]
[[@LSP-NOTIFY:textDocument/didOpen:
"textDocument": {
"uri": "file:/position.carbon",
"languageId": "carbon",
"text": "FROM_FILE_SPLIT"
}
]]
[[@LSP-CALL:textDocument/hover:
"textDocument": {"uri": "file:/position.carbon"},
"position": {"line": 0, "character": 3}
]]
[[@LSP-CALL:shutdown]]
[[@LSP-NOTIFY:exit]]
// --- AUTOUPDATE-SPLIT
Full keyword documentation is in
testing/file_test/README.md.
Traps
Warning
A blank line inside the
STDINsplit breaks the JSON transport. It terminates a header block, so clangd logs a timestampedWarning: Missing Content-Length header, or zero-length message.The timestamp makes the test unreproducible, so it fails on the next run. Comment lines between messages are fine; blank lines are not. A single blank line immediately before// --- AUTOUPDATE-SPLITis also fine.
Other things worth knowing:
positionEncodingis UTF-16. Acharacteris a UTF-16 code unit offset, not a byte offset.- Line and character numbers in requests are 0-based, while the
locN_Msuffixes in SemIR output are 1-based. Off-by-ones here are silent: the request succeeds and returns the wrong thing. - A split can hold a document that itself contains
// CHECK:STDOUT:lines, becauseCHECKlines only form expectations inside theAUTOUPDATE-SPLIT. Such a document still can't contain a literal// ---line, which would split the enclosing test file; write it as[[@0x2f]]/ --- name.carbon.
The SemIR text reader
sem_ir_text.cpp indexes the formatted SemIR inside a test file's
// CHECK:STDOUT: lines so that hover and go-to-definition work on operand
names. It is a heuristic reader, not a parser, and its correctness rests on
facts about toolchain/sem_ir/formatter.cpp and
toolchain/sem_ir/inst_namer.cpp. Re-check these if the formatter changes:
- There are exactly four scope keywords:
file,generated,imports, andconstants(InstNamer::GetScopeName). Everything else is@entityname. - A reference is
%namewithin its own scope andscope.%nameotherwise (InstNamer::GetNameFor). Names may contain.and may start with one, as in%.Self.frozen. - Type annotations are printed in the
constantsscope.Formatter::FormatTypeOfInstdoesllvm::SaveAndRestore file_scope(scope_, InstNamer::ScopeId::Constants), so in%x: %foo = ...a bare%foomeansconstants.%foo. This does not apply to ordinary operands or to[concrete = ...]annotations. *_declbraces hold the declared entity's scope. The braces of%F.decl: ... = fn_decl @F [...] { ... } { ... }are lexically insidefile { }, but their names belong to@F.!with Self:switches scope without a brace, until!members:switches it back. Brace counting alone cannot see this.- A
specific @F(args) { }block uses@F's scope and defines nothing; each%name => valuerow references an instruction of the generic.
Validating a change to the reader
The unit tests only cover a handful of cases. To check a change against the
real corpus, drive the server over a sample of check testdata, hovering on
every %name, and compare the resolved fraction before and after. Roughly 99%
of names resolve; the residue are names the formatter references but never
emits a definition line for, such as %I.WithSelf.F, which only ever appears
inside a [symbolic = ...] annotation.
Two things to get right in such a harness:
- Feed the request stream from a file, not a pipe. The server reads stdin
as a file and reports
error: Input/output erroron a pipe. - Read the output as bytes. Python's
text=Truerewrites the\r\nframing, andContent-Lengthcounts bytes.
VS Code extension
utils/vscode/ declares three languages in package.json:
| Language id | Applies to |
|---|---|
carbon |
*.carbon |
carbon-testdata |
**/testdata/**/*.carbon |
semir |
*.semir |
Note
The TextMate scope for SemIR is
source.carbon-semir, but the language id issemir. Markdown code fences in hover text resolve language ids, so a fence must say```semir.
extension.ts launches the server over stdio, using the carbonPath setting
(default ./bazel-bin/toolchain/carbon). Its documentSelector controls which
files are sent to the server at all; a new document kind has to be added there
as well as in the server.