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

KeysAction
gdGo to definition (picker if multiple results)
gDGo to declaration (picker if multiple results)
giGo to implementation (picker if multiple results)
gfGo to type definition (picker if multiple results)
grList references — picker when >1, direct jump for exactly one
KHover. First non-empty line of the response is shown in the cmdline.
]dNext diagnostic line in this buffer (wraps).
[dPrevious diagnostic line (wraps).
\hShow the diagnostic message at the cursor in the cmdline.
\rnOpen 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

CommandEffect
: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.
:LspReferencesSend textDocument/references; results show in the cmdline.
:LspDiagnosticPrint 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:

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.

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

ServerType checkLintFormatCompletion / navRuntimeDiagnostics
Pyright✅ excellentbasic✅ strongNodepush
basedpyright✅ excellentbasic✅ strongNode (bundled)push
pylsp⚠️ via mypy plugin✅ goodPythonpush
Ruff✅ best-in-classnone (Rust)push
jedi-language-server✅ strongPythonpush

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:

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

  1. :e foo.c opens the buffer.
  2. Editor::lsp_did_open finds (or spawns) the right client, sends textDocument/didOpen with the file's text and language ID.
  3. Server starts indexing. Diagnostics trickle in via publishDiagnostics.
  4. User presses gdClient::goto_definition(uri, line, col) → synchronous textDocument/definition request → 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):

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.