Language servers
Textchum validates code through the Language Server Protocol, with one defining behavior: one server instance per project.
One instance per project
Server processes are keyed by (server, project root), using the same
notion of project as the navigator: the nearest ancestor
directory with a root marker. Open files from two different Rust projects
and two independent rust-analyzer processes run, each initialized with
its own root, each seeing only its own project's files. Cross-project
leakage — diagnostics from one workspace bleeding into another, an index
built over your whole home directory — cannot happen by construction.
Files outside any project get a per-directory instance, so loose files never join someone else's workspace either.
What you see
- Findings arrive as you type (sent in debounced batches) and mark the offending text: red for errors, orange for warnings, blue for notes.
- The window subtitle counts them ("2 errors, 1 warning").
- Completion as you type: suggestions appear after identifier
characters and
., filtered as you keep typing — ↑/↓ to choose, ⏎ or ⇥ to accept, ⎋ to dismiss, ⌃Space to ask explicitly. - Resting the mouse over a symbol shows the server's hover documentation in a popover, with the Markdown servers send rendered — code blocks monospaced, emphasis and inline code styled. It only triggers over identifiers (never whitespace or comments), can be switched off in View ▸ Hover Documentation (or Settings), and Show Documentation for Symbol (⌃⌘H) asks for the symbol under the caret on demand — even with mouse hover off.
- Jump to Definition (⌃⌘J, or ⌘-click) goes to the symbol under the caret — across files, opening or fronting the target as needed. On the definition it has nowhere to go, so it answers the question that is left: who uses this. One use is a jump, several open the list, and a symbol nothing refers to says so. A server that answers with several definitions — a declaration and an implementation — offers them the same way. The explicit Find References shortcut is unchanged.
- Find References (⇧⌘R) lists every use of the symbol under the
caret in a floating panel — ↑/↓ to move, ⏎ to jump. Code comes
first, tests after, each under a heading with a count: what calls
this is the question, and what checks it is the follow-up. Which
files are tests is a convention rather than a fact — a
testsdirectory, aparser_test.go, aButton.test.ts, aParserTests.swift— so the rule is a cautious one, andlatest.rsis not a test. A Rust#[cfg(test)] mod testsinside an ordinary file is listed as code, which is what its path says. A result that is all one or all the other gets no headings. - A marked line can be read. Resting the pointer on an underlined stretch shows what the server said, and Show Diagnostic for Line (⌃⌘E, Ctrl+Alt+E on Linux) says the same for the caret's line — the caret is usually at the end of the line being fixed rather than inside the mark, so the line is what it answers about. The message names its severity, because an underline says only that something is wrong and a warning should not read like an error. No round trip: the finding is already in hand.
- Diagnostics… (⇧⌘E, Ctrl+Shift+E on Linux) lists every finding in the document, in the order they appear — which is the order they get fixed in and the order the gutter shows them, with the severity in each row. ⏎ jumps, and the jump joins the back stack.
- Code Actions… (⌘., Ctrl+. on Linux) asks what the server can do
about the place the caret is — import this name, add the missing
match arm, remove the unused variable — and lists what comes back,
the server's own suggestion marked as such. The findings under the
caret go with the request exactly as the server published them,
codeanddataincluded: that is how a server recognizes its own finding, and a reconstructed one gets a shrug. An action the server answered without its edit is sent back to be finished before it is applied, and one that carries a command rather than an edit is run by the server. - Code Actions… (⌘., Ctrl+. on Linux) asks what the server can do
about the place the caret is — import this name, add the missing
match arm, remove the unused variable — and lists what comes back,
the server's own suggestion marked as such. The findings under the
caret go with the request exactly as the server published them,
codeanddataincluded: that is how a server recognizes its own finding, and a reconstructed one gets a shrug. An action the server answered without its edit is sent back to be finished before it is applied, and one that carries a command rather than an edit is run by the server. - Rename Symbol… (⌃⌘R) renames across the whole workspace: open windows edit in place (undo works per window), files nobody has open are rewritten on disk.
- Format Document (⌥⇧⌘F) asks the server first and falls back to the save-preprocessor chain — so formatting works on untitled documents and languages without a server, whenever a chain is configured.
- Format Document (⌥⇧⌘F) reformats through the server, keeping tabs if the document indents with tabs and spaces otherwise.
- Document Outline (⇧⌘O) lists the file's symbols — nesting shown by indentation, fuzzy-filterable — and ⏎ jumps to the selection.
- A missing server is reported once, with the command that installs it; everything else about the editor keeps working without it.
Servers
Textchum finds servers on PATH — it does not install them:
| Language | Server | Install |
|---|---|---|
| Rust | rust-analyzer | rustup component add rust-analyzer |
| Python | pyright | npm install -g pyright |
| Go | gopls | go install golang.org/x/tools/gopls@latest |
| C | clangd | Xcode CLT, or brew install llvm |
| JavaScript | typescript-language-server | npm install -g typescript-language-server typescript |
| Swift | sourcekit-lsp | ships with the Xcode toolchain |
| Zig | zls | brew install zls |
| Bash | bash-language-server | npm install -g bash-language-server |
| C++ | clangd | Xcode CLT, or brew install llvm |
| TypeScript | typescript-language-server | npm install -g typescript-language-server typescript |
| Java | jdtls | brew install jdtls |
| C# | csharp-ls | dotnet tool install --global csharp-ls |
| Ruby | ruby-lsp | gem install ruby-lsp |
| Lua | lua-language-server | brew install lua-language-server |
| Haskell | haskell-language-server | ghcup install hls |
| OCaml | ocamllsp | opam install ocaml-lsp-server |
| Scala | metals | cs install metals |
| Nix | nil | nix profile install nixpkgs#nil |
| CMake | cmake-language-server | uv tool install cmake-language-server |
| JSON | vscode-json-language-server | npm install -g vscode-langservers-extracted |
| HTML | vscode-html-language-server | npm install -g vscode-langservers-extracted |
| CSS | vscode-css-language-server | npm install -g vscode-langservers-extracted |
| YAML | yaml-language-server | npm install -g yaml-language-server |
| TOML | taplo | brew install taplo |
| Markdown | marksman | brew install marksman |
Go templates are served by gopls too, C++ by clangd, and TypeScript
and TSX by the JavaScript servers. Several languages have more than one
server registered: Python has pyright, basedpyright, pylsp,
ruff, jedi, ty and pyrefly; JavaScript, TypeScript and TSX have
typescript-language-server, vtsls, deno and biome; Ruby has
ruby-lsp and solargraph.
PHP has a grammar but no registered server: the ones people use do not
document the command line they take, and an entry that is a guess is
worse than none — lsp.servers takes one you know. The table
names the one used when the configuration says nothing; the others are
asked for by id.
Choosing servers yourself
Settings → Language Servers overrides which command serves a language —
for every project (a default) or for a single project root. Project
entries win over defaults; unlisted languages use the table above. The
entries live in config.json under "lsp", with the file's usual
hand-editing guarantees:
{
"lsp": {
"defaults": {"python": "pylsp"},
"projects": {"/work/projA": {"python": "pyright-langserver --stdio"}}
}
}
The language field lists the languages this build knows and still accepts anything typed: a language can be configured before there is a grammar for it, and the entry keeps working when one arrives.
Defining a server the editor does not know
lsp.servers holds entries of the same shape the built-in table uses,
so a server can be added without a code change, and one already known
can be redefined by reusing its id:
{
"lsp": {
"servers": {
"basedpyright": {
"command": "{project}/.venv/bin/basedpyright-langserver",
"args": ["--stdio"],
"languages": ["python"],
"install": "uv tool install basedpyright"
}
},
"defaults": {"python": "basedpyright"}
}
}
command is required; the rest may be left out. The built-in table
stays available alongside these, so a configuration that says nothing
still has servers, and a build that learns a new one offers it without
the configuration being rewritten. Defining a server does not change
which one a language gets by default — lsp.defaults decides that.
Naming a server, and pointing at one inside the project
A language's entry takes either the id of a server the editor knows or a command line.
An id brings the server's own arguments with it. A language with more than one registered server uses the first unless the configuration names another.
A command line is run as written, with two substitutions:
{project}— the project root the server instance is keyed on.{home}— the user's home directory.
{"lsp": {"defaults":
{"python": "{project}/.venv/bin/basedpyright-langserver --stdio"}}}
This is what a checkout carrying its own tooling needs: a virtualenv, a
node_modules/.bin entry, a server vendored in the repository. The
substitution happens per argument after the command line is split, so a
project path containing spaces stays one argument.
An entry's command is editable in place — fix a typo or add a missing
--stdio right in the row, press ⏎ or click away, no delete-and-re-add.
Changes apply to servers started afterwards; the tab's Restart Servers
Now retires running instances and respawns them under the new
configuration.
When there is no server
Two safety nets cover the no-server case:
- The ctags fallback. With Ctags fallback enabled in
Settings → Projects (as a default or per project, like every project
flag), Jump to Definition is answered from a
Universal Ctags index of the project whenever no
language server is available — and whenever a running server has no
answer. The index is built on first use and refreshed as you keep
jumping; ctags knows names, not semantics, so it is a fallback, not a
replacement. It must be Universal Ctags (
brew install universal-ctags) — thectagsmacOS ships in/usr/binis a different, much older program that cannot emit the JSON index this reads. Textchum looks past that one to find a real Universal Ctags further along yourPATH. - The debug log. Every decision on the road from "file opened" to
"server running" — the resolved project root, which server was chosen
and why, spawn failures with the exact
PATHsearched, and every status transition — is appended to:
~/Library/Logs/Textchum/lsp.log
Each server's own error output (stderr) is captured there too, so a
server that exits during startup leaves its complaint on record — a
command missing its transport flag (pyright's --stdio, say) is
diagnosed in one glance, and the log notes outright when a custom
command omits arguments the built-in registry knows are required.
When a project mysteriously has no language support, this file names
the missing piece.
One classic cause deserves a note: apps launched from Finder used to
inherit macOS's minimal PATH, which contains none of the places
language servers actually live (Homebrew, npm, cargo, go). Textchum now
adopts the login shell's PATH at startup — plus a few conventional
tool directories — so a server that works from the terminal works from
the Dock too.
Under the hood
The client lives in the core, behind the same boundary as everything else: JSON-RPC over stdio, an initialize handshake before any document traffic, and full-document synchronization (incremental sync is a later optimization). Server messages are handled off the UI thread and reach the interface through the core's single event channel; a wedged server process gets a bounded grace period at shutdown and is then killed, so quitting Textchum can never hang on a misbehaving server. The whole protocol path is exercised in CI against a scripted server.
Instances also look after themselves: a server that crashes mid-session is restarted automatically with backoff (1 → 2 → 4 → 8 seconds; four failures in a row and it stays down until a restart or a configuration change), and an instance no open document has needed for five minutes is shut down — the next open starts a fresh one.
- Snippet completions expand and walk. The first placeholder comes back selected, so typing replaces it; ⇥ moves to the next stop and ⇧⇥ back to the previous one; a placeholder written more than once mirrors the one being typed. Reaching the end, pressing ⎋, or clicking outside gives the keys back.
- View ▸ Language Server Status lists the running instances and the session's recent status transitions, refreshed live, with a pointer to the full log.