RFC 0012: Polyglot LSP Server over the .metast Index
Status
Approved.
1. Problem
MetaCall projects mix languages in one workspace. A function defined in Python is called from TypeScript through metacall(). No editor sees this: editors run one language server per file type, and each server knows nothing about the other languages. Cross-language navigation, hover, and completion are invisible.
Two past attempts failed:
intellisense: a VSCode extension, never a real language server. Per-request Python subprocesses for goto-definition (with an async race that missed on first use), placeholder types injected destructively into user.tsfiles, hardcoded type maps, and a committed cache with absolute Windows paths “first try”. Thevscode-languageclientdependency was dead weight.vscode-extension: deploy tooling and snippets only, no code intelligence “which doesn’t make sense”.
Meanwhile meta-ast already computes everything a code-intelligence backend needs: tree-sitter extraction for 9 languages, import resolution, a cross-language dependency graph with a confidence ladder, Tarjan SCC, call-site detection, and incremental re-analysis. But its output is a CLI artifact with no consumer loop except Function Mesh for now.
2. Proposal
The etags shape, upgraded:
meta-astemits a versioned, incrementally updatable index artifact (.metastv2).- A new LSP server consumes that index and serves requests for all supported languages from one process.
- Open-editor buffers feed the same extraction path, so unsaved edits stay consistent.
- Runtime metadata from
metacall inspectenriches hover when available; static features never depend on it.
The LSP does not re-parse anything. It serves queries from an immutable index snapshot and coordinates re-indexing when files change.
Home: the server lives in its own repository, metacall/lsp, not in this one. meta-ast stays a library dependency as discussed, Phase 0 patches below are the only changes required here.
3. Architecture
┌──────────────────────────────────────────────────────┐
│ Editor (VSCode / Neovim / emacs) │
│ thin client: launch server, forward buffers │
├─────────────────────────── LSP (stdio) ──────────────┤
│ meta-ast-lsp server │
│ ┌────────────────┐ crossbeam ┌───────────────┐ │
│ │ sync main loop │<-------------│ reindex worker│ │
│ │ answers from │ swap Arc │ runs engine │ │
│ │ Arc<IndexSnap> │------------->│ incremental │ │
│ └────────────────┘ └───────────────┘ │
│ │ embeds │
│ ┌───────────────────────▼──────────────────────┐ │
│ │ meta-ast engine (library, not CLI) │ │
│ │ WatchState + incremental_reanalyze │ │
│ │ BLAKE3 diff, per-file FileExtraction deltas │ │
│ └──────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────┤
│ Disk state │
│ .meta-ast/manifest.jsonl + per-file shards │
│ (cold start without full re-analysis) │
└──────────────────────────────────────────────────────┘
3.1 Engine layer
Embed meta-ast as a library inside the server process. Reuse directly:
incremental_reanalyze+WatchState: BLAKE3 fingerprint diff, re-extract only changed files, unchanged files keep their cachedArc<FileExtraction>.IdGenerator::with_start(max_cached_id + 1)seam: no ID collisions across ticks.GraphBuilder::from_extractions+add_edge_normalized: dedup and confidence rules stay canonical.
New seams required:
- In-memory ingestion: extract from
(uri, text, version)for open buffers instead of disk reads. This extendsextract_with_id_gen, it does not replace disk discovery. - Resolver invalidation:
PythonResolver,TsConfigResolver,GoModResolvercache filesystem state for the process lifetime. An LSP is long-lived, so these caches must invalidate on relevant config-file events. Until then, config changes require a server restart (documented limitation). - Debounce: reuse the notify-debouncer pattern from
src/watch/watcher.rs, driven by both OS events andtextDocument/didChangeflushes.
3.2 Index artifact (.metast v2)
Layout under .meta-ast/:
manifest.jsonl one line per file:
{ path, content_hash (BLAKE3 hex), size,
mtime, shard, schema_version }
shards/<n>.jsonl one block per file: symbols (full ranges),
imports, references, diagnostics, ast_node_count,
edges whose source or target belongs to the file
(endpoints as stable names)
header.json { schema_version, tool_version, created_at }
Rules:
- Hash bytes, never trust mtime alone. mtime and size are a fast negative check only (git checkout and rsync lie about mtime).
- Stable join key: language-scoped qualified name (SCIP-style descriptor, e.g.
python module . encrypt .). NumericSymbolIds are per-run and rayon-nondeterministic; they are regenerated in memory at load and never persisted. - Edge rows carry
(source_name, target_name, kind, confidence, flow_kind)so a merged load reproducesadd_edge_normalizedsemantics exactly. - Cold start loads
manifest.jsonl+ shards; warm path skips unchanged files entirely.
Optional exchange layer (Phase 3): meta-ast export scip writes a standard SCIP index so Sourcegraph-style consumers can read the same data “not needed for now”.
3.3 Server layer
Framework: lsp-server (rust-analyzer’s sync framework, actively released from the rust-analyzer monorepo).
Rationale for this specific shape:
- Indexed queries are hash-map lookups. A synchronous main loop answering from
Arc<IndexSnapshot>needs no async runtime. - Reindex coordination is plain threads: worker mutates nothing shared, then hands the new snapshot over the channel; main loop swaps the
Arc. - This mirrors rust-analyzer’s
main_loop.rstask-passing design without salsa. - Cancellation:
$/cancelRequestbecomes a token registry checked between handler phases. With sub-millisecond queries, cancellation pressure is minimal.
3.4 Client layer
Thin clients hosted under clients/ in metacall/lsp: VSCode and Zed first. Each client (~200 lines) activates on supported languages, spawns the server binary over stdio, forwards didOpen/didChange/didSave/didClose, and surfaces status. No intelligence lives in a client.
Editors without an extension story (emacs, vim, helix) work with zero client code: any LSP configuration pointing at the binary is enough. The TypeScript/Rust clients are deliberately simple onboarding tasks for new contributors arriving through Discord.
3.5 Distribution and packaging
The end user installs one extension and sees none of this machinery:
- CI publishes static server binaries per platform to GitHub releases of
metacall/lsp. - The client extension downloads the matching binary on activation and checks releases for updates automatically.
- If MetaCall itself is missing, the extension guides installation instead of failing silently. Static features never require MetaCall; only runtime enrichment (Phase 3) does.
- Optional later step: bundle the server into the existing MetaCall installer ‘not decided’, which already ships deploy and FaaS components. The maintainer confirmed this path stays open. Default stays GitHub releases because it decouples tooling releases from runtime releases.
4. Feature Phasing
Phase 0: meta-ast prep (prerequisite, small diffs)
serialize_symbol_nodeemitssource_rangeandfile_pathfor symbol nodes (currently dropped, which makes the graph export unusable for navigation).- Expose
extract_with_id_genover in-memory text. - Shard writer/reader module behind no new feature flag (it is pure output).
Phase 1: single-language correctness (not alive in this crate)
initialize, capabilities, workspace root handling (one root per server instance).- Incremental document sync + buffer overlay.
textDocument/documentSymbol(tree-sitter symbols already carry ranges).textDocument/hover: signature + docstring markdown.textDocument/definitionwithin one language via resolved references.- Publish diagnostics on re-extract.
Phase 2: the polyglot payoff
- Cross-language
definitionandreferencesrouted throughCodeGraphedges, includingmetacall()client-call edges with the RFC 0011 confidence ladder. workspace/symbol.textDocument/completion: bucketed symbol list with kind, signature, defining file; cross-language candidates ranked by edge confidence.- Debounced background reindex on file change with snapshot swap.
Phase 3: enrichment and ecosystem
- Hover merge with runtime metadata. Two sources, both optional enrichment:
- Local: parse
metacall inspectJSON (parameter and return annotations where the loader captured them, e.g. Python type hints). - Deployed: query a live FaaS deployment through the same
metacall/protocolAPI thatmetacall/deploy-mcp-serverwraps, so signatures reflect the running runtime. Absence of either changes nothing statically.
- Local: parse
- Semantic tokens (symbol kinds map cleanly).
- Stub generation (recycled concept from
deprecated/intellisense, done right): emit.pyi/.d.tsfrom the static model so native per-language servers also see cross-language signatures. Never write into user source files; write sibling stub directories configured as extra paths. Stubs are a one-way complement that feeds foreign signatures to native servers, they never carry navigation or diagnostics. The two-way channel is this LSP itself. meta-ast export scip.
5. Non-Goals
- Full type inference (RFC 0006 scope stands).
- Renaming across languages (write path; later decision if demand appears).
- Running or loading user code. Static analysis only; runtime inspection happens out-of-band via
metacall inspect. - Replacing native per-language servers. This server coexists; editors keep their TypeScript or Python LSP for deep single-language semantics and gain cross-language features from this one. We do not rebuild nine type systems: pyright and tsserver stay authoritative for deep inference.
6. Failure Modes We Will Not Repeat
From the old forensics:
- No destructive edits of user source. Stubs go to generated directories.
- No per-request subprocesses. Everything answers from the loaded snapshot.
- No placeholder or fabricated types. Absent metadata means absent hover detail.
- No committed caches with machine-specific paths.
- No VSCode-only logic. Intelligence lives in the server; clients stay thin.
7. Deliverables
meta-astPhase 0 patches in this repository: serializer ranges, memory-text extraction seam, shard IO. These land first sometacall/lspstarts against a capable library.metacall/lsprepository: server crate (meta-ast-lsp: engine host, sync server loop, index store) plus thin TypeScript clients underclients/vscodeandclients/zed.- Release engineering: per-platform binary builds published to GitHub releases; extension-side download and auto-update flow.
Verification for metacall/lsp follows the same repo standards:
cargo build --features watch --features metacall-deploy --features dataflow
cargo test --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo +1.94.0 fmt --check
Plus new integration tests: shard round-trip, snapshot swap under concurrent queries, cross-language definition fixtures under tests/fixtures/mixed/.