返回 DeepSeek-Reasonix
client.go
根目录 / internal / lsp / client.go
1 package lsp
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "os"
9 "os/exec"
10 "sync"
11 "time"
12
13 "reasonix/internal/proc"
14 "reasonix/internal/secrets"
15 )
16
17 // docState tracks what we last sent the server for a document, so ensureSynced
18 // can detect an out-of-band disk edit (any tool, including bash) by stat alone.
19 type docState struct {
20 version int
21 size int64
22 mod time.Time
23 }
24
25 type client struct {
26 cmd *exec.Cmd
27 conn *conn
28 root string
29 langID string
30 posEnc string
31
32 mu sync.Mutex
33 docs map[string]*docState
34 diags map[string][]Diagnostic
35 diagVer map[string]int
36 }
37
38 // Diagnostic is one published problem for a document.
39 type Diagnostic struct {
40 Range Range `json:"range"`
41 Severity int `json:"severity"`
42 Message string `json:"message"`
43 Source string `json:"source"`
44 }
45
46 func startClient(ctx context.Context, bin string, args []string, env map[string]string, langID, root string) (*client, error) {
47 cmd := exec.CommandContext(ctx, bin, args...)
48 proc.HideWindow(cmd)
49 cmd.Dir = root
50 cmd.Env = append(secrets.ProcessEnv(), envSlice(env)...)
51 cmd.Stderr = io.Discard
52
53 stdin, err := cmd.StdinPipe()
54 if err != nil {
55 return nil, err
56 }
57 stdout, err := cmd.StdoutPipe()
58 if err != nil {
59 return nil, err
60 }
61 if err := cmd.Start(); err != nil {
62 return nil, err
63 }
64
65 c := &client{
66 cmd: cmd,
67 root: root,
68 langID: langID,
69 docs: map[string]*docState{},
70 diags: map[string][]Diagnostic{},
71 diagVer: map[string]int{},
72 }
73 c.conn = newConn(stdin, stdout, c.handleNotify, c.handleRequest)
74 initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
75 defer cancel()
76 if err := c.initialize(initCtx); err != nil {
77 c.close()
78 return nil, err
79 }
80 return c, nil
81 }
82
83 func (c *client) initialize(ctx context.Context) error {
84 params := map[string]any{
85 "processId": os.Getpid(),
86 "rootUri": pathToURI(c.root),
87 "capabilities": map[string]any{
88 "general": map[string]any{
89 "positionEncodings": []string{encodingUTF8, encodingUTF16},
90 },
91 "textDocument": map[string]any{
92 "publishDiagnostics": map[string]any{"versionSupport": true},
93 "hover": map[string]any{"contentFormat": []string{"plaintext", "markdown"}},
94 },
95 },
96 }
97 res, err := c.conn.call(ctx, "initialize", params)
98 if err != nil {
99 return err
100 }
101 var r struct {
102 Capabilities struct {
103 PositionEncoding string `json:"positionEncoding"`
104 } `json:"capabilities"`
105 }
106 _ = json.Unmarshal(res, &r)
107 c.posEnc = r.Capabilities.PositionEncoding
108 if c.posEnc == "" {
109 c.posEnc = encodingUTF16
110 }
111 return c.conn.notify("initialized", map[string]any{})
112 }
113
114 // handleNotify caches diagnostics. The version guards waitDiagnostics against
115 // returning problems computed for pre-edit content.
116 func (c *client) handleNotify(method string, params json.RawMessage) {
117 if method != "textDocument/publishDiagnostics" {
118 return
119 }
120 var p struct {
121 URI string `json:"uri"`
122 Version *int `json:"version"`
123 Diagnostics []Diagnostic `json:"diagnostics"`
124 }
125 if json.Unmarshal(params, &p) != nil {
126 return
127 }
128 c.mu.Lock()
129 c.diags[p.URI] = p.Diagnostics
130 if p.Version != nil {
131 c.diagVer[p.URI] = *p.Version
132 } else if d := c.docs[p.URI]; d != nil {
133 c.diagVer[p.URI] = d.version
134 }
135 c.mu.Unlock()
136 }
137
138 // handleRequest answers the server→client requests that block initialization on
139 // some servers (rust-analyzer stalls without a workspace/configuration reply).
140 func (c *client) handleRequest(id int64, method string, params json.RawMessage) {
141 switch method {
142 case "workspace/configuration":
143 var p struct {
144 Items []json.RawMessage `json:"items"`
145 }
146 _ = json.Unmarshal(params, &p)
147 _ = c.conn.reply(id, make([]any, len(p.Items)))
148 default:
149 _ = c.conn.reply(id, nil)
150 }
151 }
152
153 func (c *client) ensureSynced(uri, path string) error {
154 fi, err := os.Stat(path)
155 if err != nil {
156 return err
157 }
158 c.mu.Lock()
159 d, open := c.docs[uri]
160 c.mu.Unlock()
161 if open && fi.Size() == d.size && fi.ModTime().Equal(d.mod) {
162 return nil
163 }
164 content, err := os.ReadFile(path)
165 if err != nil {
166 return err
167 }
168 if !open {
169 err = c.conn.notify("textDocument/didOpen", map[string]any{
170 "textDocument": map[string]any{
171 "uri": uri, "languageId": c.langID, "version": 1, "text": string(content),
172 },
173 })
174 c.mu.Lock()
175 c.docs[uri] = &docState{version: 1, size: fi.Size(), mod: fi.ModTime()}
176 c.mu.Unlock()
177 return err
178 }
179 ver := d.version + 1
180 err = c.conn.notify("textDocument/didChange", map[string]any{
181 "textDocument": map[string]any{"uri": uri, "version": ver},
182 "contentChanges": []any{map[string]any{"text": string(content)}},
183 })
184 c.mu.Lock()
185 c.docs[uri] = &docState{version: ver, size: fi.Size(), mod: fi.ModTime()}
186 c.mu.Unlock()
187 return err
188 }
189
190 func (c *client) docVersion(uri string) int {
191 c.mu.Lock()
192 defer c.mu.Unlock()
193 if d := c.docs[uri]; d != nil {
194 return d.version
195 }
196 return 0
197 }
198
199 // waitDiagnostics blocks until a publishDiagnostics for uri at version >= minVer
200 // arrives or the deadline elapses, returning the freshest cache either way.
201 func (c *client) waitDiagnostics(ctx context.Context, uri string, minVer int, deadline time.Duration) []Diagnostic {
202 end := time.Now().Add(deadline)
203 for {
204 c.mu.Lock()
205 ver, d := c.diagVer[uri], c.diags[uri]
206 c.mu.Unlock()
207 if ver >= minVer || time.Now().After(end) {
208 return d
209 }
210 select {
211 case <-ctx.Done():
212 return d
213 case <-time.After(40 * time.Millisecond):
214 }
215 }
216 }
217
218 // callRetry retries a request while the server answers ContentModified (-32801),
219 // which means it is mid-reindex and the state is in flux. A short bounded retry
220 // hides the brief window after a didOpen/didChange; a longer reindex still
221 // surfaces so the caller can decide (see Manager, which turns it into a
222 // retry-shortly message).
223 func (c *client) callRetry(ctx context.Context, method string, params any) (json.RawMessage, error) {
224 const attempts = 5
225 for i := 0; ; i++ {
226 raw, err := c.conn.call(ctx, method, params)
227 if err == nil || i >= attempts || !isContentModified(err) {
228 return raw, err
229 }
230 select {
231 case <-ctx.Done():
232 return nil, ctx.Err()
233 case <-time.After(400 * time.Millisecond):
234 }
235 }
236 }
237
238 func isContentModified(err error) bool {
239 var e *rpcError
240 return errors.As(err, &e) && e.Code == -32801
241 }
242
243 func (c *client) query(ctx context.Context, method, uri string, pos Position) (json.RawMessage, error) {
244 return c.callRetry(ctx, method, map[string]any{
245 "textDocument": map[string]any{"uri": uri},
246 "position": pos,
247 })
248 }
249
250 func (c *client) references(ctx context.Context, uri string, pos Position) (json.RawMessage, error) {
251 return c.callRetry(ctx, "textDocument/references", map[string]any{
252 "textDocument": map[string]any{"uri": uri},
253 "position": pos,
254 "context": map[string]any{"includeDeclaration": true},
255 })
256 }
257
258 func (c *client) close() {
259 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
260 defer cancel()
261 _, _ = c.conn.call(ctx, "shutdown", nil)
262 _ = c.conn.notify("exit", nil)
263 if c.cmd.Process != nil {
264 _ = c.cmd.Process.Kill()
265 }
266 _ = c.cmd.Wait()
267 }
268
269 func envSlice(env map[string]string) []string {
270 out := make([]string, 0, len(env))
271 for k, v := range env {
272 out = append(out, k+"="+v)
273 }
274 return out
275 }
276
276 lines GO