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

206 lines
4.9 KiB
Go

// Package lsp is a minimal Language Server Protocol client: it spawns a language
// server per language on demand, syncs queried documents from disk, and adapts a
// few read-only capabilities (definition, references, hover, diagnostics) to the
// tool.Tool interface. Servers are not bundled — they resolve on PATH and a clear
// install hint is returned when one is missing.
package lsp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"sync"
)
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *rpcError) Error() string { return fmt.Sprintf("lsp error %d: %s", e.Code, e.Message) }
type outMsg struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params any `json:"params,omitempty"`
Result any `json:"result,omitempty"`
}
type inMsg struct {
ID *int64 `json:"id"`
Method string `json:"method"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
Params json.RawMessage `json:"params"`
}
// conn speaks LSP framing (Content-Length headers) over a subprocess's
// stdin/stdout. A single read-pump goroutine demultiplexes the stream:
// id-bearing responses wake the matching call; method-only messages are
// notifications (diagnostics); id+method messages are server→client requests
// that must be answered or some servers stall during initialize.
type conn struct {
w io.Writer
writeMu sync.Mutex
mu sync.Mutex
nextID int64
pending map[int64]chan inMsg
onNotify func(method string, params json.RawMessage)
onRequest func(id int64, method string, params json.RawMessage)
closeOnce sync.Once
closed chan struct{}
err error
}
func newConn(w io.Writer, r io.Reader,
onNotify func(string, json.RawMessage),
onRequest func(int64, string, json.RawMessage)) *conn {
c := &conn{
w: w,
pending: map[int64]chan inMsg{},
onNotify: onNotify,
onRequest: onRequest,
closed: make(chan struct{}),
}
go c.readLoop(r)
return c
}
func (c *conn) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
c.nextID++
id := c.nextID
ch := make(chan inMsg, 1)
c.pending[id] = ch
c.mu.Unlock()
defer func() {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}()
if err := c.writeMsg(outMsg{JSONRPC: "2.0", ID: &id, Method: method, Params: params}); err != nil {
return nil, err
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.closed:
return nil, c.err
case m := <-ch:
if m.Error != nil {
return nil, m.Error
}
return m.Result, nil
}
}
func (c *conn) notify(method string, params any) error {
return c.writeMsg(outMsg{JSONRPC: "2.0", Method: method, Params: params})
}
func (c *conn) reply(id int64, result any) error {
return c.writeMsg(outMsg{JSONRPC: "2.0", ID: &id, Result: result})
}
func (c *conn) writeMsg(v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if _, err := fmt.Fprintf(c.w, "Content-Length: %d\r\n\r\n", len(b)); err != nil {
return err
}
_, err = c.w.Write(b)
return err
}
func (c *conn) readLoop(r io.Reader) {
br := bufio.NewReader(r)
for {
body, err := readFrame(br)
if err != nil {
c.fail(err)
return
}
var m inMsg
if json.Unmarshal(body, &m) != nil {
continue
}
switch {
case m.Method != "" && m.ID != nil:
if c.onRequest != nil {
c.onRequest(*m.ID, m.Method, m.Params)
}
case m.Method != "":
if c.onNotify != nil {
c.onNotify(m.Method, m.Params)
}
case m.ID != nil:
c.mu.Lock()
ch := c.pending[*m.ID]
c.mu.Unlock()
if ch != nil {
ch <- m
}
}
}
}
func (c *conn) fail(err error) {
c.closeOnce.Do(func() {
c.err = err
close(c.closed)
})
}
// maxFrameBytes caps a single LSP message body. Generous for any real response
// (document symbols, semantic tokens for a huge file) while stopping a corrupt or
// desynced Content-Length from triggering an unbounded allocation that OOMs the
// whole process.
const maxFrameBytes = 64 << 20 // 64 MiB
// readFrame reads one LSP message: header lines terminated by a blank line, then
// exactly Content-Length bytes of JSON body.
func readFrame(r *bufio.Reader) ([]byte, error) {
n := -1
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
parsed, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return nil, fmt.Errorf("bad Content-Length %q: %w", v, err)
}
n = parsed
}
}
if n < 0 {
return nil, fmt.Errorf("missing Content-Length header")
}
if n > maxFrameBytes {
return nil, fmt.Errorf("Content-Length %d exceeds the %d-byte frame cap", n, maxFrameBytes)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf, nil
}