Skip to content

Latest commit

 

History

History
240 lines (189 loc) · 9.65 KB

File metadata and controls

240 lines (189 loc) · 9.65 KB

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.

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.

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_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.

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).