Language Server & Editor Integrations
The Rust LSP server and editor clients resolve document flavor with
osprey_syntax::resolve_flavor; both parsers lower to
osprey_ast::Program before analysis.
Architecture [LSP-ENGINE]
The server uses the published lspkit
crates. One EngineApi implementation owns the open-document state and supplies
the analysis queries consumed by the stdio LSP server.
flowchart LR
vscode[VS Code]
vscode -->|LSP over stdio<br/>JSON-RPC| lsp
subgraph server["crates/osprey-lsp"]
lsp["osprey lsp"]
engine["OspreyEngine<br/>lspkit::EngineApi"]
vfs["lspkit-vfs<br/>rope documents"]
live["lspkit-live<br/>Session / generation"]
syntax["osprey_syntax::parse_program"]
types["osprey_types::check_program"]
lsp --> engine
engine --> vfs
engine --> live
engine --> syntax
engine --> types
endThe server does not shell out to osprey or scrape stderr. It calls the
compiler front-end directly
(crates/osprey-lsp/src/diagnostics.rs),
so diagnostics, hover, and navigation use the compiler parser and type checker.
Consumed crates:
| Crate | Used for |
|---|---|
lspkit |
EngineApi trait + neutral types. |
lspkit-server |
JSON-RPC framing, Dispatcher, and DiagnosticsBus/DiagnosticsSink. |
lspkit-vfs |
Open-document store, rope incremental edits, position measurement. |
lspkit-live |
Session generation counter + broadcast. |
Shared lspkit services [LSP-REUSE-LSPKIT]
Editor-neutral functionality MUST NOT be re-implemented in osprey-lsp; it
comes from lspkit-*. The remaining word-at-position, occurrence, and position
measurement helpers are isolated in
crates/osprey-lsp/src/text.rs.
Transport [LSP-TRANSPORT]
There is one server entry point for every editor:
osprey lsp
It speaks LSP over stdio with Content-Length framing; there is no socket,
port, or per-editor binary. The subcommand is
implemented in crates/osprey-cli/src/main.rs
(delegating to osprey_lsp::run_stdio).
Lifecycle [LSP-LIFECYCLE]
Standard LSP handshake and document sync:
initialize→ advertise capabilities ([LSP-CAPABILITIES]);initialized.shutdownreturnsnull; the followingexitnotification terminates the stdio loop.- Document sync (incremental,
textDocumentSync: 2):didOpen,didChange,didClose. AdidChangeapplies either a full replacement or a set of incremental edits — never an open+change at the same version (which silently drops edits). Dropped edits are surfaced, not swallowed. $/cancelRequestis accepted. Requests are served sequentially; request-level concurrency is not supported.
Capabilities [LSP-CAPABILITIES]
The server exposes:
| Capability | Method | Notes |
|---|---|---|
| Diagnostics | textDocument/publishDiagnostics |
Push, via DiagnosticsBus. [LSP-DIAGNOSTICS] |
| Hover | textDocument/hover |
Markdown. Functions/builtins → signature; effect operations → qualified operation type plus owning-effect docs; let/mut bindings (local and top-level) → their declared or inferred type; reserved keywords (match, handle, in, …) → a one-line meaning; declaration docs rendered as prose. [LSP-HOVER] |
| Go to definition | textDocument/definition |
AST-driven, anchored on the identifier; built-ins resolve to their own use. [LSP-DEFINITION-BUILTIN] |
| Find implementations | textDocument/implementation |
An effect operation resolves to every matching handler arm, keyed by owning effect plus operation. [LSP-IMPLEMENTATIONS-EFFECT-HANDLERS] |
| Find references | textDocument/references |
Whole-word scan; includeDeclaration honored. |
| Document symbols | textDocument/documentSymbol |
Flat DocumentSymbols; range on the name, not the fn/let/type keyword. |
| Signature help | textDocument/signatureHelp |
Active-parameter tracking; ignores ,/(/) inside strings and // comments. Triggers on the callee name as well as inside its parentheses. [LSP-WORKSPACE] |
| Completion | textDocument/completion |
Position-filtered keywords/snippets + project declarations. [LSP-COMPLETION-CONTEXT], [LSP-WORKSPACE] |
| Formatting | textDocument/formatting |
Returns one whole-document edit when formatting changes the buffer, otherwise no edits. |
Diagnostics [LSP-DIAGNOSTICS]
Opening or changing a document publishes compiler diagnostics for that buffer.
Flavor conflicts are reported alone as flavor-error; otherwise syntax errors
short-circuit type checking. A syntax-error-free parse is assembled and type-checked through
osprey_project when the file belongs to a project, while standalone files use
the same single-source assembly path as the CLI. Closing the document removes it
from the live VFS.
Hover [LSP-HOVER]
textDocument/hover locates the word through [LSP-REUSE-LSPKIT], walks the
AST for its declaration, and returns Markdown: a fenced signature or
name: type, followed by documentation when present. Implemented in
crates/osprey-lsp/src/hover.rs
over analysis.rs
(collect_all_symbols).
Resolution order for the symbol under the cursor:
- An effect-operation declaration, qualified
perform, or handler arm →[LSP-HOVER-EFFECT-OPERATIONS]. - A declaration in the open document — including user functions and
let/mutbindings — with nearest-binding shadowing ([LSP-HOVER-VARIABLES]). - A built-in (
print,map, …) → its reference signature. - A written name that declares nothing — a parameter or built-in type
name →
[LSP-HOVER-WRITTEN]. - A symbol declared in a sibling file of the project →
[LSP-WORKSPACE]. - A reserved keyword (
match,handle,in, …) →[LSP-HOVER-KEYWORD]. Checked last, since a keyword can never be any of the above.
Variable hover [LSP-HOVER-VARIABLES]
Every binding is hoverable:
- Collection is deep.
collect_all_symbolswalks into every expression that can contain a block — function bodies,handle … in …,match/selectarms, lambdas,spawn/await, interpolations, call arguments, list/map/object literals — so aletnested anywhere (e.g. inside an HTTP handler'sin { … }block) is found. A cursor-line/“nearest binding at or before the cursor” rule resolves shadowing. - Type comes from inference when unannotated. An annotated
let x: T = …showsx: T. An unannotatedlet x = f()shows the inferred type: the checker publishes everylet's resolved type keyed by source position (ProgramTypes.lets, queried vialet_type), the same position-keyed mechanism used for lambda parameters. The binding position is anchored on the declaration'slet/mutkeyword so a leading doc comment never shifts it. Implemented acrossosprey-types(let_tys) andosprey-types/src/info.rs.
Inferred signatures [LSP-HOVER-INFERRED-SIGNATURE]
Hovering a function declaration shows the signature with every slot the
author left blank filled in by the checker. Osprey is Hindley-Milner and the
house style omits every inferable annotation, so blank slots are the common
case: rendering them literally showed fn fib(n) -> Unit, where the parameter
carried no type and the return type was flatly wrong (Unit was the display
fallback, never a claim about the function). Hover is the main way a reader
recovers the types the source deliberately omits, so it answers from inference.
One exception, in both directions:
- A slot the author did write is shown as written — hover never restates a declared type in the checker's spelling.
- An inferred type that still holds a type variable is not shown at all;
the slot stays bare. A variable name (
t5) is an inference artefact: it means nothing outside the run that produced it and it shifts when an unrelated line is edited, sofn classify(xs) -> intis correct andfn classify(xs: List<t5>) -> intis not.
Implemented by inferred_signature in
crates/osprey-lsp/src/hover.rs, over
ProgramTypes::param_types / return_type and osprey_types::has_type_var.
Written names [LSP-HOVER-WRITTEN]
Parameters and built-in type names are hoverable even though they are not declarations:
- A parameter, inside its own function's body. Its type is its annotation
when it has one and otherwise the type the checker resolved for that argument
position (
ProgramTypes::param_types), sofn twice(n) = n * 2hoversnasn: int. Scope is the enclosing declaration: the nearest function declared at or above the cursor. A parameter must not answer for a name in a later declaration. - A built-in type name in an annotation (
int,string,Result, …). No source file declares these, so there is nothing to navigate to; hover carries a one-line summary instead. A declared type resolves to its declaration and never reaches this table.
Keywords [LSP-HOVER-KEYWORD]
Every reserved keyword hovers to a one-line meaning — match, handle, in,
fn, let, effect, perform, resume, spawn, and the rest. A keyword is
reserved, so it can never be a declared symbol, a built-in, a parameter or a
written type; it reaches hover as an ordinary word that no declaration-driven
path can answer, and previously returned nothing — even though the highlighter
colours a keyword exactly like the built-in types that do hover, so an author
expects the same. The fixed reference table (shared with the built-in type
summaries in
osprey-lsp/src/reference_docs.rs)
is flavor-blind — the reserved set is common to both surfaces even where a
Default keyword such as fn has no ML spelling — and the summary is fenced in
the document's flavor like every other hover.
Documentation comments [LSP-HOVER-DOCS]
Every declaration form can be documented — fn, let/mut, type, effect,
extern, and module — in both flavors (/// in Default, (** … *) in
ML). The doc comment is lowered into the structured
DocComment on the AST node's doc field, and
hover renders it as Markdown (summary, body, then recognised sections) beneath
the signature/type line. See Documentation Comments
for the full model, sigils, sections, and body markup.
Doc-link hover [DOC-LINK]. A [Symbol] intra-doc link inside a doc
comment is itself hoverable: putting the cursor on [helper] or
[Console.emit] shows the referenced declaration's own hover. Rendering lives
in osprey-lsp/src/hover.rs
(doc_link_target / resolve_link); doc capture lives in
osprey-syntax/src/docparse.rs
and each flavor's lowerer.
Effect operations [LSP-HOVER-EFFECT-OPERATIONS]
The operation name in an effect declaration, qualified perform Effect.op, or
matching handler arm hovers to the operation's qualified type. The presentation
uses the active authoring flavor: Audit.step: fn(string) -> int in Default and
Audit.step : string => int in ML ([FLAVOR-ML-EFFECT]). Operations do not
carry independent documentation ([DOC-ATTACH]), so the hover appends the
owning effect declaration's documentation.
Go to definition [LSP-DEFINITION-BUILTIN]
textDocument/definition resolves the identifier under the cursor to its
declaration: first in the open buffer, then across the project's sibling files
([LSP-WORKSPACE]). A built-in (listAppend, print, map, …) declares
nothing in any .osp file, so neither scan finds it. Rather than return an
empty result — which editors surface as "No definition found" over a function
that hovers perfectly well — the built-in resolves to the identifier the cursor
sits on, a graceful self-definition. Implemented in
osprey-lsp/src/features.rs
(builtin_definition), reusing the same built-in table as [LSP-HOVER].
Find implementations [LSP-IMPLEMENTATIONS-EFFECT-HANDLERS]
textDocument/implementation on any effect-operation site returns the
operation-name range of every handler arm that implements it. Identity includes
both the owning effect and operation, so Trace.mark never returns a handler
for Other.mark. The unsaved open buffer is searched first, followed by project
siblings through [LSP-WORKSPACE]; a standalone file searches only itself.
Answering in the authoring flavor [LSP-FLAVOR-RENDER]
Both source surfaces lower to a flavor-blind osprey_ast::Program
([FLAVOR-BOUNDARY]). LSP responses render symbols in the document's resolved
flavor.
Every document-scoped feature resolves its flavor with the one
[FLAVOR-SELECT] precedence chain — marker > extension > Default — the same
chain the CLI uses. There is exactly one resolver
(osprey_syntax::resolve_flavor); a feature that sniffs the extension itself is
a defect, because a // osprey: flavor=ml marker must outrank it.
Normative requirements:
- Hover renders its code block in the document's flavor: the fence language
is
ospreyorosprey-ml(each is a distinct VS Code language with its own TextMate grammar), and the signature is respelled —fn inc(x: int) -> intin Default isinc : int -> intin ML ([FLAVOR-ML-FN], curried and right-associated; parameter names belong to the clause head, not the signature line). Declaration binders juxtapose:type Box<T>istype Box T([FLAVOR-ML-GENERICS]). - Signature help labels the call in the same spelling.
- Completion offers only keywords the flavor actually has, with snippets
that flavor accepts. ML has no
fn,let, orif— a definition is a bare clause, a binding needs no keyword, and a condition is amatchontrue/false— so completing them would insert plain identifiers and a guaranteed parse error. Brace-form snippets are equally invalid under the layout parser. - A marker/extension conflict is a diagnostic, not a guess.
[FLAVOR-SELECT]makes the disagreement a hard error and the CLI refuses to build the file; the editor reports it (codeflavor-error, anchored on the marker line) as the document's only finding. Parsing under a guessed flavor would produce unrelated syntax errors.
Rendering lives in
osprey-lsp/src/mlrender.rs and is
applied at the feature boundary.
Position-filtered completion [LSP-COMPLETION-CONTEXT]
Completion classifies the cursor before answering
(osprey-lsp/src/context.rs) and
offers only what is legal there:
| Cursor | Offered |
|---|---|
| Declaration/statement | Every keyword of the flavor, plus every visible symbol. |
Value (after =, (, ,, an operator) |
Expression keywords of the active flavor plus every visible symbol: Default offers if/match; ML offers match/handle. Declaration forms are withheld. |
Written type (after : or ->) |
Declared types and effects, plus the built-in type names. No keywords, no bindings, no functions. |
receiver. |
Only that record's fields — [LSP-COMPLETION-MEMBER]. |
match arm pattern |
Constructors and _. |
| A declaration's parameter name | Nothing. |
Classification is lexical, not semantic: it reads the scrubbed prefix
before the cursor, because a buffer being edited is usually not parsable. String
literals and comments are blanked first, so a : inside a string is not an
ascription and a . inside a comment is not a field access. Indentation carries
match-arm nesting, since Default's braces are optional and ML has none.
Unrecognised contexts fall back to declaration completions; binder and unresolved
member contexts deliberately return no suggestions.
Member completion [LSP-COMPLETION-MEMBER]
After receiver., the list is exactly the fields of the record the receiver
holds — its annotation when written, else its inferred type, read
structurally from the checker (a record renders as { x: int, y: int },
which names no type, so a rendered string cannot be parsed back into a lookup
key). An unresolved receiver yields no suggestions; falling back to the whole
symbol table is forbidden.
Project-wide analysis [LSP-WORKSPACE]
When the open document belongs to a project — the nearest ancestor directory
holding an osprey.toml — hover, go-to-definition, find-implementations,
find-references, completion, and signature help resolve against every source
file linked by the manifest.
Sibling files are loaded through osprey_project::load, the same loader used by
the CLI and [LSP-DIAGNOSTICS]. URI/path resolution and project discovery are
implemented in
osprey-lsp/src/workspace.rs.
Normative requirements:
- The open buffer is searched first. A local declaration shadows an imported one, and the open buffer's unsaved text is authoritative for itself.
- Without
osprey.toml, only the open document is analyzed. - Find-references reaches the declaration wherever it lives. A declaring
file spells the name unqualified (
openSql) while its callers write the qualified path (Ledger::openSql), so a whole-word scan does not find the declaration; the sibling scan adds declaration sites by symbol identity.
Position encoding [LSP-ENCODING]
The server advertises and uses UTF-16 positionEncoding. Tree-sitter
reports columns as byte offsets, so every position crossing the wire is
re-measured into UTF-16 units
(crates/osprey-lsp/src/text.rs,
byte_col_to_encoding). The internal helpers remain encoding-parameterized so
conversion behavior can be unit-tested independently of the fixed wire choice.
Editor integrations
The VS Code integration is a thin client over [LSP-TRANSPORT].
VS Code [EDITOR-VSCODE]
- Extension id
nimblesite.osprey; client invscode-extension/client/src/extension.tsspawnsosprey lspover stdio. - Packaged as a per-platform VSIX (
darwin-arm64,linux-x64,win32-x64). Each VSIX bundles a version-matchedospreybinary + runtime libs + a stampedshipwright.json, verified present inside the package at build time. - Client resolves the server command in priority order: user setting
(
osprey.server.compilerPath) → bundled binary →PATH(per the Shipwrightsourceslist inshipwright.json). - The extension's native DAP integration is specified in Debugger; it is separate from the LSP request path.
- Marketplace publication uses OIDC (no PAT) — see
[EDITOR-VERSIONING]and the release workflow. Open VSX publication uses the same VSIX artifacts and an independent optional-token job, so either registry can succeed alone.
Versioning & supply chain [EDITOR-VERSIONING]
The VS Code distributions obey the Shipwright version contract: the extension and bundled binary MUST be version-matched.
- The binary is the source of truth:
osprey --version→osprey X.Y.Z;osprey --version --json→ the version manifest ([SWR-VERSION-CLI-OUTPUT]). - Components are declared in
shipwright.json:osprey(the CLI — which is the language server, via thelspsubcommand) andosprey-vscode. The component id must equal the name the binary reports fromosprey --version(Shipwright matches the probed name against the component id), so the CLI component isosprey, notosprey-compiler. The LSP is not a separate component; it is the same binary, so no separate version surface exists to drift. - Source version fields stay at
0.0.0-dev; the release version is stamped from the git tag at build time ([SWR-VERSION-BUILD-STAMPING]). Hard-coding a version is a defect. - VS Code activation verifies the bundled compiler against the manifest and
prompts to reinstall on mismatch (
hosts.vscode.onMismatch). PATH/registry sources are verified at startup (verifyStartup). - Marketplace publishing uses GitHub OIDC and Microsoft Entra workload-identity federation, with no stored PAT.