Files
zzet--gortex/internal/semantic/lsp/registry.go
T
pbednarcik 0fb81c4247 lsp: env override for the heavy-request opt-out (GORTEX_LSP_HEAVY)
The NoHeavyRequests spec flag guards stock csharp-ls builds, but the
leak it works around has a known upstream fix — an operator running a
patched server build wants references / incomingCalls back without
rebuilding gortex. GORTEX_LSP_HEAVY wins over the spec flag both ways,
mirroring GORTEX_LSP_OPEN_DOCS: "on" restores the heavy legs for an
opted-out spec, "off" disables them for every server, anything else
falls through to the spec.
2026-08-19 20:55:42 +02:00

901 lines
31 KiB
Go

package lsp
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/zzet/gortex/internal/semantic"
)
// ServerSpec describes one LSP server's invocation, the languages it
// covers, and the file extensions that route requests to it. Specs are
// the source of truth for both default-config registration (used by
// `gortex daemon` / `gortex mcp` / `gortex server`) and the runtime
// router (which dials a per-extension provider on first touch).
type ServerSpec struct {
// Name is the unique identifier used in config (e.g. "tsserver",
// "pyright", "rust-analyzer", "clangd", "jdtls").
Name string
// Command is the executable the daemon spawns (e.g. "rust-analyzer",
// "typescript-language-server", "pyright-langserver").
Command string
// Args is the argv tail. Most servers take stdio; servers that need
// `--stdio` or similar carry it here.
Args []string
// Languages is the set of internal language codes this server
// handles. Used by the manager's per-language priority routing.
Languages []string
// Extensions is the set of file extensions (with leading dot) that
// route to this server. Drives the runtime router.
Extensions []string
// LanguageIDs maps an extension to the LSP `languageId` to use in
// `textDocument/didOpen`. When an extension isn't listed, the first
// entry of Languages is used as a fallback.
LanguageIDs map[string]string
// Priority is the default priority used when no user override
// exists. Lower wins. Reserved 1-3 for user-tuned overrides; default
// LSP servers use 5.
Priority int
// Daemon hints whether the LSP server is intended to be kept alive
// across enrich passes. All known LSP servers benefit from this;
// the field is here for explicit future tuning.
Daemon bool
// MaxParallel caps concurrent LSP requests for this server. 10 is a
// safe starting point — gopls / rust-analyzer happily serve 10
// in-flight requests without backpressure.
MaxParallel int
// AlternativeCommands lists fallback executables to try when the
// primary `Command` is not on PATH. The first one that resolves
// wins. Useful for ecosystems with multiple bundled drivers
// (e.g. `tsserver` from `npm i -g typescript`, vs
// `typescript-language-server`).
AlternativeCommands []ServerAlt
// Env carries extra KEY=VALUE environment entries for the server
// subprocess, appended to the daemon's own environment. Empty for
// every built-in spec; populated only when a user overrides a
// server via .gortex.yaml (e.g. pinning JAVA_HOME for jdtls).
Env []string
// InitializationOptions carries server-specific initialization
// parameters sent in the "initialize" request. For jdtls this
// includes Maven/Gradle import settings so the server can resolve
// project dependencies instead of falling back to an "invisible
// project" with only JRE on the classpath. Nil/empty for servers
// that don't need it (gopls, rust-analyzer, etc.).
InitializationOptions json.RawMessage `json:"initializationOptions,omitempty"`
// Connect, when non-nil, switches this spec from spawn mode to
// passive-attach: instead of starting a subprocess via Command +
// Args, Gortex dials the configured network endpoint and uses
// the resulting net.Conn for JSON-RPC framing. The intended use
// is IDE-coexistence — share the LSP server the user's editor is
// already running, rather than spawning a duplicate 200-500 MB
// subprocess per language.
Connect *ConnectSpec
// UseWorkspaceBundleGemfile, when true, makes the router set
// BUNDLE_GEMFILE to the spawned workspace's own Gemfile (when one
// exists) in the server's environment. ruby-lsp runs a `bundle install`
// for a "composed bundle" on spawn unless BUNDLE_GEMFILE is already set;
// pointing it at the project Gemfile skips that install for enrichment.
UseWorkspaceBundleGemfile bool
// NeedsCompileDB marks a server that cannot produce full semantic
// signal without a compilation database in the workspace. clangd is
// the case: with no compile_commands.json every didOpen becomes a
// header-less fallback translation unit — a full preamble + AST
// rebuild per file — so the hover / hierarchy sweep pays a per-file
// rebuild for little return, and opening a header directly makes it a
// standalone TU. When this is set and no database is found, enrichment
// degrades to reference confirmation: it skips the sweep and header
// files and warns with the remediation.
NeedsCompileDB bool
// DefaultSweepMode overrides the global demand-gated default of the
// per-file hover / call-hierarchy sweep for this server. Empty keeps
// the "demand" default; "full" makes the server sweep every file
// unless the operator sets an explicit sweep mode (config or the
// GORTEX_LSP_SWEEP env), both of which still win. Set for a server
// whose net-new edges come mostly from call hierarchy the demand gate
// skips — rust-analyzer, whose ambiguous receivers resolve through
// std-library types the graph never indexes, so its recall lives in
// the full sweep rather than in cheaper static confirmation.
DefaultSweepMode string
// NoDidOpen marks a server that answers position requests (hover,
// references, call hierarchy) for files that were never opened with
// `textDocument/didOpen`, reading them from its own workspace load
// instead. For such a server the enrichment pass skips the didOpen /
// didClose document lifecycle entirely. The point is throughput, not
// tidiness: a scheduler that runs read-only requests concurrently but
// treats document notifications as exclusive writes (csharp-ls) drains
// every in-flight read on each didOpen, so a sweep that interleaves
// opens with its queries serializes to single-request throughput no
// matter how many requests it keeps in flight. The
// GORTEX_LSP_OPEN_DOCS env override wins over this flag both ways.
NoDidOpen bool
// NoHeavyRequests marks a server whose FindReferences machinery leaks
// memory per request until process exit — csharp-ls holds ~10MB and a
// set of OS handles per textDocument/references round trip and ~0.7MB
// per callHierarchy/incomingCalls, with nothing released short of a
// restart, so a full sweep collapses the server long before it
// completes. For such a server the enrichment pass skips both request
// classes: ambiguous edges are confirmed through textDocument/definition
// at their call sites (a clean position request) and dispatch fan-out
// stays with the graph-side interface-dispatch synthesis. The
// GORTEX_LSP_HEAVY env override wins over this flag both ways — an
// operator running a server build without the leak sets it "on" to
// restore the heavy legs.
NoHeavyRequests bool
// ProjectReady, when non-nil, reports whether a workspace has the
// project setup this server needs to resolve anything at all — e.g.
// node_modules for tsserver, whose every cross-file / import lookup
// returns "no package metadata" without it. When it returns
// (false, remediation) the enrichment pass is SKIPPED entirely (not
// merely degraded like NeedsCompileDB): such a server spends minutes
// indexing and hovering to produce zero semantic signal, and the
// tree-sitter floor still covers those files. This is the general form
// of NeedsCompileDB for servers whose failure is a missing dependency
// tree rather than a missing compilation database.
ProjectReady func(root string) (ready bool, remediation string)
}
// ConnectSpec carries the transport coordinates for passive LSP attach
// — Gortex dials an already-running LSP server (typically the one the
// user's IDE has started) instead of spawning its own. When the dial
// fails and FallbackSpawn is false (the default), Gortex refuses the
// language for that workspace rather than silently spinning up a second
// instance behind the user's back.
type ConnectSpec struct {
// Network is the dial network — "tcp" or "unix".
Network string
// Address is the dial address — host:port for tcp, socket path
// for unix.
Address string
// FallbackSpawn, when true, lets ensureClient fall back to
// spawning a subprocess (using the spec's Command + Args) if the
// dial fails after backoff. Default false: if the IDE's server
// is unreachable, the language is unavailable rather than racing
// the IDE for a port.
FallbackSpawn bool
}
// Validate reports any obvious misconfiguration in a ConnectSpec.
// Returns nil for a usable spec; otherwise an error describing the
// missing field. A nil receiver is treated as "not configured" (no
// error — the spec just stays in spawn mode).
func (c *ConnectSpec) Validate() error {
if c == nil {
return nil
}
if strings.TrimSpace(c.Network) == "" && strings.TrimSpace(c.Address) == "" {
return fmt.Errorf("lsp.connect: network and address are required")
}
if strings.TrimSpace(c.Network) == "" {
return fmt.Errorf("lsp.connect: network is required (tcp or unix)")
}
if strings.TrimSpace(c.Address) == "" {
return fmt.Errorf("lsp.connect: address is required")
}
switch strings.ToLower(strings.TrimSpace(c.Network)) {
case "tcp", "tcp4", "tcp6", "unix":
default:
return fmt.Errorf("lsp.connect: unsupported network %q (want tcp or unix)", c.Network)
}
return nil
}
// ServerAlt is one fallback command form for a server.
type ServerAlt struct {
Command string
Args []string
// InitializationOptions overrides the spec-level InitializationOptions
// when this alternative wins on PATH. The spec-level blob is keyed to
// the primary command, so an alternative from a different vendor
// (e.g. intelephense standing in for phpactor) can carry its own
// options. Empty means "inherit the spec's InitializationOptions".
InitializationOptions json.RawMessage `json:"initializationOptions,omitempty"`
// InitOptionsFunc, when non-nil, is consulted at initialize time with
// the resolved workspace root so an alternative can root a per-repo
// cache path inside Gortex's cache home — a path only known at resolve
// time, not as a baked literal. It wins over InitializationOptions when
// it returns a non-empty result.
InitOptionsFunc func(repoRoot string) json.RawMessage `json:"-"`
}
// Servers is the canonical list of LSP servers Gortex knows how to
// route. Order is meaningful only for the seed config — the runtime
// router uses the per-extension lookup table.
//
// Adding a new server: add the entry here and its file extensions are
// collected into the per-extension table at init time. The default
// config picks it up automatically; users override priority / command
// via .gortex.yaml.
// JdtlsTrustBuildEnv, when set to a truthy value ("1" / "true"), opts jdtls
// into build-backed resolution (Maven/Gradle import + autobuild) for the
// indexed repository. OFF by default: import and autobuild RUN the indexed
// project's own build — Gradle build scripts execute during import, autobuild
// compiles the sources — which is code execution from the indexed repo. Only
// enable it for repositories you trust.
const JdtlsTrustBuildEnv = "GORTEX_LSP_JDTLS_TRUST_BUILD"
// jdtlsSafeInitOptions keeps jdtls in a no-build mode: JRE-only classpath, with
// Maven/Gradle import and autobuild DISABLED. Resolution is more limited (jdtls
// falls back to an "invisible project"), but indexing an untrusted Java repo
// never executes its build. The default; opt into the build variant via
// JdtlsTrustBuildEnv.
const jdtlsSafeInitOptions = `{
"settings": {
"java": {
"import": {
"maven": {"enabled": false},
"gradle": {"enabled": false}
},
"autobuild": {"enabled": false}
}
},
"bundles": [],
"workspaceFolders": "auto"
}`
// jdtlsBuildInitOptions enables Maven/Gradle import + autobuild so jdtls
// resolves real project dependencies. WARNING: import and autobuild execute the
// indexed project's build (arbitrary code for Gradle). Sent only when the
// operator has explicitly trusted the indexed repo via JdtlsTrustBuildEnv.
const jdtlsBuildInitOptions = `{
"settings": {
"java": {
"import": {
"maven": {"enabled": true},
"gradle": {"enabled": true}
},
"autobuild": {"enabled": true}
}
},
"bundles": [],
"workspaceFolders": "auto"
}`
// effectiveInitializationOptions returns the InitializationOptions to send for
// spec. jdtls is upgraded from its safe (no-build) defaults to build-backed
// options ONLY when the operator opted in via JdtlsTrustBuildEnv; every other
// server gets its spec options unchanged.
func effectiveInitializationOptions(spec *ServerSpec) json.RawMessage {
if spec == nil {
return nil
}
if spec.Command == "jdtls" && envTruthy(JdtlsTrustBuildEnv) {
return json.RawMessage(jdtlsBuildInitOptions)
}
return spec.InitializationOptions
}
// envTruthy reports whether environment variable name is set to "1" or "true".
func envTruthy(name string) bool {
v := strings.TrimSpace(os.Getenv(name))
return v == "1" || strings.EqualFold(v, "true")
}
var Servers = []ServerSpec{
{
Name: "gopls",
Command: "gopls",
Args: []string{"-remote=auto"},
Languages: []string{"go"},
Extensions: []string{".go"},
LanguageIDs: map[string]string{".go": "go"},
Priority: 3,
Daemon: true,
MaxParallel: 10,
},
{
Name: "typescript-language-server",
Command: "typescript-language-server",
Args: []string{"--stdio"},
Languages: []string{"typescript", "javascript"},
Extensions: []string{
".ts", ".tsx", ".mts", ".cts",
".js", ".jsx", ".mjs", ".cjs",
},
LanguageIDs: map[string]string{
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
},
// Fallback behind tsgo: serves TS/JS wherever the native
// compiler's binary is not installed. Raise its priority in
// .gortex.yaml to prefer it over tsgo again.
Priority: 6,
Daemon: true,
MaxParallel: 10,
ProjectReady: tsProjectReady,
},
{
// tsgo is the native-Go TypeScript compiler's LSP mode. It is
// the default TS/JS routing winner when its binary is on PATH:
// native process, no node/tsserver/typingsInstaller tree, much
// faster startup. typescript-language-server serves as the
// fallback when tsgo is not installed.
Name: "tsgo",
Command: "tsgo",
Args: []string{"--lsp", "--stdio"},
Languages: []string{"typescript", "javascript"},
Extensions: []string{
".ts", ".tsx", ".mts", ".cts",
".js", ".jsx", ".mjs", ".cjs",
},
LanguageIDs: map[string]string{
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
},
Priority: 5,
Daemon: true,
MaxParallel: 10,
ProjectReady: tsProjectReady,
},
{
Name: "pyright",
Command: "pyright-langserver",
Args: []string{"--stdio"},
Languages: []string{"python"},
Extensions: []string{".py", ".pyi"},
LanguageIDs: map[string]string{
".py": "python",
".pyi": "python",
},
Priority: 5,
Daemon: true,
MaxParallel: 10,
AlternativeCommands: []ServerAlt{
// jedi-language-server / pylsp are common alternates.
{Command: "jedi-language-server"},
{Command: "pylsp"},
},
},
{
// pyrefly is Meta's Rust-based Python type checker (the Pyre
// successor). Offered at a lower precedence than pyright so the
// default routing is unchanged for anyone who has both — a user
// opts in by raising its priority in .gortex.yaml.
Name: "pyrefly",
Command: "pyrefly",
Args: []string{"lsp"},
Languages: []string{"python"},
Extensions: []string{".py", ".pyi"},
LanguageIDs: map[string]string{
".py": "python",
".pyi": "python",
},
Priority: 6,
Daemon: true,
MaxParallel: 10,
},
{
Name: "rust-analyzer",
Command: "rust-analyzer",
Languages: []string{"rust"},
Extensions: []string{".rs"},
LanguageIDs: map[string]string{
".rs": "rust",
},
Priority: 5,
Daemon: true,
MaxParallel: 8,
// 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. Default to it (operator config
// / GORTEX_LSP_SWEEP still override).
DefaultSweepMode: sweepModeFull,
},
{
Name: "clangd",
Command: "clangd",
// `--background-index` keeps a project-wide symbol index hot in
// the daemon, which is essential for cross-file references and
// type-hierarchy precision in large C++ trees. `--header-insertion=never`
// avoids tactical edits when we only want graph signal.
// `--clang-tidy=false` disables lint matchers during enrichment:
// Gortex consumes semantic graph signal, not clang-tidy diagnostics,
// and a broad repo `.clang-tidy` can crash clangd mid-pass — which,
// with reconnect, becomes a crash→reconnect→reindex loop that pins the
// server at high CPU for the whole pass.
Args: []string{"--background-index", "--header-insertion=never", "--clang-tidy=false"},
Languages: []string{"c", "cpp", "objc", "objcpp"},
Extensions: []string{
".c", ".h",
".cc", ".cpp", ".cxx", ".c++",
".hh", ".hpp", ".hxx", ".h++",
".m", ".mm",
},
LanguageIDs: map[string]string{
".c": "c",
".h": "c",
".cc": "cpp",
".cpp": "cpp",
".cxx": "cpp",
".c++": "cpp",
".hh": "cpp",
".hpp": "cpp",
".hxx": "cpp",
".h++": "cpp",
".m": "objective-c",
".mm": "objective-cpp",
},
Priority: 5,
Daemon: true,
MaxParallel: 6,
// clangd's hover / call- and type-hierarchy signal needs a
// compilation database; without one it rebuilds a fallback AST
// per didOpen, so the enrichment pass degrades to reference
// confirmation instead of driving that churn.
NeedsCompileDB: true,
},
{
Name: "jdtls",
Command: "jdtls",
Languages: []string{"java"},
Extensions: []string{".java"},
LanguageIDs: map[string]string{
".java": "java",
},
Priority: 6, // jdtls is heavyweight; lower priority than scip-java.
Daemon: true,
MaxParallel: 6,
// InitializationOptions for jdtls. SAFE BY DEFAULT (jdtlsSafeInitOptions):
// no Maven/Gradle import and no autobuild, so indexing an UNTRUSTED Java
// repo never runs its build. Build-backed resolution — which EXECUTES the
// repo's own build (Gradle scripts run on import; autobuild compiles) — is
// opt-in via GORTEX_LSP_JDTLS_TRUST_BUILD; see effectiveInitializationOptions.
InitializationOptions: json.RawMessage(jdtlsSafeInitOptions),
},
{
Name: "kotlin-language-server",
Command: "kotlin-language-server",
Languages: []string{"kotlin"},
Extensions: []string{".kt", ".kts"},
LanguageIDs: map[string]string{
".kt": "kotlin",
".kts": "kotlin",
},
Priority: 6,
Daemon: true,
MaxParallel: 6,
},
{
Name: "omnisharp",
Command: "omnisharp",
Args: []string{"-lsp"},
Languages: []string{"csharp"},
Extensions: []string{".cs"},
LanguageIDs: map[string]string{
".cs": "csharp",
},
Priority: 5,
Daemon: true,
MaxParallel: 6,
// Both Roslyn workspaces answer position requests for files that
// were never didOpen'd, and csharp-ls's request scheduler treats
// every didOpen / didClose as an exclusive write that drains all
// in-flight reads — an enrichment pass that interleaves opens
// serializes to single-request throughput. Skip the lifecycle.
NoDidOpen: true,
// csharp-ls leaks per-request on the FindReferences path
// (references, incomingCalls) until process exit; confirm through
// definition at call sites instead and leave dispatch fan-out to
// the interface-dispatch synthesizer.
NoHeavyRequests: true,
// csharp-ls is a Roslyn stdio LSP (`dotnet tool install csharp-ls`)
// that speaks plain LSP with no args. Current versions discover
// solutions on their own (recursive .sln/.slnx glob, most-projects
// heuristic since 0.19.0); gortex still pins the resolved solution
// at spawn via csharpSolutionArgs for determinism, to name the
// same file the targeted pre-restore uses, and as the operator
// override when the server's heuristic picks wrong. It is far more
// commonly installed than OmniSharp on dev machines, so try it when
// omnisharp is not on PATH.
AlternativeCommands: []ServerAlt{
{Command: "csharp-ls"},
},
},
{
Name: "ruby-lsp",
Command: "ruby-lsp",
Languages: []string{"ruby"},
Extensions: []string{".rb", ".rake"},
LanguageIDs: map[string]string{
".rb": "ruby",
".rake": "ruby",
},
Priority: 5,
Daemon: true,
MaxParallel: 6,
UseWorkspaceBundleGemfile: true,
AlternativeCommands: []ServerAlt{
{Command: "solargraph", Args: []string{"stdio"}},
},
},
{
Name: "phpactor",
Command: "phpactor",
Args: []string{"language-server"},
Languages: []string{"php"},
Extensions: []string{".php"},
LanguageIDs: map[string]string{
".php": "php",
},
Priority: 5,
Daemon: true,
MaxParallel: 6,
AlternativeCommands: []ServerAlt{
// intelephense ships as a Node CLI. Pin its index cache under
// Gortex's cache home per repo so it doesn't write to its
// default global location outside the engine's isolation.
{Command: "intelephense", Args: []string{"--stdio"}, InitOptionsFunc: intelephenseInitOptions},
},
},
{
Name: "lua-language-server",
Command: "lua-language-server",
Languages: []string{"lua"},
Extensions: []string{".lua"},
LanguageIDs: map[string]string{
".lua": "lua",
},
Priority: 5,
Daemon: true,
MaxParallel: 4,
},
{
Name: "sourcekit-lsp",
Command: "sourcekit-lsp",
Languages: []string{"swift"},
Extensions: []string{".swift"},
LanguageIDs: map[string]string{
".swift": "swift",
},
Priority: 5,
Daemon: true,
MaxParallel: 6,
},
{
Name: "haskell-language-server",
Command: "haskell-language-server-wrapper",
Args: []string{"--lsp"},
Languages: []string{"haskell"},
Extensions: []string{".hs", ".lhs"},
LanguageIDs: map[string]string{
".hs": "haskell",
".lhs": "haskell",
},
Priority: 5,
Daemon: true,
MaxParallel: 4,
},
{
Name: "elixir-ls",
Command: "elixir-ls",
Languages: []string{"elixir"},
Extensions: []string{".ex", ".exs"},
LanguageIDs: map[string]string{
".ex": "elixir",
".exs": "elixir",
},
Priority: 5,
Daemon: true,
MaxParallel: 4,
},
{
Name: "ocamllsp",
Command: "ocamllsp",
Languages: []string{"ocaml"},
Extensions: []string{".ml", ".mli"},
LanguageIDs: map[string]string{
".ml": "ocaml",
".mli": "ocaml",
},
Priority: 5,
Daemon: true,
MaxParallel: 4,
},
{
Name: "zls",
Command: "zls",
Languages: []string{"zig"},
Extensions: []string{".zig", ".zon"},
LanguageIDs: map[string]string{
".zig": "zig",
".zon": "zig",
},
Priority: 5,
Daemon: true,
MaxParallel: 4,
},
{
Name: "terraform-ls",
Command: "terraform-ls",
Args: []string{"serve"},
Languages: []string{"hcl"},
Extensions: []string{".tf", ".tfvars"},
LanguageIDs: map[string]string{
".tf": "terraform",
".tfvars": "terraform-vars",
},
Priority: 6,
Daemon: true,
MaxParallel: 4,
},
{
Name: "yaml-language-server",
Command: "yaml-language-server",
Args: []string{"--stdio"},
Languages: []string{"yaml"},
Extensions: []string{".yaml", ".yml"},
LanguageIDs: map[string]string{
".yaml": "yaml",
".yml": "yaml",
},
Priority: 7,
Daemon: true,
MaxParallel: 4,
},
{
Name: "vscode-json-language-server",
Command: "vscode-json-language-server",
Args: []string{"--stdio"},
Languages: []string{"json"},
Extensions: []string{".json", ".jsonc"},
LanguageIDs: map[string]string{
".json": "json",
".jsonc": "jsonc",
},
Priority: 7,
Daemon: true,
MaxParallel: 4,
},
{
Name: "bash-language-server",
Command: "bash-language-server",
Args: []string{"start"},
Languages: []string{"bash"},
Extensions: []string{".sh", ".bash"},
LanguageIDs: map[string]string{
".sh": "shellscript",
".bash": "shellscript",
},
Priority: 7,
Daemon: true,
MaxParallel: 4,
},
}
// extToSpecs resolves a file extension (with leading dot, lower case)
// to every spec covering it, ordered by Priority (registration order
// breaks ties). Built once at init from `Servers`.
var extToSpecs map[string][]*ServerSpec
// nameToSpec resolves a server name (e.g. "rust-analyzer") to its spec.
var nameToSpec map[string]*ServerSpec
func init() {
extToSpecs = make(map[string][]*ServerSpec, 64)
nameToSpec = make(map[string]*ServerSpec, len(Servers))
for i := range Servers {
spec := &Servers[i]
nameToSpec[spec.Name] = spec
for _, ext := range spec.Extensions {
lower := strings.ToLower(ext)
extToSpecs[lower] = append(extToSpecs[lower], spec)
}
}
// Priority orders the candidates per extension; the stable sort
// keeps registration order among equals (gopls stays authoritative
// for `.go` even if a future same-priority entry claims it).
for _, specs := range extToSpecs {
sort.SliceStable(specs, func(i, j int) bool { return specs[i].Priority < specs[j].Priority })
}
// Contribute every known LSP spec to semantic.DefaultConfig()
// so the daemon picks them up automatically when the binary is
// on PATH. Each spec becomes a disabled-only-when-binary-missing
// ProviderConfig at the spec's default priority.
semantic.RegisterDefaultProviders(func() []semantic.ProviderConfig {
out := make([]semantic.ProviderConfig, 0, len(Servers))
for i := range Servers {
s := &Servers[i]
out = append(out, semantic.ProviderConfig{
Name: s.Name,
Command: s.Command,
Args: s.Args,
Languages: s.Languages,
Priority: s.Priority,
Enabled: true,
Daemon: s.Daemon,
MaxParallel: s.MaxParallel,
})
}
return out
})
}
// SpecForExtension returns the ServerSpec preferred for the given file
// extension (with or without leading dot): the highest-priority spec
// whose binary is actually installed. When several specs cover one
// extension (tsgo / typescript-language-server, pyright / pyrefly) the
// lower Priority number wins among the installed ones — `.ts` routes
// to tsgo when its binary is on PATH and falls back to
// typescript-language-server otherwise. When none of the covering
// servers is installed the priority winner is returned unchanged, so
// the spawn path reports the missing binary as before. Returns nil
// when no spec covers the extension.
func SpecForExtension(ext string) *ServerSpec {
specs := SpecsForExtension(ext)
if len(specs) == 0 {
return nil
}
for _, s := range specs {
if specCommandAvailable(s) {
return s
}
}
return specs[0]
}
// SpecsForExtension returns every ServerSpec covering the extension in
// priority order (lower Priority number first), without consulting
// binary availability. Callers that hold their own availability source
// (e.g. the Router) walk this list and pick the first live entry.
func SpecsForExtension(ext string) []*ServerSpec {
if ext == "" {
return nil
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return extToSpecs[strings.ToLower(ext)]
}
// lookPath is swappable in tests so extension-preference cases can
// simulate which server binaries are installed.
var lookPath = exec.LookPath
var (
cmdAvailMu sync.Mutex
cmdAvailCache map[string]bool
)
// specCommandAvailable reports whether the spec's command (or one of
// its alternatives) resolves on PATH. Passive connect specs count as
// available — they dial instead of spawn. Results are cached for the
// process lifetime, matching Router.specAvailable: installing a server
// mid-daemon takes a restart to notice.
func specCommandAvailable(s *ServerSpec) bool {
if s == nil {
return false
}
if s.Connect != nil {
return s.Connect.Validate() == nil
}
if commandOnPath(s.Command) {
return true
}
for _, alt := range s.AlternativeCommands {
if commandOnPath(alt.Command) {
return true
}
}
return false
}
func commandOnPath(cmd string) bool {
if cmd == "" {
return false
}
cmdAvailMu.Lock()
defer cmdAvailMu.Unlock()
if v, ok := cmdAvailCache[cmd]; ok {
return v
}
if cmdAvailCache == nil {
cmdAvailCache = make(map[string]bool)
}
_, err := lookPath(cmd)
cmdAvailCache[cmd] = err == nil
return err == nil
}
// resetCommandAvailabilityCache clears the per-command PATH cache.
// Test-only: pairs with swapping lookPath.
func resetCommandAvailabilityCache() {
cmdAvailMu.Lock()
cmdAvailCache = nil
cmdAvailMu.Unlock()
}
// SpecForPath returns the ServerSpec covering the file's extension.
// Returns nil when no spec covers it.
func SpecForPath(path string) *ServerSpec {
return SpecForExtension(filepath.Ext(path))
}
// SpecByName returns the ServerSpec with the given name, or nil.
func SpecByName(name string) *ServerSpec {
return nameToSpec[name]
}
// SpecWithOverrides returns a copy of base with non-empty command /
// args / env overrides applied — the path by which .gortex.yaml tunes
// a heavyweight server (notably pinning a JRE + jdtls launcher args).
// base is never mutated, so the package-global Servers slice stays
// pristine across daemons and tests.
func SpecWithOverrides(base *ServerSpec, command string, args, env []string) *ServerSpec {
return SpecWithOverridesConnect(base, command, args, env, nil)
}
// SpecWithOverridesConnect is SpecWithOverrides plus an optional
// passive-attach block. When connect is non-nil and validates, the
// returned spec switches from spawn to dial — the Router constructs
// the LSP client by dialing the configured endpoint instead of running
// the spec's Command. A nil connect leaves spawn behaviour untouched.
func SpecWithOverridesConnect(base *ServerSpec, command string, args, env []string, connect *ConnectSpec) *ServerSpec {
if base == nil {
return nil
}
cp := *base
if command != "" {
cp.Command = command
}
if len(args) > 0 {
cp.Args = append([]string(nil), args...)
}
if len(env) > 0 {
cp.Env = append([]string(nil), env...)
}
if connect != nil {
// Shallow-copy so callers can't mutate our cached spec by
// retaining a handle to their input struct.
c := *connect
cp.Connect = &c
}
return &cp
}
// LanguageIDForPath returns the LSP `languageId` for the given file
// path using the preferred spec's mapping. Returns an empty string
// when no spec covers the extension.
func LanguageIDForPath(path string) string {
spec := SpecForPath(path)
if spec == nil {
return ""
}
ext := strings.ToLower(filepath.Ext(path))
if id, ok := spec.LanguageIDs[ext]; ok {
return id
}
if len(spec.Languages) > 0 {
return spec.Languages[0]
}
return ""
}
// AllSpecs returns the canonical list of known specs in registration
// order.
func AllSpecs() []*ServerSpec {
out := make([]*ServerSpec, 0, len(Servers))
for i := range Servers {
out = append(out, &Servers[i])
}
return out
}