For editor users and contributors: connect Volta's diagnostics, completion and navigation to a supported code editor.
Canonical reference for volta-lsp. Design history: Language server and editor support. Packaging and future workspace work: Ide Editor Delivery.
Current capabilities#
volta-lsp is a tower-lsp stdio adapter over the compiler-owned, wasm-safe volta_compiler::ide analysis product. Native and browser workbenches consume the same source-aware product; the server owns document scheduling and protocol translation rather than a separate language-intelligence pipeline. The native workbench runs ordinary analysis on a long-lived compiler thread; the browser runs it in a module Worker with a separate compiler-wasm instance. Both use immutable import snapshots and revision/generation rejection before installing results, while the LSP retains its workspace-aware scheduler.
The server accepts incremental document changes and advertises:
- diagnostics with stable codes (
volta/parse,volta/unbound, …), an analysis phase, and structured data; hover; contextual ranked completion with explicit replacement edits (automatic imports included); and space-triggered signature help; - definition, type definition, references, document highlights, prepare-rename/rename, hierarchical document symbols with name-token selection ranges, and workspace symbols (open closures plus unopened workspace files through a syntax index);
- full, range, and revision-keyed delta semantic tokens;
- full-document and range formatting (the canonical formatter's minimal changed line window);
- opt-in inlay hints (inferred type/unit/rate) and compile-verified code actions: the missing-import quick fix, Remove unused import, organize imports, extract/inline binding, and Extract to module… (see "Editor help for imports" below);
volta/unused-importwarnings (taggedunnecessary, so editors fade them) for a selector name the song never uses or an alias it never qualifies;- selection ranges and folding ranges;
- legacy-spelling hints (
volta/legacy-spelling, HINT severity, entry buffer only): each spellingvolta fmt --modernizewould retire, naming the canonical one (forconst 900Hzit says to write900Hz) — an advisory, never a gate.
Keyword and builtin hover uses the same generated help as the shared editor: named arguments, accepted types, required/optional status, defaults, live-value policies, return values and examples. Parameter schemas and the compiler type registry supply the mechanical details; the catalogue supplies prose and polymorphic signatures. Qualified names resolve to their registered symbol.
Definitions, references, highlights, prepare-rename, and rename use source-qualified FileId/SymbolId values whose identity is scope-discriminated: top-level terms, type definitions, constructors, nominal record fields, namespace aliases, and lexical binders (shadowing spellings are distinct identities keyed by their binding site). Shred :=/trigger targets and mixer/bus/group name references are occurrences too, so renaming a macro or track reaches the mix surface. Queries span the import closures of the currently analyzed open documents, and rename also discovers UNOPENED workspace importers through the syntax index, analyzing each as its own entry (bounded; past the cap the rename is rejected rather than left partial).
Rename returns versioned documentChanges and is verified twice per affected closure: the proposed snapshot must recompile without new errors, and its occurrence graph must be preserved up to relabeling — the partition check that rejects silent capture (renaming x to y in \x -> \y -> x still compiles, but the reference migrates to the inner binder and the rename is refused with an explanation). Existing paths use canonical filesystem identity, including symlink aliases; never-saved paths use normalized lexical identity, and Windows identity is case-insensitive. The exact open client URI remains the display and navigation target.
The server analyzes complete immutable snapshots on one bounded background compiler worker. Pending work coalesces per entry; a newer revision cooperatively cancels the active one between compiler phases and inside type inference/specialization, and the commit gate still rejects any stale result. LSP I/O and document mutation therefore do not wait for compilation.
Imports resolve relative to the file containing each import, then from project and workspace roots (including each root's lib/), followed by explicitly configured library/toolchain directories, then the song workspace (~/Documents/Volta and its lib/, lsp/src/project.rs). VOLTA_STDLIB supplies additional library paths, last. There is no repository-layout assumption, and registry imports are not fetched. Open imported documents are authoritative over disk, and changing one reanalyzes every open dependent entry.
didSave, watched .volta create/change/delete events, workspace-folder changes, and live analysis-configuration changes use the same revision and dependency invalidation path as editor changes. A disk event for an open buffer is ignored because the open text remains authoritative.
Analysis is cached by its actual dependencies, in one process-wide AnalysisCache (compiler/src/ide/cache.rs; the workbench keeps its own): a file's parse is keyed by its bytes; resolution and the checked program by the whole import closure (the entry's bytes plus each reachable module's import path and bytes); the prepared plan by that closure plus the host's elaboration budgets, region seed, whether the silence debugger runs, the sample index and the file each import path resolved to. Each store is bounded and insertion-ordered (64 entries; parses ×8, declarations ×64 and refreshed on a hit); a failed check or elaboration is not cached. The compiler's own fingerprint is a component of every key, and no cache is ever written to disk, so a rebuilt compiler starts empty. Keys are content, never revisions, so caching composes with the revision fence instead of competing with it: the fence still decides which snapshot may be committed, and a cache hit is possible only between requests whose inputs are byte-identical — in which case their answers are identical too. A warm snapshot equals the cold one field for field and query for query over the corpus sample (compiler/tests/ide_cache.rs; songs whose cold analysis is itself nondeterministic are bounded, not compared).
Per-declaration reuse. Editing one declaration does not re-check the file. The checker's binding loop replays an already-typed declaration's whole contribution to the checking state — its typed value, its declared type, its reference edges, its pattern-call and builtin-alias rows, its refinement obligations and its generalized inference scheme — instead of inferring the body again (compiler/src/check/reuse.rs). A declaration is replayed when five things are unchanged: the compiler's own fingerprint, the ambient header and type environment, its own bytes, the spans the parser gave them (relative to its own start) and the span-free shape of its parsed tree, the transitive source closure of the top-level names its parsed tree reads, and the resolver's identity of it and of every declaration in that closure. The closure is stricter than the declaration's signature on purpose: a reader sees a referenced declaration's TEXT, not only its type (melody = "c3 e3" and melody = "1 2 3" both have the signature Str and give a reader Pattern Pitch and Pattern Num). It is read off the parsed TREE because the parser builds references the bytes do not spell (a "${x}" hole, the hidden \u{1}tupN behind (a, b) = e); the ids are there because an edit elsewhere can renumber the declarations a typed tree names.
Two rules follow, and both are visible in an editor:
- A declaration whose bytes MOVED is replayed with rebased spans. A typed tree is full of absolute byte offsets, so the stored delta is rebased by the shift — every span in the typed tree, the pattern-call and alias rows, the obligations and the deferred inference rules (
compiler/src/check/rebase.rs, an exhaustive walk). That is sound only for spans that moved with the declaration, and the checker copies text from OTHER declarations into a typed tree (an inlined top-level lambda body, an embedded options record, a static value read through a name); each such source is recorded as a span donor on the delta, and a moved declaration is replayed only when every donor sits in the same file and moved by the same shift. Typing at the top of a file therefore replays the file; an edit BETWEEN a donor and its reader re-checks the reader and nothing else (ide_cache::a_moved_declaration_is_replayed_only_when_its_span_donors_moved_with_it). - A parse is not a function of a declaration's own bytes either. An application's span ends at its last argument and the parser's lookahead can carry it past the declaration, so the last declaration before an edit is re-checked too.
Where a replay is not provably the cold answer it does not happen: a declaration that forward-inferred another one from inside its body, one whose generalized scheme is not closed under quantification, one whose obligations carry another declaration's spans (a type's refinement predicate), and one whose local-id or embedded-options counter has drifted are all inferred instead. Reuse can therefore only make the same answer cheaper. Two tests assert it: ide_cache::a_one_declaration_edit_answers_the_cold_snapshot_for_every_corpus_song (an append to every song of the corpus sample — every song under VOLTA_FULL_CORPUS=1), ide_cache::a_line_inserted_at_the_top_answers_the_cold_snapshot_for_every_corpus_song (every declaration moved; most must replay through the rebase) and reuse_differential — warm against cold after mid-file edits over a corpus sample, a proptest generator over desugared forms, and a regression per form. The prepared plan is not reused per declaration — elaboration's late passes (scene mixer addresses, parameter-lane resolution, bounce ordering, strict mode, capability declaration) rewrite the finished module, so no declaration's lowered contribution is final at its own point in the walk.
Compiler provenance survives module flattening through parse, resolve, check, lower, doctor, and advisory diagnostics. An imported error is therefore published at the imported file's URI and mapped against that file's text—not the importer's offsets. Diagnostics from several importers are deduplicated; an open target receives its own client version, stale snapshots are excluded, and recovery or removal of the last importing owner clears the old result. An importer that uses a failed imported declaration gets its own cannot use `name` diagnostic at the use site; the definition's error is not repeated there. A healthy imported function whose obligation fails at a call is the caller's error alone. The CLI runs the same recovering checker, so volta --diagnostics-json reports every broken declaration in a buffer rather than only the first (it folds a use site's cannot use into its root and adds independent lowering errors), and on the pinned fixture its rows match the ones the server publishes and the workbench paints — message, byte span and severity (compiler/tests/fixtures/ide_agreement.{volta,expected.json}, read by compiler/tests/ide_agreement.rs, lsp/src/server.rs and ui/tests/ide_agreement.rs).
Syntax-derived symbols, folding ranges, and selection ranges remain available through resolution and type failures. A type error in one ordinary binding preserves typed information for independent healthy bindings. Ordinary binding annotations recover too, including unknown type names. The checker accumulates binding diagnostics, rolls back provisional inference, marks failed declarations unavailable and removes their transitive consumers from typed queries. This includes consumers checked before the failing declaration; a signature cannot make an invalid binding appear valid. Locals, patch fields and qualified builtins retain their lexical identities. Deliberate named holes remain valid open values. A type declaration whose body names an unknown type, has an alias cycle or a duplicate constructor, and a failing ambient block (mixer { … }, bus, group, or one in an imported library), recover the same way: only that declaration or block is lost, the file's other declarations and blocks stay typed and every semantic query keeps answering while its diagnostic stands; a reference to a failed type reports unknown type at the reference. Import failures can still prevent typed recovery. Any recovered type error prevents executable IR, cost and doctor results for that revision.
A parse failure goes further: the compiler's segment recovery masks the broken top-level declaration in place (byte offsets preserved, the mask minimized) and writes a hole stub where it stood, so the intact rest of the document keeps identity, navigation, and an exact current-text completion snapshot while the syntax diagnostic stands. The product phase is skipped under recovery — a partial program never publishes IR, cost, or doctor results as a successful compile. Diagnostics carry their phase (syntax/semantic/product) so one failed phase neither erases nor masquerades as another's results.
Code actions recompile their proposed snapshot before they are offered: a quick fix must strictly reduce errors (one that clears a warning, Remove unused import, must only add none), a refactor must not add any. The missing-import fix and automatic-import completion draw their vocabulary from the workspace syntax index (unopened .volta files under the project roots, public exports derived by the compiler); an accepted auto-import candidate carries its import line as additionalTextEdits.
Completion uses the same compiler-owned query as the native and browser workbenches. Pipe stages, modulation holes, patch fields, numeric unit suffixes, comment/string suppression, candidate order, documentation, and parameter-slot grouping therefore do not drift by adapter. Named imports and entry symbols remain available from syntax when semantic checking fails. LSP items carry a rank-preserving sortText and a precise textEdit (for example, 440m replaces only m with ms). Compiler snapshots add lexical locals and parameters, public sum constructors, record/namespace/module fields, expected type/unit/rate ranking, and deterministic usage ranking (resolved reference counts break ties within a band). Under segment recovery a broken revision serves an exact snapshot of the current text; only when recovery itself fails does completion degrade to last-good global spellings without scoped facts, members, expected types, or snippets.
Clients receive snippets and InsertReplaceEdit only when they explicitly advertise support; legacy clients receive plain full-token TextEdits. Detail and documentation remain eager unless the client advertises each property for completion resolve. Resolve data is fenced by URI, client document version, the LSP's monotonic internal revision (which also changes after import or project invalidation), query offset, and candidate identity. A stale or malformed resolve request succeeds without modifying the item.
Editor help for imports#
Every published diagnostic carries its stable code (volta/unbound, volta/unused-import, …). The import helpers below are compiler-owned and I/O-free (compiler/src/ide/imports.rs); the server supplies only the files (lsp/src/import_paths.rs).
- Hover on a name another module exports — at a use or in the
import "…" ( … )list — shows its type, its declaration head, its doc (the--block above it, or@doc), its kind,from "drums"and the file the import resolved to (a registry pack shows its spec). Hover on the quoted path shows the module's blurb and its exports with kinds. Both readvolta_compiler::module_summary, the one module scanner (Reuse sounds and patterns with imports § Editor help for imports). A renamed import ((kick as k808)) answers atk808in code and at either spelling in the list; apub importre-export is followed to its defining module, so hover, go-to-definition and references land on the real binding. Renaming that binding rewrites the exported spelling (kick) and leaves an importer's local name (k808) alone. - Completion inside
import "…"offers module paths, each spelled the way the resolver will find it from this document, in resolution order: sibling files (the document's folder, three levels down), workspace files, library modules, then registry packs already in the local cache (newest version,@owner/name@N; nothing is fetched). A path another location shadows is not offered. Inside( … )it offers the module's public exports not yet listed — itspub importre-exports included — with signature and doc; afterasit offers nothing (a fresh local name). In code, a renamed import completes under its local name. volta/unused-import(Warning): a selector name the entry never spells again, or an alias it never qualifies; when every name of a statement is unused, one warning covers the statement. The check is lexical — a record field or${…}splice spelling the name counts as a use — so it can miss an unused import but never flags a used one. Header names imported by name (tempo,key, … — resolve'sENTRY_ONLY_NAMES) never warn: they set the song's header. A renamed name is judged by its local name (k808, never akickspelled elsewhere), and apub importnever warns — its importers are its use. Over the 1380 files ofexamples/songsit reports 35 warnings in 20 files. Remove unused import deletes the selection (kick as k808whole, with its comma) or the whole statement line; Organize Imports also drops unused names and statements, keepingasrenames and thepubkeyword.- Import paths (C2): a path the grammar rejects with an obvious fix — a trailing
.volta,\separators — offers the quick fix Change toimport "drums", which rewrites the quoted path. It is verified to add no error (the respelled module may not be in the snapshot yet). Module lookup is case-exact like the gatherer's:Drums.voltadoes not answerimport "drums"on a case-insensitive disk, and the missing-import error names the near miss. - Add import (the missing-name quick fix and automatic-import completion) spells a workspace module's path relative to the first import base that reaches it — the document's folder, then each project root and its
lib/, then the library paths — and checks the resolver's first hit for that spelling is the same file:kits/808, never the stem808. Cached registry packs exporting the name are offered as@owner/name@N. - Extract to module… (
refactor.extract) moves the top-level bindings the selection touches, plus helpers only they use (writtenprivin the module), into a new sibling module named after the first binding — or into a sibling module the song already imports — carries the imports the moved text needs, imports the moved names back where the song still uses them, and drops song imports only the moved code used. It is one workspace edit:CreateFilefor a new module, then a text edit per file. It refuses with a reason (a disabled action on the new-module target) when a moved binding uses a name the rest of the song also uses, when the song has errors, when the selection is the song itself (main/song), or when the target already defines a moved name; an extraction whose result would not compile is never offered. The sameide::extract_to_modulereturns anide::ModuleExtraction(changes: [FileChange { file, create, edits }]) for the workbench.
VS Code#
The extension in editors/vscode/ starts the server, exposes lifecycle status, restart, version, and trace commands, and offers recovery when the executable is missing. See its README for installation and development.
volta.lsp.path selects an explicit executable. At its default, a release package may supply server/<platform>-<arch>/volta-lsp[.exe]; otherwise the client uses volta-lsp from PATH. volta.trace.server accepts off, messages, or verbose.
The extension registers a **/*.volta watcher. volta.analysis.projectRoots adds import roots, volta.analysis.libraryPaths adds library/toolchain roots, and volta.analysis.doctor controls advisory diagnostics. These settings apply live; workspace folders are import roots automatically.
The TextMate grammar is a fallback, not a semantic authority. Its builtin name expression is generated from docs/reference/builtins.json and checked by:
npm --prefix editors/vscode run metadata:check
Other clients#
Configure a stdio language server command volta-lsp, language id volta, and the .volta file extension. Formatting is standard textDocument/formatting; selection expansion and folding use the corresponding standard LSP methods. Clients may pass initializationOptions.analysis with doctor, projectRoots, and libraryPaths, and may send the same shape under workspace/didChangeConfiguration. Disk invalidation requires the client to forward .volta watched-file notifications.
Tests and known gaps#
Compiler-owned analysis has unit coverage in volta-compiler; the LSP crate tests document scheduling, protocol mapping, and position conversion. The VS Code client has offline lifecycle tests and a separate extension-host smoke. The Rust suite includes tower-lsp JSON-RPC coverage for revisioned diagnostics, open-import recovery, source-qualified definition/references, shadowing-safe multi-file rename, collision rejection, diagnostic backpressure, background coalescing/cancellation, save and watched-file invalidation, live project configuration, and workspace-folder changes. Path tests cover symlink aliases, platform case policy, cycles, and diamond imports. A packaged real-server stdio conformance matrix, manifest-based project discovery and full-project/versioned refactors remain planned in Ide Editor Delivery and the shared analysis-service work.