package lsp import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "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 // 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 add its file extensions // to extToSpec 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", }, Priority: 5, Daemon: true, MaxParallel: 10, ProjectReady: tsProjectReady, }, { // tsgo is the native-Go TypeScript compiler (the // @typescript/native-preview package). Its LSP mode is offered // at a lower precedence than typescript-language-server so the // default routing is unchanged for anyone who has both — a // user opts in by raising its priority in .gortex.yaml. 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: 6, 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, // csharp-ls is a Roslyn stdio LSP (`dotnet tool install csharp-ls`) // that speaks plain LSP with no args and auto-discovers a .sln under // the workspace root. 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, }, } // extToSpec resolves a file extension (with leading dot, lower case) to // its preferred LSP spec. Built once at init from `Servers`. var extToSpec map[string]*ServerSpec // nameToSpec resolves a server name (e.g. "rust-analyzer") to its spec. var nameToSpec map[string]*ServerSpec func init() { extToSpec = 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) // First registration wins; entries earlier in `Servers` // take precedence when extensions overlap. (Used to keep // gopls authoritative for `.go` even if a future entry // claims it.) if _, exists := extToSpec[lower]; !exists { extToSpec[lower] = spec } } } // 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). Returns nil when no spec // covers the extension. func SpecForExtension(ext string) *ServerSpec { if ext == "" { return nil } if !strings.HasPrefix(ext, ".") { ext = "." + ext } return extToSpec[strings.ToLower(ext)] } // 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 }