package lsp import ( "context" "encoding/json" "errors" "io" "os" "os/exec" "sync" "time" "reasonix/internal/proc" "reasonix/internal/secrets" ) // docState tracks what we last sent the server for a document, so ensureSynced // can detect an out-of-band disk edit (any tool, including bash) by stat alone. type docState struct { version int size int64 mod time.Time } type client struct { cmd *exec.Cmd conn *conn root string langID string posEnc string mu sync.Mutex docs map[string]*docState diags map[string][]Diagnostic diagVer map[string]int } // Diagnostic is one published problem for a document. type Diagnostic struct { Range Range `json:"range"` Severity int `json:"severity"` Message string `json:"message"` Source string `json:"source"` } func startClient(ctx context.Context, bin string, args []string, env map[string]string, langID, root string) (*client, error) { cmd := exec.CommandContext(ctx, bin, args...) proc.HideWindow(cmd) cmd.Dir = root cmd.Env = append(secrets.ProcessEnv(), envSlice(env)...) cmd.Stderr = io.Discard stdin, err := cmd.StdinPipe() if err != nil { return nil, err } stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } if err := cmd.Start(); err != nil { return nil, err } c := &client{ cmd: cmd, root: root, langID: langID, docs: map[string]*docState{}, diags: map[string][]Diagnostic{}, diagVer: map[string]int{}, } c.conn = newConn(stdin, stdout, c.handleNotify, c.handleRequest) initCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() if err := c.initialize(initCtx); err != nil { c.close() return nil, err } return c, nil } func (c *client) initialize(ctx context.Context) error { params := map[string]any{ "processId": os.Getpid(), "rootUri": pathToURI(c.root), "capabilities": map[string]any{ "general": map[string]any{ "positionEncodings": []string{encodingUTF8, encodingUTF16}, }, "textDocument": map[string]any{ "publishDiagnostics": map[string]any{"versionSupport": true}, "hover": map[string]any{"contentFormat": []string{"plaintext", "markdown"}}, }, }, } res, err := c.conn.call(ctx, "initialize", params) if err != nil { return err } var r struct { Capabilities struct { PositionEncoding string `json:"positionEncoding"` } `json:"capabilities"` } _ = json.Unmarshal(res, &r) c.posEnc = r.Capabilities.PositionEncoding if c.posEnc == "" { c.posEnc = encodingUTF16 } return c.conn.notify("initialized", map[string]any{}) } // handleNotify caches diagnostics. The version guards waitDiagnostics against // returning problems computed for pre-edit content. func (c *client) handleNotify(method string, params json.RawMessage) { if method != "textDocument/publishDiagnostics" { return } var p struct { URI string `json:"uri"` Version *int `json:"version"` Diagnostics []Diagnostic `json:"diagnostics"` } if json.Unmarshal(params, &p) != nil { return } c.mu.Lock() c.diags[p.URI] = p.Diagnostics if p.Version != nil { c.diagVer[p.URI] = *p.Version } else if d := c.docs[p.URI]; d != nil { c.diagVer[p.URI] = d.version } c.mu.Unlock() } // handleRequest answers the server→client requests that block initialization on // some servers (rust-analyzer stalls without a workspace/configuration reply). func (c *client) handleRequest(id int64, method string, params json.RawMessage) { switch method { case "workspace/configuration": var p struct { Items []json.RawMessage `json:"items"` } _ = json.Unmarshal(params, &p) _ = c.conn.reply(id, make([]any, len(p.Items))) default: _ = c.conn.reply(id, nil) } } func (c *client) ensureSynced(uri, path string) error { fi, err := os.Stat(path) if err != nil { return err } c.mu.Lock() d, open := c.docs[uri] c.mu.Unlock() if open && fi.Size() == d.size && fi.ModTime().Equal(d.mod) { return nil } content, err := os.ReadFile(path) if err != nil { return err } if !open { err = c.conn.notify("textDocument/didOpen", map[string]any{ "textDocument": map[string]any{ "uri": uri, "languageId": c.langID, "version": 1, "text": string(content), }, }) c.mu.Lock() c.docs[uri] = &docState{version: 1, size: fi.Size(), mod: fi.ModTime()} c.mu.Unlock() return err } ver := d.version + 1 err = c.conn.notify("textDocument/didChange", map[string]any{ "textDocument": map[string]any{"uri": uri, "version": ver}, "contentChanges": []any{map[string]any{"text": string(content)}}, }) c.mu.Lock() c.docs[uri] = &docState{version: ver, size: fi.Size(), mod: fi.ModTime()} c.mu.Unlock() return err } func (c *client) docVersion(uri string) int { c.mu.Lock() defer c.mu.Unlock() if d := c.docs[uri]; d != nil { return d.version } return 0 } // waitDiagnostics blocks until a publishDiagnostics for uri at version >= minVer // arrives or the deadline elapses, returning the freshest cache either way. func (c *client) waitDiagnostics(ctx context.Context, uri string, minVer int, deadline time.Duration) []Diagnostic { end := time.Now().Add(deadline) for { c.mu.Lock() ver, d := c.diagVer[uri], c.diags[uri] c.mu.Unlock() if ver >= minVer || time.Now().After(end) { return d } select { case <-ctx.Done(): return d case <-time.After(40 * time.Millisecond): } } } // callRetry retries a request while the server answers ContentModified (-32801), // which means it is mid-reindex and the state is in flux. A short bounded retry // hides the brief window after a didOpen/didChange; a longer reindex still // surfaces so the caller can decide (see Manager, which turns it into a // retry-shortly message). func (c *client) callRetry(ctx context.Context, method string, params any) (json.RawMessage, error) { const attempts = 5 for i := 0; ; i++ { raw, err := c.conn.call(ctx, method, params) if err == nil || i >= attempts || !isContentModified(err) { return raw, err } select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(400 * time.Millisecond): } } } func isContentModified(err error) bool { var e *rpcError return errors.As(err, &e) && e.Code == -32801 } func (c *client) query(ctx context.Context, method, uri string, pos Position) (json.RawMessage, error) { return c.callRetry(ctx, method, map[string]any{ "textDocument": map[string]any{"uri": uri}, "position": pos, }) } func (c *client) references(ctx context.Context, uri string, pos Position) (json.RawMessage, error) { return c.callRetry(ctx, "textDocument/references", map[string]any{ "textDocument": map[string]any{"uri": uri}, "position": pos, "context": map[string]any{"includeDeclaration": true}, }) } func (c *client) close() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _, _ = c.conn.call(ctx, "shutdown", nil) _ = c.conn.notify("exit", nil) if c.cmd.Process != nil { _ = c.cmd.Process.Kill() } _ = c.cmd.Wait() } func envSlice(env map[string]string) []string { out := make([]string, 0, len(env)) for k, v := range env { out = append(out, k+"="+v) } return out }