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