21 KiB
LSP Routing
Gortex uses the Language Server Protocol (LSP) for two things:
- Compiler-grade resolution during enrichment. When the resolver
leaves an edge as
ast_inferredortext_matched, an LSP server (textDocument/definition+textDocument/implementation) upgrades it tolsp_resolved/lsp_dispatch. This raises precision onfind_usages,get_callers,find_implementations, and the contract pipeline's binding resolver. - 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 tojedi-language-serverorpylsp.ruby-lsp→ falls back tosolargraph stdio.phpactor→ falls back tointelephense --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.
Enabling a server
Add it to .gortex.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).
# 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:
- Interface pass — for each interface / abstract declaration, asks the
server for its implementations (
textDocument/implementation) and links the dispatch edges. - 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. - 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. - References-add pass — only for servers that expose references but not a call hierarchy; recovers the caller edges a declaration's references imply.
- 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:
- The
GORTEX_LSP_SWEEPenvironment variable (demand/full/off, withnoneaccepted as an alias foroff; case- and whitespace-insensitive). Set it where you launchgortex daemon/gortex serverto dial one run without editing config. - The
.gortex.yamlkeysemantic.lsp_sweep(same values). - 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, sosemantic.lsp_sweep: demand(or the env) puts rust-analyzer back on the demand gate. - The
demanddefault.
An unrecognised value at any level is ignored and the next source applies.
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 outputbuild*/compile_commands.json— out-of-tree build directoriescompile_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:
{
"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_diagnosticsreceive 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
serverfield on each notification identifies which LSP produced the payload. - Non-blocking. Notifications use
SendNotificationToAllClientswhich 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_forerror: the file extension didn't match any registered spec. Either the spec isn't enabled in.gortex.yaml, or the binary isn't onPATH. Run the spec's--versiondirectly to confirm install.router spawn <name>: ...error: the binary was onPATHat boot but the subprocess failed to initialise (commonly a missing dependency such asnodeforpyright, 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
WithIdleTimeoutif this hurts warm-cache benchmarks. - High memory under polyglot load: lower
WithMaxAlivefrom 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 thesemantic.LSPRouterinterface sosemantic.Managercan 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::lspProviderForPathqueries the router first; if no router is wired (legacy boot paths, tests), it falls back to a scan throughManager.AllProviders()so user-defined daemons (specs not in the registry) still work.- One
*lsp.Providerper spec, regardless of how many MCP sessions hit it. Concurrency is bounded byServerSpec.MaxParallel(6-10 inflight requests per server depending on the spec).