# LSP Routing Gortex uses the Language Server Protocol (LSP) for two things: 1. **Compiler-grade resolution during enrichment.** When the resolver leaves an edge as `ast_inferred` or `text_matched`, an LSP server (`textDocument/definition` + `textDocument/implementation`) upgrades it to `lsp_resolved` / `lsp_dispatch`. This raises precision on `find_usages`, `get_callers`, `find_implementations`, and the contract pipeline's binding resolver. 2. **On-demand actions** via the four MCP tools that wrap the LSP action surface: `get_diagnostics`, `get_code_actions`, `apply_code_action`, `fix_all_in_file`. Both paths route through the same per-daemon `lsp.Router` — one subprocess per language server, lazy-spawned on first request, idle reaper at ten minutes, LRU eviction at six concurrent. ## Server registry Sixteen servers ship in the registry today (`internal/semantic/lsp/registry.go`): | Spec name | Command | Languages | Default priority | | ---------------------------- | -------------------------------- | --------------------------- | ---------------- | | `gopls` | `gopls` | go | 3 | | `typescript-language-server` | `typescript-language-server` | typescript, javascript | 5 | | `pyright` | `pyright-langserver` | python | 5 | | `rust-analyzer` | `rust-analyzer` | rust | 5 | | `clangd` | `clangd --background-index` | c, c++, objc, objc++ | 5 | | `jdtls` | `jdtls` | java | 6 | | `kotlin-language-server` | `kotlin-language-server` | kotlin | 6 | | `omnisharp` | `omnisharp -lsp` | csharp | 5 | | `ruby-lsp` | `ruby-lsp` | ruby | 5 | | `phpactor` | `phpactor language-server` | php | 5 | | `lua-language-server` | `lua-language-server` | lua | 5 | | `sourcekit-lsp` | `sourcekit-lsp` | swift | 5 | | `haskell-language-server` | `haskell-language-server-wrapper`| haskell | 5 | | `elixir-ls` | `elixir-ls` | elixir | 5 | | `ocamllsp` | `ocamllsp` | ocaml | 5 | | `zls` | `zls` | zig | 5 | Several specs declare `AlternativeCommands` — Gortex picks the first binary on `PATH`: - `pyright` → falls back to `jedi-language-server` or `pylsp`. - `ruby-lsp` → falls back to `solargraph stdio`. - `phpactor` → falls back to `intelephense --stdio`. Lower priority numbers win when more than one provider serves the same language. `gopls` is `3` so it beats SCIP-based providers (`5`) for Go; `jdtls` is `6` so it's lower-priority than the SCIP-java path that ships separately. `clangd` is the one server that needs a compilation database for full enrichment. Point it at a `compile_commands.json` (or a `compile_flags.txt` / `.clangd`) at the repo root; without one, gortex degrades that repository's enrichment to reference confirmation — see [Enrichment cost model](#enrichment-cost-model). ## Enabling a server Add it to `.gortex.yaml`: ```yaml semantic: enabled: true mode: typecheck # or "callgraph" providers: - name: gopls enabled: true - name: rust-analyzer enabled: true - name: pyright enabled: true ``` Names match the **Spec name** column above. The router pre-registers every enabled spec at boot but does not spawn anything yet — subprocesses start the first time a tool calls into them. ## Installing the underlying servers Gortex does not ship the LSP binaries. Install the ones you want to use; the router falls back gracefully when a binary is missing (`SpecAvailable(name)` returns false → tool returns a structured `no_lsp_for` error instead of hanging). ```bash # Go go install golang.org/x/tools/gopls@latest # Rust rustup component add rust-analyzer # Python (pick one) npm install -g pyright # recommended pip install jedi-language-server # alt pip install python-lsp-server # alt (pylsp) # TypeScript / JavaScript npm install -g typescript typescript-language-server # C / C++ / Objective-C brew install llvm # ships clangd # or apt install clangd # Java brew install jdtls # Kotlin brew install kotlin-language-server # C# dotnet tool install --global Microsoft.OmniSharp # Ruby (pick one) gem install ruby-lsp # recommended gem install solargraph # alt # PHP (pick one) composer global require phpactor/phpactor npm install -g intelephense # alt # Lua brew install lua-language-server # Swift # Bundled with Xcode toolchain on macOS; no separate install. # Haskell ghcup install hls # Elixir brew install elixir-ls # OCaml opam install ocaml-lsp-server # Zig brew install zls ``` Verify with `gortex daemon status` — the LSP-router section lists `alive`, `last_used`, and the resolved command for each running server. Newly enabled specs show up only after the first request that needs them. ## Lifecycle The router applies these defaults in `cmd/gortex/server.go` and `cmd/gortex/mcp.go`: | Knob | Default | What it does | | ----------------- | ------------ | ----------------------------------------------------------------------------------------------------- | | `WithIdleTimeout` | 10 minutes | Subprocess closes if no `For()` / `ForSpec()` call lands in this window. | | `WithReaperInterval` | 1 minute | Background tick invokes `Reap()` to enforce the idle timeout. | | `WithMaxAlive` | 6 servers | LRU eviction kicks in when a seventh distinct server would spawn — the least-recently-used one closes. | These defaults suit a polyglot workspace where most languages are touched only intermittently. Override them by editing the `lsp.NewRouter(...).With...` chain in your build if you need a longer warm pool or a tighter memory bound. ## Enrichment cost model The resolution path (use 1 at the top of this page) runs as a batch **enrichment pass** — one pass per (repository × language server) — that walks the repo's nodes for that language and upgrades edges the AST resolver left ambiguous. A pass runs up to five phases: 1. **Interface pass** — for each interface / abstract declaration, asks the server for its implementations (`textDocument/implementation`) and links the dispatch edges. 2. **Confirm pass** — for every ambiguous edge (one whose confidence the resolver could not raise to certainty), asks for the referent's references (`textDocument/references`) and confirms or refutes the edge. Grouped by referent file so each file opens once. 3. **Definition-rebind fallback** — for edges the confirm pass could not settle from references alone, asks for the call site's definition (`textDocument/definition`) and rebinds the edge to the concrete target. 4. **References-add pass** — only for servers that expose references but not a call hierarchy; recovers the caller edges a declaration's references imply. 5. **Per-file sweep** — the whole-repo hover / hierarchy phase. Per function or method it prepares a call hierarchy and reads outgoing (and, where it helps, incoming) calls; per type or interface it reads the super/subtype hierarchy; per symbol it hovers for a type string. Each phase opens the files it touches on the language server (`didOpen`) and closes them when done (`didClose`). The first four phases carry most of the precision gain; the sweep carries most of the cost — on a warm restart, where every node already reloads with its resolved edges and type stamp, the sweep is almost pure churn. ### Bounded document session All five phases share one **document session** per pass. It opens each file on a server at most once while the file is in use, keeps recently-closed files warm in an LRU tail so a later phase reuses the open document instead of reopening it, and closes everything at pass end. The simultaneously-open ceiling is `2 × max_parallel` documents per server (floor 4): the pinned working set stays within `max_parallel` (bounded by the pass's file semaphore) and the extra headroom is the warm tail. `didOpen` / `didClose` stay paired per (file, server) — a file opened on a server that later dies still gets its close attempt on that server — while the server's open-document set stays bounded. This matters most for `clangd` without a compilation database: every `didOpen` triggers a full fallback-preamble + AST rebuild, so reopening the same file across phases multiplies that cost. ### Sweep modes The per-file sweep (phase 5) is gated by a **sweep mode**: | Mode | Behaviour | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `demand` | **Default.** Sweep a file only when its declarations still carry unresolved same-name call candidates (enrichment demand) or it declares a type / interface whose super/subtype hierarchy the sweep recovers. A file with neither signal is skipped, so a warm restart pays no sweep for it. Within a swept file, a node that already carries a `semantic_type` stamp from an earlier pass is not re-hovered. | | `full` | Sweep every file of the language — maximal hover and hierarchy coverage, the right choice for a first cold index. | | `off` | Skip the per-file sweep entirely. The confirm / rebind / references-add / interface passes still run, so edge tiers and recall are unaffected — only hover type strings and sweep-recovered hierarchy edges are dropped. | Resolution precedence, highest first: 1. The `GORTEX_LSP_SWEEP` environment variable (`demand` / `full` / `off`, with `none` accepted as an alias for `off`; case- and whitespace-insensitive). Set it where you launch `gortex daemon` / `gortex server` to dial one run without editing config. 2. The `.gortex.yaml` key `semantic.lsp_sweep` (same values). 3. A per-server default. Most servers have none and fall through to the global default; **rust-analyzer defaults to `full`**. Rust method calls bind overwhelmingly to standard-library receiver types the graph never indexes, so static confirmation leaves rust-analyzer's net-new call-hierarchy edges on the table — its recall lives in the full sweep. Either operator source above overrides it, so `semantic.lsp_sweep: demand` (or the env) puts rust-analyzer back on the demand gate. 4. The `demand` default. An unrecognised value at any level is ignored and the next source applies. ```yaml semantic: enabled: true lsp_sweep: demand # demand (default) | full | off ``` ### Incoming-calls policy In the sweep's call-hierarchy step, outgoing calls are always fetched — the sweep visits every caller, so a declaration's outgoing hops alone reconstruct every intra-repo static call edge. Incoming calls are fetched only where the outgoing side is blind: a dispatch-relevant declaration (whose concrete callers land on the incoming side of an interface method, not its outgoing side) or one that still carries unresolved demand — or under `full` mode. Files are swept demand-first, so a declaration whose incoming calls are skipped at a deadline cut still loses no reachable edge. The count of declined round trips rides on the pass-complete log as `incoming_calls_skipped`. ### Compile-database preflight (clangd) Before enriching a repository with a server that needs a compilation database (`clangd`), gortex checks the workspace root for one of: - `compile_commands.json` — canonical CMake / Bear output - `build*/compile_commands.json` — out-of-tree build directories - `compile_flags.txt` — clangd's flat-flags fallback - `.clangd` — a hand-written clangd config If none is present, clangd rebuilds a full fallback AST on every `didOpen`, and opening a header directly makes it a standalone translation unit with no cross-file signal to show for the cost. Rather than drive that churn, the pass **degrades to reference confirmation**: it runs the confirm and rebind passes (which work inside the fallback translation unit on fallback flags) and skips the interface pass, the references-add pass, the entire per-file sweep, and all header files. Edge tiers and confirmed / refuted edges are unaffected; hover type strings and call / type-hierarchy edges are absent for that pass. A degraded pass warns once with the remediation and marks its result `degraded`. `index_health` surfaces a recommendation naming the repository and provider: > generate compile_commands.json (cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON, > bear -- make, or meson) at the repo root, then reindex Degradation is deliberate, not a failure — `semantic_enrichment_ok` stays true. ### Telemetry The pass-complete log line (`LSP enrich: hover phase complete`) carries the cost accounting for the pass: | Field | Meaning | | ----- | ------- | | `sweep_mode` | The effective sweep mode for the pass. | | `did_opens` | Total `didOpen` calls across every phase. | | `reopened_files` | Files opened more than once (a warm-tail miss). | | `doc_evictions` | LRU evictions — a `didClose` forced to make room. | | `peak_open_docs` | Peak simultaneously-open documents on any one server. | | `req_references`, `req_implementations`, `req_definitions`, `req_hovers` | Per-method request counts. | | `req_prepare_call_hierarchy`, `req_outgoing_calls`, `req_incoming_calls` | Call-hierarchy request counts. | | `incoming_calls_skipped` | Incoming-calls round trips the policy above declined. | | `req_prepare_type_hierarchy`, `req_supertypes`, `req_subtypes` | Type-hierarchy request counts. | | `skipped_already_stamped` | Nodes whose hover was skipped because they already carried a `semantic_type`. | A degraded pass logs `LSP enrich: degraded pass complete (reference confirmation only)` instead, with `degraded=true` and the document-open counters but no hover / hierarchy counts. ## Diagnostics Two surfaces: ### Pull (poll-based) `get_diagnostics` returns the most recent `publishDiagnostics` payload the LSP server produced for a file. Use it for one-shot reads, batch checks, or contexts where the agent doesn't maintain a long-lived session. ### Push (opt-in) `subscribe_diagnostics` opts the calling MCP session into `notifications/diagnostics` push events. After subscribing, every LSP `publishDiagnostics` for any router-managed server is forwarded to the session as an MCP notification with this shape: ```json { "method": "notifications/diagnostics", "params": { "uri": "file:///abs/path/to/main.go", "path": "/abs/path/to/main.go", "server": "gopls", "diagnostics": [ { "range": {"start": {"line": 41, "character": 4}, "end": {...}}, "severity": 1, "message": "missing return", "source": "gopls" } ] } } ``` Push semantics: - **Opt-in per session.** Sessions that never call `subscribe_diagnostics` receive nothing — no broadcast spam. - **Delta-only.** Identical re-publishes (which some servers emit on every save even when nothing changed) are suppressed at the broadcaster — your subscribers only see real changes. - **All-router-managed servers.** One subscription covers every spec the user enabled in config. The `server` field on each notification identifies which LSP produced the payload. - **Non-blocking.** Notifications use `SendNotificationToAllClients` which drops to an error hook when a session's notification channel is full — slow consumers don't block the LSP message-pump. Call `unsubscribe_diagnostics` to opt back out (idempotent). Pair with `get_code_actions` + `apply_code_action` + `fix_all_in_file` for the full edit-time diagnostic loop without polling. ## Language-specific behaviour A few servers need per-language handling beyond the generic router. These knobs are **environment variables** read by the daemon process — set them where you launch `gortex daemon` / `gortex server`. There is no `.gortex.yaml` key for them today. ### C# — NuGet audit advisories (`omnisharp` / `csharp-ls`) The Roslyn `MSBuildWorkspace` these servers build escalates a NuGet audit *warning* (the `NU19xx` family — e.g. a transitively vulnerable package) to a **fatal** project-load failure and drops every project that references the flagged package. Those projects then have no compilation, so the server emits false `CS####` "unresolved type / namespace" diagnostics — even though `dotnet build` / `dotnet test` keep `NU19xx` a non-fatal warning and succeed. Gortex applies two complementary, C#-scoped fixes, **both on by default**: | Env var | Default | Effect | | --- | --- | --- | | `GORTEX_LSP_CSHARP_RESTORE` | on | Before spawning the C# server, run `dotnet restore -p:NuGetAudit=false` in the workspace so the MSBuild workspace loads every project (root-cause fix). Best-effort: a failure logs and falls through to a normal spawn; skipped on passive IDE attach and when `dotnet` is not on `PATH`. | | `GORTEX_LSP_CSHARP_DIAG_FILTER` | on | Strip diagnostics whose code is the `NU####` NuGet family from `publishDiagnostics` before storing / fanning out (symptom fix). Deliberately narrow — real `CS####` compiler diagnostics always pass through. | Set either to a falsey value (`0` / `off` / `false` / `none`) to disable it — e.g. `GORTEX_LSP_CSHARP_RESTORE=0` for offline / air-gapped indexing or to keep indexing off the network. Restore is on by default because gortex only indexes repositories you explicitly add (it never auto-discovers), and spawning the C# server already evaluates the project's MSBuild graph — so the restore adds no execution surface beyond the workspace load it precedes. A successful restore logs `lsp: csharp pre-restore complete (NuGetAudit suppressed)`; a failure logs `lsp: csharp pre-restore failed; spawning server anyway` with the restore output tail. ### Java — build-backed resolution (`jdtls`) By default `jdtls` runs in a **no-build** mode (JRE-only classpath, Maven / Gradle import and autobuild disabled) so indexing an untrusted Java repo never runs its build. Resolution is more limited in this mode (jdtls falls back to an "invisible project"). Set `GORTEX_LSP_JDTLS_TRUST_BUILD=1` (or `true`) to allow full Maven / Gradle import + autobuild for higher-fidelity resolution — **opt-in**, because it executes the repository's build tooling. Enable it only for repositories you trust. ## Troubleshooting - **`no_lsp_for` error:** the file extension didn't match any registered spec. Either the spec isn't enabled in `.gortex.yaml`, or the binary isn't on `PATH`. Run the spec's `--version` directly to confirm install. - **`router spawn : ...` error:** the binary was on `PATH` at boot but the subprocess failed to initialise (commonly a missing dependency such as `node` for `pyright`, or a workspace-config mismatch). The error surfaces the LSP server's stderr. - **Server keeps restarting:** the idle reaper closed it, then the next request re-spawned. Increase `WithIdleTimeout` if this hurts warm-cache benchmarks. - **High memory under polyglot load:** lower `WithMaxAlive` from 6 to 3-4. The LRU evicts the least-recent server transparently. ## Implementation notes - The router lives at `internal/semantic/lsp/router.go`. It satisfies the `semantic.LSPRouter` interface so `semantic.Manager` can drive batch enrichment without taking a hard import dependency on the lsp package (which would create a cycle — lsp already imports semantic for the Provider interface). - `tools_lsp.go::lspProviderForPath` queries the router first; if no router is wired (legacy boot paths, tests), it falls back to a scan through `Manager.AllProviders()` so user-defined daemons (specs not in the registry) still work. - One `*lsp.Provider` per spec, regardless of how many MCP sessions hit it. Concurrency is bounded by `ServerSpec.MaxParallel` (6-10 inflight requests per server depending on the spec).