Files
2026-07-13 13:00:08 +08:00

276 lines
7.0 KiB
Go

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
}