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