Specifications / Developer tooling
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.
Compiler and language rules are separate from integration tooling. See Editor integration and distribution for the extracted contract.
Architecture [LSP-ENGINE]
The server uses the lspkit crates from the pinned lspkit/ submodule, sharing its maintained file-watcher dependencies. 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.shutdownreturnsnullwithout waiting for document analysis; the followingexitnotification terminates the stdio loop and process. Analysis that completes after the session closes is discarded.- 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. Document updates and ordinary requests use one ordered analysis worker. Lifecycle and profiler control messages remain responsive on the transport while analysis runs; ordinary requests are processed sequentially.- The pending analysis queue holds at most 256 messages. Queued and running analysis requests retain at most 32 MiB of encoded request data. Exceeding either limit closes the session with an explicit backlog error instead of discarding edits.
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; effect operations resolve to their declaration, parameters and local bindings to the binding in scope, built-ins to their own use. [LSP-DEFINITION-EFFECT-OPERATIONS] [LSP-DEFINITION-LOCAL] [LSP-DEFINITION-BUILTIN] |
| Find implementations | textDocument/implementation |
An effect operation resolves to every matching handler arm, keyed by owning effect plus operation; an effect name resolves to every handler that discharges it. [LSP-IMPLEMENTATIONS-EFFECT-HANDLERS] |
| Find references | textDocument/references |
Whole-word scan; a parameter or local binding is scanned only inside its scope; includeDeclaration honored. [LSP-REFERENCES-LOCAL] [LSP-REFERENCES-EFFECT-OPERATIONS] |
| 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. |
| Quick fixes | textDocument/codeAction |
Proven annotation erasure; quickfix and source.fixAll.osprey. [LSP-CODE-ACTIONS-ANNOTATIONS] |
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.
One analysis per edit [LSP-PROJECT-BATCH]
Editing one file of a project republishes every open file of that project, and each of those answers needs the project assembled, type-checked and its warnings proved. That work is done once per edit and shared by the files answering it, rather than once per open file: on examples/projects/modules a single answer takes about six seconds, so repeating it per open buffer made the editor unusable with more than one file open.
Each language-server engine owns the latest analysis for each manifest root, including nested projects. Its inputs include the complete project configuration and each source's path, syntax flavor and text, compared exactly. Siblings answering the same edit share that result, and later requests with identical inputs may reuse it. Separate projects and server instances cannot evict each other's analysis; syntax is retained while any loaded project includes its file. Code actions use the same analysis as diagnostics; requesting a quick fix does not repeat an unchanged project proof.
A source or manifest change invalidates the analysis on the next request, even when the requesting buffer is unchanged. workspace/didChangeWatchedFiles republishes diagnostics for open documents. The VS Code client watches both Osprey source flavors and osprey.toml, so saving a manifest, creating a source, or deleting a source refreshes the project without another keystroke. Loading failures in a closed sibling or manifest produce an open-document project-error identifying the failed input; an unanalyzable project must not appear healthy.
A file's own diagnostics are then selected from that shared result by position: an error or warning is reported in the file its position lands in.
Module advice [LSP-MODULE-ADVICE]
Project style and state-boundary warnings follow MODULES-STYLE and MODULES-STATE-INVENTORY. They have Warning severity, source osprey, their compiler rule code and the owning declaration's physical source range. They have no Unnecessary tag or deletion action. Unsaved source and manifest changes invalidate the same project snapshot used by every other editor feature; incomplete or invalid projects suppress these warnings.
Module repair actions [LSP-CODE-ACTIONS-MODULES]
quickfix offers an explicit alias for an unaliased quoted namespace import, and replaces mistaken dot qualification such as Store.read with the module path Store::read. The alias is formed from the imported namespace label, capitalizing each alphanumeric component. Empty or digit-leading names receive an Imported prefix. An alias collision is not resolved by silently rebinding a name.
A repair is offered only for a currently erroneous buffer and only when that single edit makes the complete live project parse, assemble and type-check. Private members, invalid siblings, incomplete syntax, unresolved alias collisions and remaining errors prevent the action. Correct record field access receives no action. Selection ranges and action-kind filtering apply; edits use the negotiated position encoding and current document version. Client-supplied diagnostics never authorize an edit. These alternatives are excluded from annotation source.fixAll.osprey because selecting a module or alias is an author decision.
Annotation quick fixes [LSP-CODE-ACTIONS-ANNOTATIONS]
textDocument/codeAction uses the same current-program redundancy proof as diagnostics. The request range selects overlapping annotations for quickfix; a cursor selects an annotation when it lies inside or at either boundary of its highlight. context.only follows hierarchical action-kind matching. source.fixAll.osprey removes the jointly removable set in the current document. A caller-supplied diagnostic does not authorize an edit: the server recomputes the findings from current source. Parse, type or project errors prevent speculative deletion actions. Opening, editing or closing a project file refreshes diagnostics in its other open source files, so a signature warning disappears when a live helper makes the signature necessary and returns when inference again proves it redundant.
Each action carries its exact diagnostic, a preferred-action marker and a versioned TextDocumentEdit. Its source ranges come from the parser, in the negotiated UTF-16 encoding, including multiline signatures. Edits preserve comments, definition bodies and public visibility. The diagnostic highlights the annotation; the edit can additionally consume the whitespace-only header line. Inline fixes retain parentheses, commas, function names and parameter names. The full erasure contract is TYPE-ANNOTATION-REDUNDANT.
The VS Code language client converts protocol edits to a WorkspaceEdit without retaining its version. The Osprey extension therefore guards the final apply command with the captured document version and source, together with the set, versions and sources of all open Osprey documents. If the target or an open sibling has changed, or that set has changed, the command returns without editing and asks the reader to request the action again. Before applying, the extension also requests a fresh compiler proof and requires the exact original edits and document version to remain available. This catches changes to closed project sources and the manifest. It checks the open-document snapshots again after that request; a failed request or any mismatch leaves the source untouched. Applying a valid action is one editor edit: it supports save, undo and redo, and normal document synchronization republishes diagnostics for the resulting source.
Unused-symbol diagnostics use the four rules in TYPE-WARNINGS-UNUSED. They have Warning severity, source osprey, an identifier range and the LSP Unnecessary tag. They have no removal action.
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).
For a function with a written effect row, hover, signature help, completion
detail and --symbols preserve that row after type inference fills unwritten
parameter and return types. declaredEffectRow in symbol JSON is the written
upper bound (![], !e, or a fixed/open row), not an assertion that every
permitted operation actually occurs. Inferred requirements are presented separately under [LSP-EFFECT-REQUIREMENTS].
Inferred effect requirements [LSP-EFFECT-REQUIREMENTS]
Function hover, signature-help documentation, completion documentation, symbol JSON and generated API pages share the checker's final closed-program operation summary. The summary describes full application, including curried parameters. It follows transitive calls, callback provenance and arithmetic; a handler removes only the operations and generic instances it covers. Operations performed by a recovery arm remain requirements of the enclosing policy. A written row remains a separate upper bound and is never copied into the inferred requirements.
effectRequirements in symbol JSON contains sorted operations, sorted runtimeBuiltins, unresolvedCallbacks and their shared Markdown description. Generic operation names retain concrete arguments (Read<int>.get); unresolved argument types use _, never private inference IDs. Host runtime builtins are listed separately. An unresolved callback or dynamic callable must produce an explicit uncertainty statement, even when the known operation list is empty. Absence of a report, including on externs, is not a purity guarantee. These summaries do not implement independently quantified rows in function types.
The views must update when an unsaved source edit changes a helper's requirements. Both-flavor unit and LSP transport tests verify transitive requirements, discharge and removal of stale descriptions; checker tests additionally cover recursive calls, exact generic discharge, forwarding, arithmetic and unknown callbacks.
Symbol resolution
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, scoped handler blocks,match/selectarms, lambdas,spawn/await, interpolations, call arguments, list/map/object literals — so aletnested anywhere (e.g. inside a handler arm's 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.[LSP-HOVER-PARAM-DOCS]The parameter's entry in its function's# Parameterssection (DOC-SECTIONS) follows the type, so a documentedworkhovers with its description. - 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].
Effect operations [LSP-DEFINITION-EFFECT-OPERATIONS]
An effect operation resolves to its declaration from every site that names it: a handler arm (zipWith a b f => … in a handle or handler, or a def in a Python-flavor @handler class), a perform, or the declaration itself. Identity is the owning effect plus the operation, as for [LSP-IMPLEMENTATIONS-EFFECT-HANDLERS], and the open buffer is searched before the project siblings. A file that several manifests share answers once.
Parameters and local bindings [LSP-DEFINITION-LOCAL]
A function parameter, a local binding in a function body and a handler-arm parameter resolve to the nearest binding written at or before the cursor inside the enclosing function, before any top-level declaration is considered. work in fn onGpu(work) and work in fn onLists(work) are therefore two names. The same rule holds in every flavor.
[LSP-REFERENCES-LOCAL] Find-references on such a binding scans only its scope, from the binding to the next top-level declaration, and never crosses into sibling files. [LSP-REFERENCES-EFFECT-OPERATIONS] Find-references on an effect operation lists its declaration, every handler arm and every perform across the project, each once.
Find implementations [LSP-IMPLEMENTATIONS-EFFECT-HANDLERS]
textDocument/implementation on any effect-operation site — its declaration in an effect or a signature, or a perform — 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.
On an effect's name — at its declaration, in a handle or handler head, or as the qualifier of a perform — the query returns the effect-name range of every handle, handler and kernel region that discharges it. A builtin effect such as Arith has no declaration in the project; a discharge of it is enough to name it.
Identity is the declaration, not the instantiation: handle static Tensor<List<float>> and handle static Tensor<GpuBuffer<float>> both implement Tensor. A qualified mention (Backends::Tensor) matches its declaration by path suffix. The unsaved open buffer is searched first, followed by project siblings through [LSP-WORKSPACE], each file once even when several manifests share its source root; 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 — project override > 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.
Two limits are deliberate and current. Signature help on a curried call labels
the whole signature; it does not render the partially applied remainder. And
completion has no handler-arm or perform-operand context: those positions get
the declaration list of [LSP-COMPLETION-CONTEXT].
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 whose source roots list the file, else every
neighbouring project whose source roots reach it — hover, go-to-definition,
find-implementations, find-references, completion, and signature help resolve
against every source file linked by the manifest. A source root two projects
share (source_roots = ["../model", "."] in each) has no manifest of its
own, so a file in it belongs to all of them: the server finds those projects
among the child directories of the file's ancestors. Editor features answer
from the union of their sources, so a reference search from the shared file
reaches every program that includes it. Diagnostics assemble the file with
the nearest of those projects alone, listing each file once under its
lexically normalised path (generate/../model/m.osp is model/m.osp):
assembling the projects together would declare the shared file twice and
give the program two entries.
The server uses the compiler's osprey_project::discover, source-root membership and flavor-aware parse_text contracts. An engine-owned syntax cache keys each file by its path, configured flavor and exact text, including failed parses. The manifest's flavor override applies to both the active file and every sibling, including formatting and warning fixes. Unchanged sources reuse their syntax across diagnostics, navigation and code actions. The checked project cache described in [LSP-PROJECT-BATCH] reuses assembly and inference until any input changes. URI/path conversion lives in workspace.rs; source snapshots live in project_sources.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. Every open sibling's unsaved text is also authoritative, including a newly created buffer not yet saved inside a configured source root. Closing a buffer restores its disk source, or removes it if it was never saved. If outer and nested projects include the same source, opening, editing or closing it refreshes every affected open project.
- Incomplete sibling syntax may fall back to its saved source to preserve known errors while editing. Such a snapshot cannot justify warning fixes; warnings and code actions remain suppressed until the live project parses. A valid unsaved repair takes precedence over invalid saved text.
- Files outside configured source roots, hidden directories and
targetdirectories do not join the project merely because they are open. - 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.
The transport tests project_features_follow_unsaved_siblings_in_both_flavors, live_effect_declarations_and_handler_locations_follow_both_flavors, watched_sources_and_manifest_refresh_the_live_project, unsaved_mixed_flavor_files_join_and_leave_the_project, unsaved_repair_overrides_invalid_disk_and_actions_reuse_the_analysis, watched_invalid_project_inputs_report_their_source, and nested_project_edits_refresh_every_project_that_includes_the_source pin these contracts. nested_projects_do_not_share_the_first_sources_cache_slot pins syntax and analysis reuse across overlapping roots. Existing incomplete-buffer and annotation-fix tests remain the safety oracle.
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.