The dusk language server
dusk-lsp is the language server for dusk, and like the compiler and dawn before it, it is written in dusk itself. It builds with the bootstrapped dusk compiler and then drives that same compiler for its answers, so the diagnostics you see in your editor are the compiler’s own, byte for byte, not a second front end that can drift out of step with it. It replaces vesper, the earlier server built on the retired Rust implementation, now that the toolchain is pure dusk. The source lives at github.com/choice404/dusk-lsp, currently at v0.6.1, dual licensed under MIT and Apache 2.0 on the same terms as dusk.
Features
Section titled “Features”Diagnostics come straight from dusk check --json, run on open, change, and save. Each one is published at the precise byte span the compiler flagged rather than a whole line approximation, so the squiggle sits under exactly the offending text. When the JSON envelope does not parse, the server falls back to reading the compiler’s plain text output, so you always get diagnostics of some shape. Semantic highlighting is painted from the compiler’s own dusk lex token spans, with comments folded in: keywords, functions, types, structs, enums, interfaces, numbers, strings, comments, variables, and directives all colored by the lexer that compiles the language. A range request paints one screenful at a time and drops every token outside the viewport, so a large file stays responsive.
Structure is exposed two ways. Folding ranges collapse brace blocks, block comments, runs of line comments, and runs of imports, each region on its own. Selection ranges grow the cursor outward through the word, then each enclosing bracket layer, then the whole document, so one keystroke widens the selection to the next structural boundary.
Hover shows a declaration’s signature together with its doc comment: the /** */ block written above the declaration with its gutter stripped when one is there, or the // comment block above it otherwise. Signature help follows a call as you type it, showing the callee’s signature, its parameters split out, and the active parameter tracked as the cursor passes each comma. Parameter name inlay hints label every argument at a call site with the parameter it fills, resolving the callee to its function first and dropping the label when the argument already reads as that name.
Navigation works across the whole workspace. Go to definition, document symbols, and workspace symbol search all resolve against the indexed declarations, and find references, rename with prepare support, and document highlight in the current file all skip matches inside strings and comments so a rename never touches a word in a string literal. Completion offers dusk keywords, builtin types, workspace symbols with their signatures, and identifiers from the current buffer, all prefix matched.
Quickfix code actions are built on the compiler’s own diagnostics rather than a separate lint pass: discard an unused variable or an unhandled error with .ignore(), remove an unused declaration outright, or export a definition another file cannot see. Each action hands the editor a WorkspaceEdit it applies in place, and the export action edits the file the definition actually lives in. Formatting is configurable in indentation and brace style, and position encoding is negotiated per client: utf-8 when the client offers it, which Neovim does, and utf-16 otherwise, which VS Code uses, so multibyte text never drifts out from under a span.
Building
Section titled “Building”The server is one dusk program. Point the build at a dusk compiler and its checkout, then run the script:
DUSK_BIN=~/projects/cool-lang/target/dusk-out/dusk \DUSK_HOME=~/projects/cool-lang \./build.shBoth variables default to the sibling ~/projects/cool-lang checkout, so on a machine laid out that way a bare ./build.sh works. The binary lands at target/dusk-out/dusklsp. Put it on your PATH or point your editor straight at it.
Neovim
Section titled “Neovim”The plugin lives in editors/nvim. Add it to your runtimepath and call setup:
vim.opt.runtimepath:append('~/projects/dusk-lsp/editors/nvim')require('dusklsp').setup({ cmd = { vim.fn.expand('~/projects/dusk-lsp/target/dusk-out/dusklsp') }, init_options = { duskBin = vim.fn.expand('~/projects/cool-lang/target/dusk-out/dusk'), duskHome = vim.fn.expand('~/projects/cool-lang'), format = { braceStyle = 'knr' }, },})That registers filetype detection for .dusk, a baseline syntax file for the moment before the server attaches, and an autocmd that starts dusk-lsp per buffer. Semantic tokens, hover, definitions, and vim.lsp.buf.format() all work out of the box on Neovim 0.10 and later.
VS Code
Section titled “VS Code”The extension lives in editors/vscode. Install its one dependency and load it as an unpackaged extension, or package it with vsce and install the result:
cd editors/vscodenpm installnpx @vscode/vsce package # produces dusk-lsp-0.6.1.vsixcode --install-extension dusk-lsp-0.6.1.vsixThen set dusk.serverPath, dusk.duskBin, and dusk.duskHome in your settings. The extension ships a TextMate grammar for instant coloring, and the language client layers the server’s semantic tokens on top once it attaches.
Other editors
Section titled “Other editors”Any LSP client works. Run dusklsp over stdio and hand it initializationOptions shaped like the Neovim example above. Helix, Kate, Emacs with eglot or lsp-mode, and Sublime with the LSP package all speak this protocol, so setup is a matter of pointing the client’s server command at the binary and passing the same options.
Configuration
Section titled “Configuration”Everything reaches the server through initializationOptions:
| option | meaning | fallback |
|---|---|---|
duskBin | dusk compiler binary the server shells out to | DUSK_BIN env, then dusk on PATH |
duskHome | checkout holding lib/ and runtime/ | DUSK_HOME env |
format.braceStyle | preserve, knr, or allman | preserve |
format.indentWidth | spaces per level | the request’s tab size, then 4 |
diagnostics.minIntervalMs | shortest gap in milliseconds between two compiler runs for the same buffer as it is edited | 250 |
Formatting
Section titled “Formatting”The formatter runs two passes. The brace pass moves opening braces to match the configured style: K&R keeps the brace on the declaration line, Allman gives it its own line, and preserve leaves placement alone and only reindents. The indent pass then recomputes every line’s leading whitespace from bracket depth. Both passes track strings, char literals, line comments, and nested block comments, so a brace sitting inside any of those never moves anything. Formatting is idempotent, and the K&R and Allman styles round trip cleanly between each other.
How it works
Section titled “How it works”The server subprocesses the compiler rather than linking it. dusk check supplies diagnostics and dusk lex supplies the token spans behind semantic highlighting. Edited buffers are checked through a hidden shadow file written beside the real one, so @import resolution walks the same project tree the compiler would see at build time and never checks a file in isolation. Definitions, hover, and symbols come from a line anchored scan for export, async, func, struct, enum, and interface declarations, indexed across the workspace on first use and refreshed per file on every edit. When the client supports dynamic registration the server asks it to watch the workspace’s .dusk files, so a file created, changed, or deleted outside the editor updates the index too, and a save rechecks every other open document, since one file’s changed signatures can turn a call in another valid or invalid.
Text synchronization is incremental. A change that carries a range splices just those bytes into the buffer, its start and end read in the negotiated encoding and turned into byte offsets against the current text, and the buffer is rebuilt after every item in a batch since each splice shifts the offsets the next one names. A change with no range replaces the whole document, the fallback older clients still send, so both shapes keep working. The doc comment a hover shows is read directly by def_doc in src/symbols.dusk: it takes the /** */ block above a declaration when one is there, stripping the leading * gutter and joining the lines, and falls back to the // comment block above the declaration otherwise.
Known limits
Section titled “Known limits”- A long editing session grows in memory, though far slower than it once did now that JSON message trees are deep freed through std.json’s
json_freeand the buffer a change replaces is freed on every edit: roughly 12 kB per edit, down from 42, about 6 MB after 500 edits on a 200 line file. It has not fully plateaued, since the definition index and the diagnostic structures still leave allocations behind on each pass, but a normal day of editing stays comfortably small and restarting the server resets it. dusk checkon an edit is rate limited rather than debounced. A change withindiagnostics.minIntervalMsof the last run for that buffer updates the stored text but skips the compiler, leaving the previous diagnostics in place until the next edit past the window. Open and save always run. The knob defaults to 250 milliseconds; set it to 0 to check on every keystroke.- The definition index is line anchored, so a declaration that does not start its own line is missed.
See also
Section titled “See also”- github.com/choice404/dusk-lsp: the server source, editor plugins, and the framed LSP test suite
- The dusk CLI: the
checkandlexcommands the server drives - Getting started: installing the compiler the server needs