package grammar import ( "bufio" "encoding/json" "io" "os" "os/exec" "strings" "sync" ) // binary is the harper-ls executable name; a var so tests can point it elsewhere. var binary = "harper-ls" // 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 nextID int 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, 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.writeMu.Lock() c.nextID++ id := c.nextID c.writeMu.Unlock() c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) return id } 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"` } if json.Unmarshal(body, &m) != nil { continue } switch { case m.Method == "" && len(m.ID) > 0: // response to one of our requests 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) } } } 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) result := make([]any, len(p.Items)) for i := range result { result[i] = 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: } } } }