LSP (mty-lsp)¶
Live editor integration for Mighty. Implements LSP 3.17 over stdio
via tower-lsp 0.20.
This is the internals doc. The user-facing CLI reference lives at
docs/reference/cli/mty-lsp.md.
Crate layout¶
crates/mty-lsp/
├── Cargo.toml
├── src/
│ ├── lib.rs # module wiring, re-exports tower_lsp::lsp_types
│ ├── line_index.rs # UTF-16-aware (line, char) ↔ byte offset
│ ├── conv.rs # span/severity/diagnostic → lsp-types
│ ├── docs.rs # DocAnalysis cache + apply_change
│ ├── diagnostics.rs # build PublishDiagnosticsParams
│ ├── hover.rs # textDocument/hover
│ ├── definition.rs # textDocument/definition
│ ├── document_symbols.rs # textDocument/documentSymbol
│ ├── completion.rs # textDocument/completion (v0.5: locals + receiver)
│ ├── semantic_tokens.rs # textDocument/semanticTokens/full + /range
│ ├── rename.rs # textDocument/rename + prepareRename
│ ├── references.rs # shared find-references helper
│ ├── inlay_hints.rs # textDocument/inlayHint
│ ├── code_actions.rs # textDocument/codeAction
│ ├── signature_help.rs # textDocument/signatureHelp
│ └── server.rs # Backend impl LanguageServer + run_stdio()
└── tests/
├── integration.rs # original v0.2 feature smoke tests
├── semantic_tokens.rs # legend + token classification
├── rename_local.rs # local + prepareRename + invalid-ident
├── rename_top_level.rs # top-level fn / struct rewrite
├── inlay_hints.rs # inferred-type vs annotated
├── code_action_unresolved.rs # MT2021 quickfix suggestions
├── signature_help.rs # call-site active-param tracking
├── workspace_folders.rs # multi-doc independence
└── completion_semantic.rs # locals + receiver completion
Pipeline¶
Every didOpen / didChange re-runs the full compiler pipeline:
source: String
│
▼
parse_source() ──► ParsedFile { source, green, parse_errors }
│
▼
lower() ──► (Package, lower_diags)
│
▼ (if no lowering errors)
check_package_typed() ──► TypedPackage { def_map, ty_arena,
expr_ty, fn_params, fn_ret,
diagnostics }
│
▼
DocAnalysis { source, version, line_index, parsed, package, typed,
diagnostics }
The cached DocAnalysis is shared between feature handlers via
Arc<DocAnalysis> (so a slow hover call doesn't lock the next
didChange).
Borrow check¶
The v0.2/v0.5 LSP intentionally skips the borrow check. Two reasons:
- Latency. The borrow checker walks HIR linearly per fn body; it roughly doubles per-change analysis time on typical files. For an editor that runs the pipeline on every keystroke, that's enough to feel sluggish.
- Dep weight.
mty-borrowpulls in additional state; the LSP crate keeps its dep set lean (only what the in-scope features need).
Borrow-check diagnostics still surface via mty check. A future
amendment will incrementalize the borrow checker so the LSP can include
its output without the latency hit.
UTF-16 line index¶
The compiler pipeline uses UTF-8 byte offsets for every span. LSP
positions are UTF-16 code units. The conversion lives in
line_index.rs:
LineIndex::new(source)walks the source once, recording each line's starting byte offset.offset_to_position(source, byte) → (line, char)binary-searches the line table, then sumsc.len_utf16()over the chars from line-start tobyteto produce the column.position_to_offset(source, line, char) → byteis the inverse: walk chars from line-start, accumulating UTF-16 code units until we hitchar, then return the byte index.
Tests cover ASCII round-trip, multibyte UTF-8 (café), surrogate-pair
emoji (U+1F600), and out-of-range clamping.
DocStore¶
DocStore is a DashMap<Url, Arc<DocAnalysis>>. Three operations:
open(uri, source, version)— runs the pipeline and inserts.update(uri, source, version)— replaces the existing entry with a freshly-analyzed one. OldArc<DocAnalysis>values may still be held by in-flight feature requests; they drop when those finish.close(uri)— removes the entry.
Incremental edits are applied before re-analysis, by
docs::apply_change. We don't currently incrementalize the parser
itself (it's fast enough for editor-sized files) — we just re-parse the
new full source after applying the patches.
Hover¶
hover::hover(doc, position) finds the rowan token at the byte offset
and:
- If the token is an
IDENTand the name resolves in the DefMap, renders a one-line signature (fn signature, struct/enum decl, variant, module, or type param). - Otherwise renders the token's literal text in a code fence.
Every hover also includes the parent CST node kind for debuggability.
Definition¶
definition::definition(uri, doc, position) finds the identifier at the
cursor and looks it up in the top-level item list. Returns the item's
SourceSpan translated to an LSP Range.
v0.2/v0.5 limitation: top-level only. The HIR resolve pass produces a name → DefRef map, but the per-expression resolution side-tables aren't exposed in a form the LSP can consume. A future amendment will surface them.
Document symbols¶
document_symbols::document_symbols(doc) walks the CST root's top-level
declaration nodes and returns DocumentSymbolResponse::Nested.
Top-level symbols include functions, constants, structs, enums, type
aliases, agents, protocols, traits, and impl blocks. Structured children
are emitted for fields, enum variants, protocol messages, trait methods,
agent state/handlers, and impl methods. Ranges come from the CST node,
while selectionRange points at the declaration name token.
The implementation is CST-backed rather than HIR-backed so outline panels keep working while a user is editing through temporarily invalid code.
Completion (v0.5)¶
completion::complete(doc, position) returns the union of:
- The full Mighty keyword set (57 entries) tagged
CompletionItemKind::KEYWORD. - Every name in
DefMap::by_nametagged with the appropriate kind (Function / Struct / EnumMember / Module / TypeParameter). - If the character immediately preceding the cursor is
.: - Receiver-aware suggestions: the receiver name is looked up in
the smallest enclosing fn body for a
let-binding or parameter whose inferred type is an ADT; we then list everyimpl_methodsentry, every trait-provided method registered inDefMap::traits.by_method, and every public field of that ADT. - Built-in method fallback: the entire
DefMap::builtin_methodstable is always added so editors still surface common methods when the receiver is unresolved. - Locals in scope: walks
LET_STMTnodes in the smallest enclosingBLOCK/ON_HANDLERwhosetext_range().end() <= cursorplus theFN_PARAMsiblings of the enclosingFN_DECL. We deliberately operate off the CST so the suggestions survive partial parses.
Deduplication: items are keyed by (kind, label) so a local named the
same as a top-level def doesn't appear twice.
Semantic tokens (v0.5)¶
semantic_tokens::{full, range} walks the CST and emits LSP semantic
tokens. The legend is declared statically:
types: keyword type function variable parameter string number
comment operator namespace enumMember typeParameter
macro property
modifiers: declaration readonly defaultLibrary
Classification rules:
- Keyword tokens (
is_keyword()onSyntaxKind) →keyword. - Numeric / string / comment literals →
number/string/comment. - Punctuation operators (
+,==,->, …) →operator. IDENTtokens: looked up first againstDefMap::by_name(yieldingfunction/type/enumMember/namespace/typeParameter); if not present, the built-in primitive type names (I32,String,Option, …) classify astypewith thedefaultLibrarymodifier; an!next sibling promotes tomacro; IDENTs underFN_PARAMgetparameter; everything else falls back tovariable.- The
declarationmodifier fires when the IDENT is theNAMEchild of a declaration node (FN_DECL,STRUCT_DECL, …,STRUCT_FIELD,ENUM_VARIANT).
Tokens are sorted by (line, start_char) and delta-encoded as required
by the LSP wire format (SemanticToken { delta_line, delta_start,
length, token_type, token_modifiers_bitset }). Multi-line tokens emit
their byte length as a conservative fallback for length; clients
still highlight them correctly because the deltas line up.
semantic_tokens/range returns the same encoding filtered to tokens
whose start position falls within the requested viewport — simpler than
maintaining a per-viewport cache and plenty fast for editor-sized files.
Rename (v0.5)¶
rename::prepare(doc, pos) returns a
PrepareRenameResponse::Range for the IDENT under the cursor, or
None to make the editor reject the rename cleanly when the cursor
isn't on an identifier.
rename::rename(uri, doc, pos, new_name):
- Validates
new_nameviareferences::is_valid_ident— must match[A-Za-z_][A-Za-z0-9_]*and not be a reserved keyword. - Classifies: if the target name resolves in
DefMap::by_name(and isn't a genericParam), treat as top-level; otherwise local. - Top-level: walk the file, collect every IDENT whose text equals the target name.
- Local: restrict the walk to the smallest enclosing
BLOCKorON_HANDLERcontaining the declaration offset. - Emit a
WorkspaceEdit { changes: {uri → [TextEdit; N]} }(nodocumentChanges— slice-1 single-file model).
Limitations: single-file only; shadowing inside the same block is renamed together (the user gets to confirm in the editor preview).
Inlay hints (v0.5)¶
inlay_hints::inlay_hints(doc, viewport) emits : T annotations:
- For each top-level
HirFnwith a body, pair itsHirStmt::Letstatements (declaration order) with theLET_STMTchildren of the fn's CSTBLOCK. When the HIR stmt hasty: Noneandinit: Some(eid)and the CST stmt has no type-node child, look upexpr_ty[eid]and render viapretty_ty. The hint is positioned at the end of the pattern's CST range. - For each
FN_PARAMwithout a type annotation, look up the fn's inferred parameter type viaTypedPackage::fn_params[fid]and emit a: Thint after the binding IDENT. - Hints whose position falls outside
viewportare filtered. - Uninteresting types (
Error, unsolvedVar, nakedParam) are suppressed to avoid noise on broken programs.
Deferred to a follow-up: closure parameter hints (CST LAMBDA_EXPR
isn't yet paired with HIR's HirExpr::Lambda), argument-name hints
(would need per-call-site overload resolution).
Code actions (v0.5)¶
code_actions::code_actions(uri, doc, range, diagnostics) matches the
diagnostic codes the editor passes along with the request:
| code | fix |
|---|---|
| MT2021 unresolved value | suggest top-3 in-scope names by edit distance ≤ 2 |
| MT2002 unresolved type | suggest top-3 in-scope type names by edit distance |
| MT3001 use-after-move | suggest inserting .clone() after the moved value |
| MT4001 effect undeclared | suggest adding effect { name } to the fn signature |
When the client sends an empty diagnostics list (some editors do
this when first painting the lightbulb), we re-scan our own cached
diagnostic list for entries whose primary span overlaps range and
generate fixes from those. Distance is Levenshtein (O(m·n) DP),
threshold 2.
Each action returns a single-edit WorkspaceEdit with
is_preferred: Some(true) so editors can auto-apply on the first
auto fix keystroke.
Envelope-driven fixes (v0.34 T2)¶
Every Mighty diagnostic also carries a
mty_diagnostics::fix::DiagnosticEnvelope with one or more
FixAlternatives. emit_envelope_actions parses the embedded
unified diffs into LSP TextEdits (see diff_apply.rs) and surfaces
each surviving alternative as a CodeAction. Confidence ≥
preferred_threshold (0.85) flips is_preferred = true; ≥
visible_threshold (0.7) is still shown but not preferred; below
0.7 is hidden from the lightbulb (the CLI's mty fix --apply can
still take them with --threshold 0.5).
Bulk apply — source.fixAll.mighty (v0.35 T3)¶
Editors that bind to the standard source.fixAll.<dialect> source
action get a single Mighty bulk-apply via
context.only = ["source.fixAll.mighty"]. The server's
code_action handler routes the request to
fix_all_mighty_action(uri, doc, cfg) which:
- Iterates the document's cached diagnostics.
- For each one with a
confidence ≥ preferred_thresholdalternative, converts the highest-confidence applicable diff to aTextEdit. - Sorts edits highest-source-line first so splicing earlier-in-file edits doesn't invalidate later ones.
- Returns a single
CodeActionwhoseWorkspaceEditcarries every edit atomically.is_preferred = true; kind =source.fixAll.mighty.
The same string-level applier
(mty_diagnostics::apply::apply_unified_diff) powers the CLI's
mty fix --apply, so behavior matches between the editor and the
terminal. See docs/reference/cli/mty-fix.md.
Signature help (v0.5)¶
signature_help::signature_help(doc, pos):
- Locate the smallest enclosing
CALL_EXPR/METHOD_CALL_EXPRwhoseARG_LISTcontains the cursor (inclusive of end-byte so cursors sitting right after the open(count). - Count commas at depth 1 between the open
(and the cursor — that's the active parameter index. - For
CALL_EXPR, take the callee's last IDENT and look it up: aDefRef::Fnrenders the full signature; aDefRef::Variantrenders the variant's payload as a constructor call. - For
METHOD_CALL_EXPR, prefer the matchingbuiltin_methodsentry (yields a synthetic.method(arg0, arg1)label); otherwise scanimpl_methodsacross every ADT and surface every match (each as a separateSignatureInformation).
Deferred: per-overload disambiguation by receiver type (we list
every candidate today), generic-arg-aware signatures, doc-comment
extraction into documentation.
Workspace folders (v0.5)¶
The server advertises workspaceFolders.supported = true and accepts
workspace/didChangeWorkspaceFolders + workspace/didChangeWatchedFiles
notifications. The implementation logs the event but does not yet
build a cross-file resolve map — each .sd file remains its own
translation unit inside the LSP. This is enough that VS Code's
"open folder" UX works (every .sd file opens individually) and the
event surface is in place for the next slice's multi-file work.
Cross-file workspace resolve (v0.8)¶
workspace.rs introduces a WorkspaceRegistry keyed by workspace
folder. On initialize the server scans each folder for .mty files
(capped at MAX_FILES = 2048, skipping target/ node_modules/ .git/
build/), runs the per-file DocAnalysis::analyze, and indexes them
by absolute path.
Subsequent events keep the index in sync:
did_open/did_change— overlay the open-buffer snapshot so cross-file queries see unsaved edits.did_change_watched_files— re-read from disk for create/change, drop for delete.did_change_workspace_folders— scan new folders, drop removed.
The new rename::rename_with_workspace consults the registry when the
rename target is a top-level public symbol. It harvests references in
every other file in the same folder and emits a multi-file
WorkspaceEdit. The single-file rename::rename entry point is
preserved as a thin wrapper that passes None for the registry.
VS Code extension capability:
"workspaceEdit": { "documentChanges": true },
"workspace": { "fileOperations": { "didRename": { ... } } }
(The Mighty extension advertises both, plus workspace.workspaceFolders.supported.)
tower-lsp version pin¶
tower-lsp = "0.20" is pinned in [workspace.dependencies]. Internally
it depends on lsp-types = "0.94" and re-exports it as
tower_lsp::lsp_types. The LSP crate uses that re-export rather than
depending on lsp-types = "0.97" directly — the trait bounds on
Client::send_notification etc. are keyed on the version tower-lsp was
compiled against, so the types must match.
If/when tower-lsp upgrades to lsp-types 0.97+, we can drop the alias.
Testing strategy¶
Integration tests under crates/mty-lsp/tests/ construct a
DocAnalysis directly and invoke each feature module, asserting on the
lsp-types result. This catches the same regressions as a full JSON-RPC
round-trip but stays fast and deterministic.
Run:
v0.5 ships 45 tests:
- 10 unit tests (line_index UTF-16, code-action edit distance, semantic-token encoding, ident validation).
- 11 v0.2 integration tests (diagnostics, hover, definition, completion, formatting, incremental change application).
- 5 semantic_tokens tests.
- 5 rename_local tests + 2 rename_top_level.
- 3 inlay_hints tests.
- 2 code_action_unresolved tests.
- 3 signature_help tests.
- 1 workspace_folders smoke test.
- 3 completion_semantic tests (keywords + locals + after-dot).
Add a regression test for every bug fix.