a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
432 lines
21 KiB
Markdown
432 lines
21 KiB
Markdown
# 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 <name>: ...` 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).
|