| 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 |