LSP
rtdvi ships with a native LSP client. By default it auto-spawns
clangd on C/C++ buffers. Add [lsp.NAME] blocks in your config to
register more servers.
Default setup
Opening a .c/.cpp/.h/.hpp file in a directory whose ancestors
contain any of .git, compile_commands.json, or compile_flags.txt
triggers:
clangd -j=2 --background-index --background-index-priority=low
--malloc-trim --pch-storage=disk
If clangd isn't on PATH, the spawn fails silently and the editor
runs without LSP for that buffer. Check editor.log for the warning.
Keybindings
| Keys | Action |
|---|---|
gd | Go to definition (picker if multiple results) |
gD | Go to declaration (picker if multiple results) |
gi | Go to implementation (picker if multiple results) |
gf | Go to type definition (picker if multiple results) |
gr | List references — picker when >1, direct jump for exactly one |
K | Hover. First non-empty line of the response is shown in the cmdline. |
]d | Next diagnostic line in this buffer (wraps). |
[d | Previous diagnostic line (wraps). |
\h | Show the diagnostic message at the cursor in the cmdline. |
\rn | Open the command line pre-filled with :LspRename to rename the symbol. |
All work in Normal mode. \ is the default leader — see
Configuration to change it.
The "go to" variants reuse an existing buffer when one already
references the target file, and the cursor ends up centered in the
window so you can immediately read the context around the landing site
(same as a manual zz).
When a goto or references request returns more than one location, a
picker popup appears. Navigate with j/k or the arrow keys,
press Enter to jump to the selected entry, or Esc/q/Ctrl-C to cancel.
Before any of these jumps, the current position is pushed onto the
jumplist so <C-o> brings you back.
Customising LSP keybindings
All LSP actions can be rebound via [[keymaps]] in your config. To use
a different leader or different keys:
[options]
leader = "," # use comma as leader instead of backslash
# Override the diagnostic and rename bindings to match your preference.
[[keymaps]]
mode = "normal"
keys = "<leader>h" # expands to ",h" with the leader above
action = "lsp_diagnostic_at_cursor"
[[keymaps]]
mode = "normal"
keys = "<leader>rn" # expands to ",rn"
action = "lsp_rename_prompt" # enters `:LspRename ` in the command line
If you prefer to drive rename directly from an ex command:
[[keymaps]]
mode = "normal"
keys = "<leader>rn"
action = ":LspRename " # runs `:LspRename <name>` — fill in the name
Ex commands
| Command | Effect |
|---|---|
:LspRename <new> | Send textDocument/rename for the symbol under the cursor with the given new name. The returned WorkspaceEdit is applied across every affected buffer (and unopened files) as a single undo entry per file. |
:LspReferences | Send textDocument/references; results show in the cmdline. |
:LspDiagnostic | Print the diagnostic message at the cursor (handy when the gutter marker is cryptic). |
Aliases: :lsprename, :lspref, :lspdiag.
Diagnostics
When a server publishes textDocument/publishDiagnostics, rtdvi:
- Stores them on the client (keyed by URI).
- Renders a gutter column with a one-char severity marker on
every diagnostic line:
!red — Error?yellow — Warningiblue — Informationhcyan — Hint
When multiple diagnostics fall on the same line, the highest-severity marker wins.
The gutter only appears when there's at least one diagnostic — it doesn't clutter unhighlighted buffers.
Virtual text (inline diagnostics)
Enable diagnostic_virtual_text in [options] to show the diagnostic
message appended after the end of each affected line:
[options]
diagnostic_virtual_text = true
Example output:
#include <stdio.h> ■ Included header stdio.h is not used directly (fix available)
The ■ marker uses the same severity colour as the gutter symbol. The
message text is rendered in dim gray. Only the first line of each
message is shown; when multiple diagnostics land on the same line the
highest-severity one wins. Disabled by default.
Configuration
[lsp.clangd]
cmd = ["clangd", "-j=2", "--background-index"]
filetypes = ["c", "cpp", "objc", "objcpp"]
root_markers = [".git", "compile_commands.json", "compile_flags.txt"]
[lsp.rust-analyzer]
cmd = ["rust-analyzer"]
filetypes = ["rust"]
# root_markers defaults to [".git"]
[lsp.pyright]
cmd = ["pyright-langserver", "--stdio"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "setup.py"]
Defining your own [lsp.clangd] replaces the built-in default
(useful for tweaking flags). Other servers are additive.
clangd and rust-analyzer are built in and start automatically; every
other server (including the Python ones below) needs a [lsp.NAME] block
and the matching binary on PATH.
Python
No Python server is built in. Pick one (or combine a type checker with a linter — see Multiple servers below) and install its binary.
Pyright — type checking + IDE features (recommended)
npm install -g pyright
[lsp.pyright]
cmd = ["pyright-langserver", "--stdio"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt"]
basedpyright — Pyright fork, pure-pip (no Node)
pip install basedpyright
[lsp.basedpyright]
cmd = ["basedpyright-langserver", "--stdio"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "setup.py"]
python-lsp-server (pylsp) — all-in-one, plugin host
pip install "python-lsp-server[all]"
[lsp.pylsp]
cmd = ["pylsp"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "setup.py"]
Ruff — extremely fast linter + formatter
pip install ruff
[lsp.ruff]
cmd = ["ruff", "server"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "ruff.toml"]
Ruff does no type checking, completion, or navigation — pair it with a type checker (below).
Comparison
| Server | Type check | Lint | Format | Completion / nav | Runtime | Diagnostics |
|---|---|---|---|---|---|---|
| Pyright | ✅ excellent | basic | ❌ | ✅ strong | Node | push |
| basedpyright | ✅ excellent | basic | ❌ | ✅ strong | Node (bundled) | push |
| pylsp | ⚠️ via mypy plugin | ✅ | ✅ | ✅ good | Python | push |
| Ruff | ❌ | ✅ best-in-class | ✅ | ❌ | none (Rust) | push |
| jedi-language-server | ❌ | ❌ | ❌ | ✅ strong | Python | push |
All Python servers above use push diagnostics, so they deliver live errors on every edit through the same path as clangd — no save required (unlike rust-analyzer, which uses pull diagnostics).
Multiple servers per language
You can run several servers for one filetype; each one whose binary is installed starts, and the rest are skipped. The recommended Python combo is Pyright + Ruff — Pyright handles types/navigation, Ruff handles fast linting and formatting:
[lsp.pyright]
cmd = ["pyright-langserver", "--stdio"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "setup.py"]
[lsp.ruff]
cmd = ["ruff", "server"]
filetypes = ["python"]
root_markers = [".git", "pyproject.toml", "ruff.toml"]
With both configured:
- Diagnostics from both servers are merged in the gutter and virtual text.
gd/gr/K/:LspRenameroute to the server that advertises the capability — so they go to Pyright, since Ruff doesn't provide them.- If only one of the two binaries is installed, only that one runs; the
missing one is silently skipped (a warning is written to
editor.log). - Each project gets its own server instances, keyed by the resolved workspace root.
Architecture
One Client per (server_name, workspace_root) pair. A single
filetype may map to several servers (e.g. Pyright + Ruff); each one that
spawns successfully gets its own Client.
crossterm event loop (main thread)
│
▼
Editor::lsp_did_open(buffer)
│
├── Manager::ensure_all(filetype, path) # spawns every matching server
│ │
│ ▼
│ Client::spawn(cmd) ──── std::process::Command
│ ├─ stdin ─── synchronous writes from main thread
│ ├─ stdout ─── reader thread (parses JSON-RPC)
│ └─ stderr ─── reader thread (WARN/ERROR → editor.log)
│
└── Client::did_open(uri, language_id, text) # sent to every matching client
▼
mpsc::channel
▼
Editor::lsp_poll() ←─── called every render
A background reader thread parses framed JSON-RPC messages off
stdout and sends them as InboundMessage values down an mpsc
channel. The main thread drains the channel on every render
(Editor::lsp_poll), turning notifications into editor state
(diagnostics).
For requests like gd / K, the main thread blocks on the channel
for up to a configurable timeout (1.5s by default), processing any
notifications that arrive in the meantime, until the matching
response shows up.
Message flow per buffer
:e foo.copens the buffer.Editor::lsp_did_openfinds (or spawns) the right client, sendstextDocument/didOpenwith the file's text and language ID.- Server starts indexing. Diagnostics trickle in via
publishDiagnostics. - User presses
gd→Client::goto_definition(uri, line, col)→ synchronoustextDocument/definitionrequest → if one location, cursor jumps; if multiple, picker popup opens.
didChange
Every buffer edit emits a BufferChanged event, which triggers a
full-text didChange notification to the running LSP client. The
server therefore always sees the live in-memory content, and
diagnostics update as you type without needing to save first.
Full-text sync (sending the entire file on each change) is used rather than incremental deltas — simpler and compatible with every server. For typical file sizes the overhead is negligible.
Shutdown
When the editor exits, every running client gets shutdown + exit
notifications and the child is reaped. If you :q! quickly, the
process kill in Drop ensures nothing's left hanging.
Adding more actions
The existing actions live in
src/lsp_actions.rs. Each follows the same
pattern: read cursor position + URI, find the client, fire a
synchronous request, act on the result.
Already wired up: gd, gD, gi, gf, gr, K,
\h, \rn, :LspRename, :LspReferences, :LspDiagnostic, ]d/[d,
and gq code formatting via textDocument/rangeFormatting
(see Formatting).
Easy follow-ups (same plumbing, ~20 lines each):
textDocument/codeAction(needs a list UI + apply)textDocument/signatureHelp(needs a floating popup)
Testing without clangd
tests/m34_lsp.rs uses a tiny Python script as a mock LSP server.
It implements just enough of the protocol to respond to initialize,
emit diagnostics on didOpen, return canned definition and hover
results, and exit cleanly on shutdown / exit. Useful as a
template if you want to test your own server-specific behaviour
locally.