Language servers
fedit talks to language servers over stdio using the Language Server
Protocol (JSON-RPC under Content-Length framing). Servers start on
demand when a file they own opens, receive the full buffer text on every
edit, and push diagnostics back into the status bar. Navigation —
definition, references, hover — works from the keyboard; :lsp manages
the server set.
Built-in servers
Four servers are configured out of the box:
| Name | Command | File types | Root markers |
|---|---|---|---|
sema | sema lsp | sema | sema.toml |
typescript | typescript-language-server --stdio | ts, tsx, js, jsx | tsconfig.json, package.json |
rust | rust-analyzer | rs | Cargo.toml |
pyright | pyright-langserver --stdio | py, pyi, pyw | pyproject.toml, setup.py, setup.cfg, requirements.txt |
Install pyright with npm install -g pyright (or pip install pyright).
To use a different Python server, replace the entry by name — see
Configuration. For example, basedpyright:
{
"languageServers": {
"pyright": {
"command": "basedpyright-langserver",
"args": ["--stdio"],
"fileTypes": ["py", "pyi", "pyw"],
"roots": ["pyproject.toml", "setup.py"]
}
}
}
A server only ever starts if its binary is on PATH and a matching file
opens. The workspace root passed to the server is found by walking up
from the file to the nearest root marker, falling back to the fedit
workspace root. One client runs per server + resolved root pair, so two
projects in one workspace each get their own server instance.
Configuration
Servers live under a languageServers object in
~/.config/fedit/config.json. Each key is a server name; each value
takes command, args, fileTypes (extensions without the dot), and
roots (files or directories that mark a project root). A user entry
with a built-in’s name replaces that built-in entirely — there is no
per-field merge.
{
"languageServers": {
"gopls": {
"command": "gopls",
"args": [],
"fileTypes": ["go"],
"roots": ["go.mod"]
},
"sema": {
"command": "/opt/sema/bin/sema",
"args": ["lsp"],
"fileTypes": ["sema"],
"roots": ["sema.toml"]
}
},
"disabledLanguageServers": ["typescript"]
}
disabledLanguageServers lists server names the editor must not start.
fedit writes this key when you toggle a server (:lsp enable/disable);
the languageServers block itself is yours — the editor never rewrites
it.
Navigation
| Chord | Secondary | Action | What it does |
|---|---|---|---|
Ctrl+B | F12 | goto-definition | Jump to the definition; multiple candidates open a picker. |
Ctrl+Shift+B | Shift+F12 | find-references | List references in a picker; Enter jumps, Esc closes. |
Ctrl+K | F1 | hover | Show hover text in the dock; the next keypress dismisses it. |
Ctrl+Alt+Left | Alt+- | jump-back | Return to where the last jump left from (a 50-entry stack). |
Ctrl+Click on a symbol also jumps to its definition, cursor placed at
the clicked cell first.
The primaries follow JetBrains (Ctrl+B go-to-declaration; the sidebar
toggle moved to Ctrl+T to free it) and were picked to survive terminal
key encoding on a stock macOS setup: the F-keys need Fn, F1 is the
system help key, and Alt+- types an en dash (–) unless “Use Option
as Meta key” (Terminal) / “Esc+” (iTerm2) is enabled. The secondaries
stay bound for full-size keyboards and option-as-meta users.
Ctrl+Shift+B needs the enhanced keyboard protocol (kitty, ghostty,
WezTerm, iTerm2 — same requirement as the macro chords); a legacy
terminal sends plain Ctrl+B, degrading to the definition jump. Hover’s
Ctrl+K is a single binding, not a sequence prefix — your keybinds file
can still override it or define ctrl+k … sequences (a user sequence
wins over the standalone default). Rebind any of these in
~/.config/fedit/keybinds, e.g. editor f9 = goto-definition.
Location pickers show one row per location — relativePath:line: plus a
preview. For definitions and references the preview is that line, read
from the open buffer when the file is open and from disk otherwise;
diagnostics rows preview severity: message instead. Type to filter,
Enter jumps (and pushes the jump stack), Esc closes.
Diagnostics
Servers push diagnostics after every open and change. The status bar’s
[DIAGNOSTICS] token shows compact severity counts for the active
buffer — E2 W1 means two errors and one warning; a clean buffer shows
nothing. The segment stays uncolored on purpose (one accent per
surface); severity shows as text, not color, in the pickers too.
A custom statusFormat in config only renders the tokens it names —
keep [DIAGNOSTICS] in yours or the counts never appear (configs saved
before the token existed migrate automatically when they still carry
the old default format).
:diagnostics opens the active buffer’s diagnostics in a location
picker with severity: message previews (error: unknown symbol).
Managing servers
:lsp— open the manager picker. Each configured server shows a status badge (running,starting,failed,stopped,disabled,idle);rrestarts,eenables/disables,lshows the log.:lsp status— print one status word per server in the status bar.:lsp restart [server]— shut the server (or all servers) down and re-open every document it owns.:lsp enable <server>/:lsp disable <server>— toggle a server and persist to config. Disabling also shuts it down and drops its diagnostics; enabling re-opens matching documents immediately.:lsp log [server]— show the recent stderr ring (last ~200 lines per client, dock shows the tail) — the first place to look when a server misbehaves.
How it works
The MVU seam mirrors syntax highlighting. A per-dispatch diff
(Editor.lspSyncEffects) compares open file-backed buffers before and
after each update: a path appearing emits didOpen, an edit-tick move
emits didChange (full text; the buffer’s EditTick is the document
version), a path vanishing emits didClose. The Runtime interprets
those effects on a single task chain per process, so a didChange can
never outrun its didOpen, and materializes buffer text off the update
thread.
Requests (definition, hover, references) carry the buffer’s EditTick;
responses echo it, and the update layer drops any result whose tick no
longer matches or whose buffer is no longer active — a stale position
must never move the cursor or yank the view from another buffer. Buffers are
LF-normalized in memory, and both fedit and LSP address positions as
0-based line + UTF-16 code unit, so positions cross the wire without
conversion.
Servers print startup banners to stderr; fedit drains and rings that
output (:lsp log) and never treats it as failure. On quit, fedit sends
shutdown and exit, gives each server 250 ms, then terminates it —
the protocol requires servers to tolerate client termination, and quit
must not wait on a slow exit path. All clients shut down concurrently,
so quit stays instant however many servers are running. A server that
exits mid-session shows as failed in :lsp with the exit code in the
log.
Troubleshooting
- Nothing happens on Ctrl+B / F12 — check
:lsp status.idlemeans no matching file has opened yet;failedmeans the spawn or handshake broke, including a binary missing fromPATH—:lsp log <server>has the stderr. - Definitions in other files come back empty right after startup —
servers index the workspace asynchronously (sema scans
.semafiles afterinitialized); retry after a moment. - Config changes don’t apply —
languageServersis read at startup; restart fedit after editing the block.:lsp enable/disable/restartapply immediately. fedit --log /tmp/fedit.logtraces every LSP effect and message dispatch alongside the rest of the editor’s event flow.