| 1 | package lsp |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "sort" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // ServerSpec declares how to launch one language server. Command resolves on PATH |
| 16 | // (the binary is never bundled); Fallbacks are alternate executable names tried |
| 17 | // when Command is missing. InstallHint is surfaced when none are found. Extensions |
| 18 | // are the file suffixes (".go", ".rs") this server handles, so a config-only entry |
| 19 | // can add a new language without any code change. |
| 20 | type ServerSpec struct { |
| 21 | Command string |
| 22 | Args []string |
| 23 | Env map[string]string |
| 24 | LanguageID string |
| 25 | Extensions []string |
| 26 | Fallbacks []string |
| 27 | InstallHint string |
| 28 | } |
| 29 | |
| 30 | // Manager owns the lazily-spawned language servers for a session. Servers start |
| 31 | // on first query for their language and are reused; the session-scoped context |
| 32 | // (cancelled by Close) bounds their lifetime, not a single turn. |
| 33 | type Manager struct { |
| 34 | root context.Context |
| 35 | cancel context.CancelFunc |
| 36 | wsRoot string |
| 37 | specs map[string]ServerSpec |
| 38 | extIndex map[string]string // file extension → language key, derived from specs |
| 39 | |
| 40 | mu sync.Mutex |
| 41 | clients map[string]*client |
| 42 | starting map[string]chan struct{} |
| 43 | } |
| 44 | |
| 45 | func NewManager(wsRoot string, specs map[string]ServerSpec) *Manager { |
| 46 | root, cancel := context.WithCancel(context.Background()) |
| 47 | extIndex := map[string]string{} |
| 48 | for lang, spec := range specs { |
| 49 | for _, ext := range spec.Extensions { |
| 50 | extIndex[strings.ToLower(ext)] = lang |
| 51 | } |
| 52 | } |
| 53 | return &Manager{ |
| 54 | root: root, |
| 55 | cancel: cancel, |
| 56 | wsRoot: wsRoot, |
| 57 | specs: specs, |
| 58 | extIndex: extIndex, |
| 59 | clients: map[string]*client{}, |
| 60 | starting: map[string]chan struct{}{}, |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func (m *Manager) Close() { |
| 65 | m.mu.Lock() |
| 66 | cs := make([]*client, 0, len(m.clients)) |
| 67 | for _, c := range m.clients { |
| 68 | cs = append(cs, c) |
| 69 | } |
| 70 | m.clients = map[string]*client{} |
| 71 | m.mu.Unlock() |
| 72 | for _, c := range cs { |
| 73 | c.close() |
| 74 | } |
| 75 | m.cancel() |
| 76 | } |
| 77 | |
| 78 | // DefaultSpecs maps a language key to its conventional server. Commands are tried |
| 79 | // on PATH; nothing here ships with reasonix. Extensions drive file routing, so a |
| 80 | // user can override any entry or add a new language entirely from config. |
| 81 | func DefaultSpecs() map[string]ServerSpec { |
| 82 | return map[string]ServerSpec{ |
| 83 | "go": {Command: "gopls", LanguageID: "go", Extensions: []string{".go"}, InstallHint: "go install golang.org/x/tools/gopls@latest"}, |
| 84 | "rust": {Command: "rust-analyzer", LanguageID: "rust", Extensions: []string{".rs"}, InstallHint: "rustup component add rust-analyzer"}, |
| 85 | "typescript": {Command: "typescript-language-server", Args: []string{"--stdio"}, LanguageID: "typescript", Extensions: []string{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}, InstallHint: "npm i -g typescript-language-server typescript"}, |
| 86 | "python": {Command: "pyright-langserver", Args: []string{"--stdio"}, LanguageID: "python", Extensions: []string{".py", ".pyi"}, InstallHint: "npm i -g pyright"}, |
| 87 | "cpp": {Command: "clangd", LanguageID: "cpp", Extensions: []string{".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx"}, InstallHint: "install clangd (LLVM): apt install clangd / brew install llvm / scoop install llvm"}, |
| 88 | "csharp": {Command: "csharp-ls", LanguageID: "csharp", Extensions: []string{".cs"}, InstallHint: "dotnet tool install --global csharp-ls"}, |
| 89 | "java": {Command: "jdtls", LanguageID: "java", Extensions: []string{".java"}, InstallHint: "install eclipse.jdt.ls (jdtls): brew install jdtls / from the JDT-LS releases"}, |
| 90 | "ruby": {Command: "ruby-lsp", LanguageID: "ruby", Extensions: []string{".rb"}, InstallHint: "gem install ruby-lsp"}, |
| 91 | "php": {Command: "intelephense", Args: []string{"--stdio"}, LanguageID: "php", Extensions: []string{".php"}, InstallHint: "npm i -g intelephense"}, |
| 92 | "lua": {Command: "lua-language-server", LanguageID: "lua", Extensions: []string{".lua"}, InstallHint: "install lua-language-server: brew install lua-language-server / scoop install lua-language-server"}, |
| 93 | "bash": {Command: "bash-language-server", Args: []string{"start"}, LanguageID: "shellscript", Extensions: []string{".sh", ".bash"}, InstallHint: "npm i -g bash-language-server"}, |
| 94 | "zig": {Command: "zls", LanguageID: "zig", Extensions: []string{".zig"}, InstallHint: "install zls (ziglang/zls) matching your zig version"}, |
| 95 | "kotlin": {Command: "kotlin-lsp", Fallbacks: []string{"intellij-server"}, Args: []string{"--stdio"}, LanguageID: "kotlin", Extensions: []string{".kt", ".kts"}, InstallHint: "install JetBrains Kotlin/kotlin-lsp: macOS: brew install JetBrains/utils/kotlin-lsp; Linux: download the standalone zip, chmod +x kotlin-lsp.sh, and symlink it as kotlin-lsp on PATH; Windows: download the standalone zip and add its bin directory containing intellij-server.exe to PATH"}, |
| 96 | "swift": {Command: "sourcekit-lsp", LanguageID: "swift", Extensions: []string{".swift"}, InstallHint: "ships with the Swift toolchain (swift.org/download)"}, |
| 97 | "haskell": {Command: "haskell-language-server-wrapper", Args: []string{"--lsp"}, LanguageID: "haskell", Extensions: []string{".hs"}, InstallHint: "install via ghcup: ghcup install hls"}, |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // notInstalledError carries the install hint so a tool can tell the model exactly |
| 102 | // how to make the capability available. |
| 103 | type notInstalledError struct { |
| 104 | command string |
| 105 | hint string |
| 106 | } |
| 107 | |
| 108 | func (e *notInstalledError) Error() string { |
| 109 | return fmt.Sprintf("language server %q not found on PATH. Install it: %s", e.command, e.hint) |
| 110 | } |
| 111 | |
| 112 | func (m *Manager) abs(p string) string { |
| 113 | if filepath.IsAbs(p) { |
| 114 | return filepath.Clean(p) |
| 115 | } |
| 116 | return filepath.Join(m.wsRoot, p) |
| 117 | } |
| 118 | |
| 119 | // resolve returns the running client for the file's language, spawning it on |
| 120 | // first use. Concurrent first-use calls (a parallel read-only tool batch) share |
| 121 | // one spawn via the starting gate instead of launching duplicate servers. |
| 122 | func (m *Manager) resolve(path string) (*client, error) { |
| 123 | lang := m.extIndex[strings.ToLower(filepath.Ext(path))] |
| 124 | if lang == "" { |
| 125 | return nil, fmt.Errorf("no language server configured for %s", filepath.Ext(path)) |
| 126 | } |
| 127 | spec, ok := m.specs[lang] |
| 128 | if !ok || spec.Command == "" { |
| 129 | return nil, fmt.Errorf("no language server configured for %s files", lang) |
| 130 | } |
| 131 | |
| 132 | m.mu.Lock() |
| 133 | if c := m.clients[lang]; c != nil { |
| 134 | m.mu.Unlock() |
| 135 | return c, nil |
| 136 | } |
| 137 | if ch := m.starting[lang]; ch != nil { |
| 138 | m.mu.Unlock() |
| 139 | <-ch |
| 140 | return m.resolve(path) |
| 141 | } |
| 142 | ch := make(chan struct{}) |
| 143 | m.starting[lang] = ch |
| 144 | m.mu.Unlock() |
| 145 | |
| 146 | c, err := m.spawn(lang, spec) |
| 147 | |
| 148 | m.mu.Lock() |
| 149 | delete(m.starting, lang) |
| 150 | if err == nil { |
| 151 | m.clients[lang] = c |
| 152 | } |
| 153 | close(ch) |
| 154 | m.mu.Unlock() |
| 155 | return c, err |
| 156 | } |
| 157 | |
| 158 | // resolveCommand returns the first spec executable found on PATH, trying |
| 159 | // Command then Fallbacks so installers that expose different names (Homebrew's |
| 160 | // kotlin-lsp vs the Windows zip's intellij-server.exe) both work out of the box. |
| 161 | func resolveCommand(spec ServerSpec) (string, error) { |
| 162 | for _, name := range append([]string{spec.Command}, spec.Fallbacks...) { |
| 163 | if name == "" { |
| 164 | continue |
| 165 | } |
| 166 | if bin, err := exec.LookPath(name); err == nil { |
| 167 | return bin, nil |
| 168 | } |
| 169 | } |
| 170 | return "", ¬InstalledError{command: spec.Command, hint: spec.InstallHint} |
| 171 | } |
| 172 | |
| 173 | func (m *Manager) spawn(_ string, spec ServerSpec) (*client, error) { |
| 174 | bin, err := resolveCommand(spec) |
| 175 | if err != nil { |
| 176 | return nil, err |
| 177 | } |
| 178 | return startClient(m.root, bin, spec.Args, spec.Env, spec.LanguageID, m.wsRoot) |
| 179 | } |
| 180 | |
| 181 | func (m *Manager) prepare(ctx context.Context, file string, line int, symbol string) (*client, string, Position, error) { |
| 182 | path := m.abs(file) |
| 183 | c, err := m.resolve(path) |
| 184 | if err != nil { |
| 185 | return nil, "", Position{}, err |
| 186 | } |
| 187 | uri := pathToURI(path) |
| 188 | if err := c.ensureSynced(uri, path); err != nil { |
| 189 | return nil, "", Position{}, err |
| 190 | } |
| 191 | content, err := os.ReadFile(path) |
| 192 | if err != nil { |
| 193 | return nil, "", Position{}, err |
| 194 | } |
| 195 | pos, err := locate(string(content), line, symbol, c.posEnc) |
| 196 | if err != nil { |
| 197 | return nil, "", Position{}, err |
| 198 | } |
| 199 | return c, uri, pos, nil |
| 200 | } |
| 201 | |
| 202 | func (m *Manager) Definition(ctx context.Context, file string, line int, symbol string) (string, error) { |
| 203 | c, uri, pos, err := m.prepare(ctx, file, line, symbol) |
| 204 | if err != nil { |
| 205 | return "", err |
| 206 | } |
| 207 | raw, err := c.query(ctx, "textDocument/definition", uri, pos) |
| 208 | if err != nil { |
| 209 | return indexingOr(err) |
| 210 | } |
| 211 | return m.formatLocations("definition", parseLocations(raw)), nil |
| 212 | } |
| 213 | |
| 214 | // indexingOr turns a persistent ContentModified into a retry-shortly message the |
| 215 | // model can act on, leaving any other error to surface. |
| 216 | func indexingOr(err error) (string, error) { |
| 217 | if isContentModified(err) { |
| 218 | return "the language server is still indexing this workspace — run the query again in a few seconds", nil |
| 219 | } |
| 220 | return "", err |
| 221 | } |
| 222 | |
| 223 | func (m *Manager) References(ctx context.Context, file string, line int, symbol string) (string, error) { |
| 224 | c, uri, pos, err := m.prepare(ctx, file, line, symbol) |
| 225 | if err != nil { |
| 226 | return "", err |
| 227 | } |
| 228 | raw, err := c.references(ctx, uri, pos) |
| 229 | if err != nil { |
| 230 | return indexingOr(err) |
| 231 | } |
| 232 | return m.formatLocations("reference", parseLocations(raw)), nil |
| 233 | } |
| 234 | |
| 235 | func (m *Manager) Hover(ctx context.Context, file string, line int, symbol string) (string, error) { |
| 236 | c, uri, pos, err := m.prepare(ctx, file, line, symbol) |
| 237 | if err != nil { |
| 238 | return "", err |
| 239 | } |
| 240 | raw, err := c.query(ctx, "textDocument/hover", uri, pos) |
| 241 | if err != nil { |
| 242 | return indexingOr(err) |
| 243 | } |
| 244 | h := parseHover(raw) |
| 245 | if h == "" { |
| 246 | return "no hover information", nil |
| 247 | } |
| 248 | return h, nil |
| 249 | } |
| 250 | |
| 251 | func (m *Manager) Diagnostics(ctx context.Context, file string) (string, error) { |
| 252 | path := m.abs(file) |
| 253 | c, err := m.resolve(path) |
| 254 | if err != nil { |
| 255 | return "", err |
| 256 | } |
| 257 | uri := pathToURI(path) |
| 258 | if err := c.ensureSynced(uri, path); err != nil { |
| 259 | return "", err |
| 260 | } |
| 261 | diags := c.waitDiagnostics(ctx, uri, c.docVersion(uri), 2*time.Second) |
| 262 | return formatDiagnostics(m.rel(path), diags), nil |
| 263 | } |
| 264 | |
| 265 | func (m *Manager) rel(path string) string { |
| 266 | if r, err := filepath.Rel(m.wsRoot, path); err == nil && !strings.HasPrefix(r, "..") { |
| 267 | return filepath.ToSlash(r) |
| 268 | } |
| 269 | return path |
| 270 | } |
| 271 | |
| 272 | func (m *Manager) formatLocations(kind string, locs []Location) string { |
| 273 | if len(locs) == 0 { |
| 274 | return "no " + kind + " found" |
| 275 | } |
| 276 | sort.Slice(locs, func(i, j int) bool { |
| 277 | if locs[i].URI != locs[j].URI { |
| 278 | return locs[i].URI < locs[j].URI |
| 279 | } |
| 280 | return locs[i].Range.Start.Line < locs[j].Range.Start.Line |
| 281 | }) |
| 282 | var b strings.Builder |
| 283 | fmt.Fprintf(&b, "%d %s(s):\n", len(locs), kind) |
| 284 | for _, l := range locs { |
| 285 | line := l.Range.Start.Line + 1 |
| 286 | p, err := uriToPath(l.URI) |
| 287 | if err != nil { |
| 288 | fmt.Fprintf(&b, "%s:%d", l.URI, line) |
| 289 | } else { |
| 290 | fmt.Fprintf(&b, "%s:%d", m.rel(p), line) |
| 291 | if snippet := readLine(p, l.Range.Start.Line); snippet != "" { |
| 292 | fmt.Fprintf(&b, " %s", snippet) |
| 293 | } |
| 294 | } |
| 295 | b.WriteByte('\n') |
| 296 | } |
| 297 | return strings.TrimRight(b.String(), "\n") |
| 298 | } |
| 299 | |
| 300 | func readLine(path string, line0 int) string { |
| 301 | content, err := os.ReadFile(path) |
| 302 | if err != nil { |
| 303 | return "" |
| 304 | } |
| 305 | lines := strings.Split(string(content), "\n") |
| 306 | if line0 < 0 || line0 >= len(lines) { |
| 307 | return "" |
| 308 | } |
| 309 | return strings.TrimSpace(lines[line0]) |
| 310 | } |
| 311 |