Skip to content

`bynk-lsp`

The Bynk language server. The bynk-lsp crate builds the bynkc-lsp binary, a tower-lsp server that communicates over stdio. Editors talk to it; most users reach it through the VS Code extension rather than directly. See the Set up editor support how-to for wiring it into an editor.

Text is synchronised in full: on every change the whole document is re-sent (TextDocumentSyncKind::FULL), and the server holds the current buffer in memory. When a project root with a bynk.toml is found, the server enables cross-file features; otherwise it operates in single-file mode. Project discovery and the analysis model that underpins these features are described in Architecture and Project discovery and performance.

Every capability below is advertised in the server’s ServerCapabilities and backed by a request handler.

CapabilityWhat it does
DiagnosticsRecovering compilation surfaced as squiggles — live by default (debounced at the configured diagnostics_debounce_ms, default 300 ms) and re-run when watched files change. With a project root diagnostics are project-wide: the whole bundle is analysed, open buffers overlaid on disk, so an error in one file shows on the file that owns it. Each diagnostic carries its bynk.* code; for the curated, highest-traffic codes the code is rendered as a clickable link (codeDescription) to its explanation in the Book — the same explanation bynk explain <code> prints. Codes without a curated explanation carry no link (graceful fallback, not an error).
HoverType signatures and doc blocks for the symbol under the cursor, resolved through the binding index so the description matches the actual definition — a name-match fallback answers only where the index does not resolve. Works on a name’s uses, not just its declaration: inside an agent handler body, a store/key field reference describes the field, a record-construction label (Stored { title: … }) describes the type’s field, and a store operation (items.put(…)) describes the operation over the field’s declared kind. Every kind the index carries is described: an actor (by u: User), a method — the one the call binds to, so g.bump() describes Gauge.bump and not a same-named Counter.bump — and a capability operation, attributed to the capability that declares it, including where a project declares one whose name a built-in shares. A [Name]/[Owner.member] intra-doc link inside the rendered doc comment becomes a clickable link when it resolves the same way document links do; unresolved names render as plain text. Hovering a single-handler service also appends its wire contract — the request envelope, the cross-context contract form + hash (on call only), and reachable HTTP responses — and hovering a handler’s own header (on GET(...), on call) answers with that same wire contract standalone, a rung no earlier hover behaviour ever occupied.
Go-to-definitionJumps to the declaration of types, functions, capabilities, services, agents, providers, and actors (a handler’s by u: User clause) — cross-file, via the project index. Local bindings resolve scope-correctly; uses/consumes unit segments jump to the unit’s source.
Go-to-type-definitionFrom a value to the declaration of its inferred type. Reads the value’s type from the round’s expression types and lands on the named type’s declaration.
Find referencesProject-wide occurrences from the binding index, including clause lists and test units. Local bindings return their definition plus uses within the file.
RenameProject-wide rename; prepareRename validates that the symbol is in scope and refuses out-of-scope kinds. Emits versioned edits, and re-analyses with the edits applied to reject collisions and silent re-bindings before returning.
FormattingWhole-document formatting via the shared bynk-fmt; a parse error yields no edits (the diagnostic flow reports it).
Range formattingPartial-document formatting. Per spec it may return edits wider than the requested range.
Document symbolsThe file outline for the editor’s symbol view and quick-open.
CompletionScope- and context-aware: units after consumes , capabilities inside consumes U { … } and after given , in-scope locals at keyword and expression positions, and members after . on a typed value receiver. Documentation is resolved lazily on the focused item so the initial list stays cheap.
Signature helpThe active parameter of the call being typed, triggered on ( and ,. Covers named callees and value-receiver methods.
Code lensA reference-count lens above each top-level definition, clickable to peek the references, plus a “Show Sequence” lens above every handler.
Sequence diagrambynk/sequenceModel — a custom (non-standard) request, advertised via ServerCapabilities.experimental rather than a dedicated provider field — classifies the handler under the cursor’s calls into runtime-participant lifelines (consumed capabilities, consumed contexts, agents) for the VS Code extension’s “Bynk: Show Sequence Diagram” webview. Served from the committed round; re-issued fresh by the client on each invocation (no refresh-push mechanism).
Documentation viewbynk/documentationModel — a second custom request (same experimental advertisement) — aggregates the whole file’s declarations into a rendered reference page for the VS Code extension’s “Bynk: Show Documentation” webview: each declaration’s heading, signature, and doc comment rendered as Markdown, in outline order and hierarchy, with undocumented declarations shown as a coverage signal (toggle to hide). Reuses hover’s signature/doc assembly so the page agrees with hover. Served from the committed round; re-issued on each invocation.
Architecture mapbynk/architectureModel — a third custom request (same experimental advertisement), project-scoped rather than file-scoped — maps every context/adapter in the active file’s project: one node per unit, a directed edge per consumes (labelled with its selected capabilities where braced), and each node’s locally-declared and consumed capabilities/providers plus its services/agents, for the VS Code extension’s “Bynk: Show Architecture Map” webview. The macro counterpart to the sequence diagram above. Served from the committed round; re-issued on each invocation.
Wire contractbynk/wireContract — a fourth custom request (same experimental advertisement), file- and position-scoped like the sequence diagram — the wire shape a boundary handler’s request/response actually takes: the request envelope (bare value for a single param, an empty body for zero, a keyed object for two or more), the cross-context contract form + hash for an on call handler, the reachable HTTP response set (declared, constructed, and boundary-implicit — e.g. the 400 every param re-validation can produce), and every referenced boundary type’s shape with its re-validation strategy (owner constructor, inlined predicate, structural-only for an opaque type, or base64 decode for Bytes) — for the VS Code extension’s “Bynk: Show Wire Contract” webview and a matching hover rung on the handler header. Served from the committed round; re-issued on each invocation.
Call hierarchyIncoming and outgoing calls over the binding index’s call graph.
ImplementationFrom a capability to its providers (the reverse direction, provider to capability, is served by go-to-definition).
Document linksuses/consumes unit names — and a test file’s suite <target> header — become clickable links to the unit’s source file. Inside doc comments, a [Name]/[Owner.member] intra-doc link also becomes clickable when it resolves against the declaring unit’s scope (itself, its uses, its consumes); an unresolved one renders as plain text.
File rename awarenessRenaming or moving a .bynk file rewrites its own declaration name and every other file’s uses/consumes reference that pointed at it. Single-file rename only — a suite or a folder move is left untouched.
Document highlightThe matching binding’s occurrences highlighted across the active file.
Folding rangesStructural folds and comment runs, driven by the recovered AST (no analysis round needed).
Selection rangesExpand-selection by syntactic nesting — the enclosing-node chain for each position.
Code actionsQuick-fixes built from the structured suggestions carried on diagnostics, served from the cached round so they agree with the squiggles on screen. Capability-aware fixes turn boundary/resolution errors into one click: fill a record construction’s missing field(s) with a typed default, add a missing consumes for an unconsumed cross-context call, and — Bynk’s analogue of auto-import — add the uses <commons> or consumes <context> { … } that brings an unresolved name into scope (one action per resolving candidate; a braced consumes is extended in place). Plus two refactors over the same selection: extract-variable binds the smallest covering expression node to a fresh let; extract-function lifts it — a single expression, or a contiguous run of full statements (optionally including the block’s tail) — into a new top-level fn, threading its free identifiers as parameters — offered only when the selection uses no capability (a plain fn has no given clause) and, for a statement run, introduces no binding still used afterward and contains no store-field write.
Inlay hintsInferred-type hints for the visible range, plus materialisable ghost given hints for uncovered capability requirements.
Semantic tokensResolution-aware highlighting (full document and range), additive over the client’s syntactic layer, read from the cached index.
Workspace symbolsSymbol search across the index’s definitions, filtered by query — aggregated over every open project (the one cross-project query in a multi-root window).
File watchingRe-checks diagnostics when .bynk files change on disk, and reloads a project’s bynk.toml when the manifest changes. The server registers the watchers itself (dynamic didChangeWatchedFiles), so any client is notified — not just those that supply watchers client-side.
Startup analysisOn activation the server discovers and analyses every project under the workspace folders, so diagnostics appear before any file is opened. Folders added later are warmed the same way.
Workspace foldersReal multi-root support: several projects open at once, each analysed independently; a file routes to its nearest bynk.toml. Adding or removing a folder adds or prunes its projects.

From the workspace root:

Terminal window
cargo build --release -p bynk-lsp

The binary is target/release/bynkc-lsp. Put it on PATH, or point your editor at it explicitly (in VS Code, the bynk.executablePath setting).

bynkc-lsp --version prints the version and exits without entering the protocol loop, so tooling (such as the VS Code status bar) can query it without the server blocking on stdin.

The Backend holds the mutable server state behind a tokio::sync::RwLock:

  • a map of projects keyed by discovered root — one editor window may hold several. Each entry carries its own parsed configuration, analysis round, freshness generation, and published-diagnostics set (so two projects analyse and publish independently),
  • the workspace-folder seeds — where projects are discovered and pruned, not how requests route, and
  • the open documents, keyed by URI, each with its current text and version — a client-global set; each doc routes to its project by its nearest bynk.toml.

A document change runs recompile_and_publish, which routes the URI to its project. In single-file mode (no manifest) that diagnoses the one buffer directly; in a project it schedules that project’s debounced project-wide round. Hover and go-to-definition first consult the binding index, falling back to a re-parse of the AST under the cursor for kinds the index does not carry; formatting delegates to bynk-fmt.

Each project-wide analysis retains one round’s outputs, held together so that every position converts against the text the analysis actually saw — not the live buffer, which may already have moved on. A round carries:

  • the binding index — the call graph and cross-file symbol table that references, rename, definition, hover, call hierarchy, implementation, and workspace symbols all read;
  • per-file analysed snapshots — the exact text each span is an offset into; every span-to-position conversion uses these;
  • the open-document versions captured when the overlay was built — the freshness contract gates on these (below), rename emits versioned edits against them, and published diagnostics carry them so a client can drop a range its buffer has moved past;
  • the full diagnostics per file, including the structured suggestions that code actions ride on (clean files retain an empty entry);
  • inferred-type hints and the capability-requirement ledger that drive the two kinds of inlay hint;
  • local bindings with scope ranges, for scope-correct local navigation;
  • expression types, which back go-to-type-definition; and
  • a unit-name-to-source map and a doc-link scope map, which back document links (including intra-doc links inside doc comments) and hover’s doc-link rendering.

The freshness contract. An index-backed request always answers against the buffer the client currently holds. Each analysed file records the document version it was analysed at; when a request arrives for a file whose buffer has moved past the last round, the server refreshes — runs a round over the current buffers — before answering, so a position is never resolved against text the user has already edited past. When it cannot reach the requested version (a file outside the project, or a concurrent edit that raced the refresh) it returns nothing rather than a stale answer. A round is fast (single-digit milliseconds for a typical project), and concurrent requests after one edit share a single refresh.

The requests the editor re-fires on every keystroke — semantic tokens, inlay hints, code lenses, document links, and code actions — take a lighter path. Because they resolve nothing against your live cursor (each decoration’s ranges are computed against the snapshot the round analysed), they are served from the last committed round as-is, without forcing a fresh round on every keystroke. The decorations may lag your typing by at most one debounce cycle; when the debounced round commits, the server asks the editor to re-pull them, so they catch up on their own. This keeps typing responsive on large projects, where forcing a whole-project round per keystroke grew slower with project size.

The server resolves each file to its project by walking upward from the file to the nearest bynk.toml (the same attribution bynkc gives it) — so several projects across one window’s workspace folders each analyse independently, and a file’s workspace folder does not decide its project. Whether a file resolves to a project decides its feature set:

  • In a project — cross-file lookups, project-wide diagnostics, workspace symbols, rename, and the index-backed navigation features all apply. The files analysed are exactly the files bynkc compiles: every tree in the manifest’s [paths] include, with exclude honoured, read through the compiler’s own discovery. A conventional project needs no [paths] at all — src/ and, when present, tests/ are picked up; a flat project (.bynk at the root) works as-is.
  • Single-file mode (no manifest) — each buffer is analysed on its own and the workspace features are unavailable; diagnostics still work per buffer.

Diagnostics are debounced at diagnostics_debounce_ms (the [lsp] key in bynk.toml, default 300 ms) — one generation-based debounce over both project and single-file mode. Every change bumps a generation, and a scheduled round runs only if it is still the latest when the delay elapses, so a burst of keystrokes coalesces into a single analysis. The analysis itself runs off the async runtime; a round already in flight when a newer edit arrives runs to completion but has its result discarded (never published) rather than being cancelled. There is a real, if narrow, window between analysing a round and publishing it — which is exactly why positions convert against the analysed snapshots rather than the live buffer.

The crate is split into focused modules:

ModuleRole
lib.rsThe server: Backend, the per-project state map, the LanguageServer impl, request dispatch, advertised capabilities, and run().
main.rsThin binary entry point — bynk_lsp::run().await.
position.rsByte-offset ↔ LSP position conversion.
symbols.rsSymbol lookups for hover and go-to-definition.
hover.rsHover’s resolution ladder — the order the lookups are tried in, as one pure function the handler and its tests share.
index_queries.rsPure queries over the project binding index: references, rename planning and validation, call hierarchy, semantic tokens, code lenses.
completion.rsContext detection and candidate generation for completion.
signature_help.rsCall-context detection and signature labels.
inlay_hints.rsInferred-type and ghost given hint rendering.
code_actions.rsQuick-fixes from diagnostics’ structured suggestions.
capability_fixes.rsCapability-aware header fixes: add consumes, and auto-uses/consumes for an unresolved name (from the binding index + a fresh reparse).
extract.rsExtract-variable: smallest covering expression node, insertion-point tracking through nested blocks, collision-avoiding placeholder naming. Extract-function: same selection, lifted to a new top-level fn with free-identifier parameters, capability-free-only.
locals_nav.rsScope-correct navigation for local bindings.
structure.rsFolding and selection ranges from the recovered AST.
document_symbols.rsThe document-symbol outline.
publish.rsThe pure publish plan (which files to publish, which to clear).
project.rsbynk.toml project configuration.
sequence_request.rsbynk/sequenceModel: the enclosing-handler-at-cursor lookup, the “Show Sequence” CodeLens site list, and the wire-shape conversion from bynk_ide::sequence::SequenceModel.
documentation_request.rsbynk/documentationModel: the whole-file model builder and the wire-shape conversion from bynk_ide::documentation::DocModel.
architecture_request.rsbynk/architectureModel: the project-wide model builder and the wire-shape conversion from bynk_ide::architecture::ArchModel, lowering each node/member/edge’s span against its own file (not just the request’s document).
wire_contract_request.rsbynk/wireContract: the enclosing-handler-at-cursor lookup and the wire-shape conversion from bynk_ide::wire_contract::WireContractModel (and, transitively, bynk_check::wire::WireModel), resolving each referenced boundary type’s own declaring file through the project index rather than assuming the request’s document.

The server logs to ~/.bynk-lsp.log; the verbosity is tunable via the BYNK_LSP_LOG environment variable (default warn).