package grammar import ( "bufio" "encoding/json" "errors" "io" "os" "os/exec" "strings" "sync" "time" ) // binary is the harper-ls executable name; a var so tests can point it elsewhere. var binary = "harper-ls" // codeActionTimeout bounds how long CodeActions waits for harper's reply. Harper is // a local subprocess, so a fix list normally returns in a few milliseconds; the cap // keeps a hung server from freezing the popup. const codeActionTimeout = 400 * time.Millisecond var ( errTimeout = errors.New("grammar: request timed out") errClosed = errors.New("grammar: client closed") errNoDoc = errors.New("grammar: document not open") ) // Available reports whether harper-ls is on PATH. When false, callers should skip // grammar entirely — no subprocess, no cost. func Available() bool { _, err := exec.LookPath(binary) return err == nil } // Client is a running harper-ls session reached over stdio. It tracks the text of // each open document so it can translate LSP diagnostic ranges (UTF-16) into // glint's rune columns, and publishes diagnostic batches on Diagnostics(). type Client struct { cmd *exec.Cmd stdin io.WriteCloser writeMu sync.Mutex pendMu sync.Mutex nextID int pending map[int]chan json.RawMessage // id -> reply channel for in-flight requests docMu sync.Mutex docs map[string]docState // uri -> latest text/version diags chan []Diag ready chan struct{} done chan struct{} } type docState struct { version int lines []string } // Start launches harper-ls, performs the LSP initialize handshake, and begins // serving diagnostics. The returned Client is ready for DidOpen once Start // returns. Callers should guard with Available first. func Start() (*Client, error) { cmd := exec.Command(binary, "--stdio") stdin, err := cmd.StdinPipe() if err != nil { return nil, err } stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } cmd.Stderr = nil // discard harper's log noise if err := cmd.Start(); err != nil { return nil, err } c := &Client{ cmd: cmd, stdin: stdin, pending: map[int]chan json.RawMessage{}, docs: map[string]docState{}, diags: make(chan []Diag, 1), ready: make(chan struct{}), done: make(chan struct{}), } go c.readLoop(bufio.NewReader(stdout)) c.initialize() return c, nil } // Diagnostics is the stream of diagnostic batches, one per harper // publishDiagnostics for a tracked document, already mapped to rune columns. The // channel is buffered depth 1 and always holds the latest batch: a slow reader // never blocks the client, but never sees a stale batch either. func (c *Client) Diagnostics() <-chan []Diag { return c.diags } // DidOpen registers a document with harper and requests its first diagnostics. func (c *Client) DidOpen(path, text string) { uri := pathToURI(path) c.setDoc(uri, 1, text) c.notify("textDocument/didOpen", map[string]any{ "textDocument": map[string]any{ "uri": uri, "languageId": "markdown", "version": 1, "text": text, }, }) } // DidChange sends the full new text for an open document (harper uses Full sync), // bumping its version so diagnostics refresh. func (c *Client) DidChange(path, text string) { uri := pathToURI(path) ver := c.bumpDoc(uri, text) c.notify("textDocument/didChange", map[string]any{ "textDocument": map[string]any{"uri": uri, "version": ver}, "contentChanges": []any{map[string]any{"text": text}}, }) } // DidClose stops diagnostics for a document (e.g. when switching files). func (c *Client) DidClose(path string) { uri := pathToURI(path) c.docMu.Lock() delete(c.docs, uri) c.docMu.Unlock() c.notify("textDocument/didClose", map[string]any{ "textDocument": map[string]any{"uri": uri}, }) } // Close shuts the subprocess down. Best-effort: it asks harper to exit, closes // stdin, then kills and reaps the process asynchronously so Close never blocks // and leaves no zombie behind. func (c *Client) Close() error { c.notify("exit", nil) _ = c.stdin.Close() err := c.cmd.Process.Kill() go func() { _ = c.cmd.Wait() }() // reap the killed process return err } // --- document bookkeeping ------------------------------------------------- func (c *Client) setDoc(uri string, version int, text string) { c.docMu.Lock() c.docs[uri] = docState{version: version, lines: strings.Split(text, "\n")} c.docMu.Unlock() } func (c *Client) bumpDoc(uri, text string) int { c.docMu.Lock() defer c.docMu.Unlock() d := c.docs[uri] d.version++ if d.version < 1 { d.version = 1 } d.lines = strings.Split(text, "\n") c.docs[uri] = d return d.version } func (c *Client) docLines(uri string) ([]string, bool) { c.docMu.Lock() defer c.docMu.Unlock() d, ok := c.docs[uri] return d.lines, ok } // --- LSP wire ------------------------------------------------------------- func (c *Client) initialize() { root := "file://" + osGetwd() c.request("initialize", map[string]any{ "processId": os.Getpid(), "rootUri": root, "capabilities": map[string]any{ "workspace": map[string]any{"configuration": true}, "textDocument": map[string]any{"publishDiagnostics": map[string]any{}}, }, }) <-c.ready // block until harper answers initialize c.notify("initialized", map[string]any{}) } func (c *Client) request(method string, params any) int { c.pendMu.Lock() c.nextID++ id := c.nextID c.pendMu.Unlock() c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) return id } // requestWait sends a request and blocks for its response, up to timeout. It // registers a reply channel keyed by the request id before sending, so the read // loop can route the matching response back. Returns errTimeout on timeout and // errClosed if the client shuts down first. func (c *Client) requestWait(method string, params any, timeout time.Duration) (json.RawMessage, error) { c.pendMu.Lock() c.nextID++ id := c.nextID ch := make(chan json.RawMessage, 1) c.pending[id] = ch c.pendMu.Unlock() defer func() { c.pendMu.Lock() delete(c.pending, id) c.pendMu.Unlock() }() c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) select { case res := <-ch: return res, nil case <-time.After(timeout): return nil, errTimeout case <-c.done: return nil, errClosed } } // CodeActions asks harper for the fixes it offers on the grammar span covering rune // range [startRune,endRune) of the given line, returning only replacement actions // with their edits mapped to rune coordinates. The rune range is phrased as an LSP // (UTF-16) range using the client's tracked document text. A missing document, a // timeout, or a shutdown returns an error and no actions. func (c *Client) CodeActions(path string, line, startRune, endRune int) ([]Action, error) { uri := pathToURI(path) lines, ok := c.docLines(uri) if !ok { return nil, errNoDoc } ltext := "" if line >= 0 && line < len(lines) { ltext = lines[line] } rng := map[string]any{ "start": map[string]any{"line": line, "character": runeColToUTF16(ltext, startRune)}, "end": map[string]any{"line": line, "character": runeColToUTF16(ltext, endRune)}, } res, err := c.requestWait("textDocument/codeAction", map[string]any{ "textDocument": map[string]any{"uri": uri}, "range": rng, "context": map[string]any{"diagnostics": []any{}}, }, codeActionTimeout) if err != nil { return nil, err } return parseActions(res, lines) } func (c *Client) notify(method string, params any) { c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) } func (c *Client) respond(id json.RawMessage, result any) { c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result}) } func (c *Client) send(v map[string]any) { body, err := json.Marshal(v) if err != nil { return } c.writeMu.Lock() defer c.writeMu.Unlock() _ = writeFrame(c.stdin, body) } // readLoop parses harper's frames until the stream closes, answering the server // requests that gate diagnostics and forwarding publishDiagnostics onward. func (c *Client) readLoop(r *bufio.Reader) { defer close(c.done) initID := 1 // initialize is always the first request for { body, err := readFrame(r) if err != nil { return } var m struct { ID json.RawMessage `json:"id"` Method string `json:"method"` Params json.RawMessage `json:"params"` Result json.RawMessage `json:"result"` } if json.Unmarshal(body, &m) != nil { continue } switch { case m.Method == "" && len(m.ID) > 0: // response to one of our requests c.deliverResponse(m.ID, m.Result) if string(m.ID) == itoa(initID) { c.signalReady() } case len(m.ID) > 0: // server -> client request: must answer or diagnostics stall c.answerRequest(m.ID, m.Method, m.Params) case m.Method == "textDocument/publishDiagnostics": c.handleDiagnostics(m.Params) } } } // deliverResponse routes a response body to the goroutine waiting on its request id, // if one registered via requestWait. Fire-and-forget requests (initialize) have no // pending channel and are ignored here. func (c *Client) deliverResponse(rawID json.RawMessage, result json.RawMessage) { var id int if json.Unmarshal(rawID, &id) != nil { return } c.pendMu.Lock() ch, ok := c.pending[id] c.pendMu.Unlock() if ok { select { case ch <- result: default: } } } func (c *Client) signalReady() { select { case <-c.ready: default: close(c.ready) } } // answerRequest replies to the server-initiated requests harper needs before it // will emit diagnostics: workspace/configuration wants one config object per // requested item (empty = harper defaults); everything else gets a null result. func (c *Client) answerRequest(id json.RawMessage, method string, params json.RawMessage) { if method == "workspace/configuration" { var p struct { Items []json.RawMessage `json:"items"` } _ = json.Unmarshal(params, &p) // Each item must be wrapped under the "harper-ls" key; a bare {} makes harper // log `Settings must contain a "harper-ls" key.` and skip our config. An empty // inner object still means "use harper's defaults". result := make([]any, len(p.Items)) for i := range result { result[i] = map[string]any{"harper-ls": map[string]any{}} } c.respond(id, result) return } c.respond(id, nil) } func (c *Client) handleDiagnostics(params json.RawMessage) { var p struct { URI string `json:"uri"` Diagnostics []struct { Range struct { Start struct{ Line, Character int } `json:"start"` End struct{ Line, Character int } `json:"end"` } `json:"range"` Message string `json:"message"` Code any `json:"code"` } `json:"diagnostics"` } if json.Unmarshal(params, &p) != nil { return } lines, ok := c.docLines(p.URI) if !ok { return // diagnostics for a document we no longer track } var batch []Diag for _, d := range p.Diagnostics { batch = append(batch, lspRangeToDiags(lines, d.Range.Start.Line, d.Range.Start.Character, d.Range.End.Line, d.Range.End.Character, d.Message, codeString(d.Code))...) } c.publish(batch) } // publish delivers batch on the depth-1 channel, replacing any undrained batch so // the reader always gets the newest diagnostics and the read loop never blocks. func (c *Client) publish(batch []Diag) { for { select { case c.diags <- batch: return default: select { case <-c.diags: default: } } } }