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.
Diagnostics are not all errors. A test case whose body statically yields the
Skip verdict publishes a Warning (code test-skipped, source osprey)
spanning its test line — unless the skip names no reason, which publishes the
same code at Error severity ([TESTING-SKIP-REASON]). [TESTING-SKIP-WARNING-STATIC]
in 0027-TestingFramework.md is the authority on
which bodies qualify and what the messages read. Skip diagnostics ride alongside
whatever type errors the file already has, and are suppressed only when the
file does not parse, since an unparsable buffer reports its syntax error alone.
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 shown with every
unsolved slot spelled
_. 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: List<_>) -> intis correct andfn classify(xs: List<t5>) -> intis not. The hole keeps everything the checker did prove — here, thatxsis a list.
A type that is nothing but a hole carries no information, so it is omitted
rather than rendered: a wholly-unknown parameter stays bare, and a wholly
unknown return type drops the arrow entirely. The arrow used to fall back to
-> Unit, which is a positive claim rather than an absent one — and one the
checker refutes, since annotating
fn bothArms(f) = if f { Success { value: 1 } } else { Error { message: "e" } }
with -> Unit fails as cannot unify Unit with Result<t5, t6>. That
function now reports -> Result<int, _>.
Implemented by render_with_holes in
crates/osprey-types/src/ty.rs and
fill_inferred in
crates/osprey-lsp/src/analysis.rs,
over ProgramTypes::param_types / return_type. Hover and --symbols share
that one path, so an outline entry and a tooltip can never disagree.
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]). An operation carries
its OWN documentation ([DOC-EFFECT-OP]), which the hover appends; the owning
effect declaration's documentation is the fallback when the operation has none.
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.
[LSP-EFFECT-MULTIPLICITY] Multiplicity
(MULTI-AXIS) adds two reports,
both this query read in the opposite direction.
On a handler arm, textDocument/implementation returns every perform site the
arm can answer. For an arm of a many operation that set IS the replay set the
author is responsible for, so the server surfaces it before the arm is written
(MULTI-TRACE).
On a handler region whose arms are all tail-resumptive over operations declared
once, the server publishes an informational diagnostic:
handler for Log is tail-resumptive on every arm; declaring Log static would
remove it from the runtime
It is a hint and never a promotion:
STAGE-ROW-DISCHARGE
forbids implicit promotion in either direction, and the server MUST NOT offer a
code action that changes the stage of an effect used elsewhere in the project
without the workspace-wide check of [LSP-WORKSPACE]
(MULTI-STAGE).
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/diagnostics.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.