1package main
2
3// Basic Language Server Protocol (LSP) client. Communicates with external
4// language servers (like gopls or clangd) via JSON-RPC over standard
5// input/output.
6
7import (
8 "bufio"
9 "encoding/json"
10 "fmt"
11 "io"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "sync"
17 "sync/atomic"
18 "time"
19
20 "github.com/nsf/termbox-go"
21)
22
23// LSPClient manages the lifecycle and communication with an LSP server process.
24type LSPClient struct {
25 cmd *exec.Cmd // The underlying server process.
26 stdin io.WriteCloser // Write messages to the server.
27 stdout io.ReadCloser // Read messages from the server.
28 scanner *bufio.Scanner
29 messageID int64 // Monotonically increasing ID for requests.
30 diagnostics []Diagnostic // Cached errors/warnings from the server.
31 diagMutex sync.RWMutex // Protects access to diagnostics.
32 filename string // The file this client is associated with.
33 uri string // The LSP-compatible URI of the file.
34 shutdown bool // Flag to indicate the client is closing.
35 shutdownOnce sync.Once
36 logCallback func(string, string) // Debug logging.
37
38 responses map[int64]chan map[string]interface{} // Map of request IDs to response channels.
39 responseMutex sync.Mutex
40 fileType *FileType // Associated file type for language ID.
41}
42
43// Position in a document (0-based line and character).
44type Position struct {
45 Line int `json:"line"`
46 Character int `json:"character"`
47}
48
49// Range represents a span of text in a document.
50type Range struct {
51 Start Position `json:"start"`
52 End Position `json:"end"`
53}
54
55// Location points to a specific range in a specific file.
56type Location struct {
57 URI string `json:"uri"`
58 Range Range `json:"range"`
59}
60
61// CompletionItem represents a suggestion for completion.
62type CompletionItem struct {
63 Label string `json:"label"`
64 Kind int `json:"kind"`
65 Detail string `json:"detail"`
66 Documentation string `json:"documentation"`
67 InsertText string `json:"insertText"`
68}
69
70// CompletionList represents a collection of completion items.
71type CompletionList struct {
72 IsIncomplete bool `json:"isIncomplete"`
73 Items []CompletionItem `json:"items"`
74}
75
76// Diagnostic represents an error, warning, or hint from the language server.
77type Diagnostic struct {
78 Range struct {
79 Start struct {
80 Line int `json:"line"`
81 Character int `json:"character"`
82 } `json:"start"`
83 End struct {
84 Line int `json:"line"`
85 Character int `json:"character"`
86 } `json:"end"`
87 } `json:"range"`
88 Severity int `json:"severity"` // 1=Error, 2=Warning, 3=Info, 4=Hint.
89 Message string `json:"message"`
90}
91
92// NewLSPClient starts a new LSP server process for the given file type.
93func NewLSPClient(filename string, fileContent string, logCallback func(string, string), ft *FileType) (*LSPClient, error) {
94 absPath, err := filepath.Abs(filename)
95 if err != nil {
96 return nil, err
97 }
98
99 client := &LSPClient{
100 filename: absPath,
101 uri: "file://" + absPath,
102 diagnostics: []Diagnostic{},
103 logCallback: logCallback,
104 responses: make(map[int64]chan map[string]interface{}),
105 fileType: ft,
106 }
107
108 // Launch the language server's executable.
109 client.cmd = exec.Command(ft.LSPCommand, ft.LSPCommandArgs...)
110
111 // Suppress the server's own internal log messages (stderr).
112 devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
113 if err == nil {
114 client.cmd.Stderr = devNull
115 }
116
117 client.stdin, err = client.cmd.StdinPipe()
118 if err != nil {
119 return nil, err
120 }
121
122 client.stdout, err = client.cmd.StdoutPipe()
123 if err != nil {
124 return nil, err
125 }
126
127 if err := client.cmd.Start(); err != nil {
128 return nil, err
129 }
130
131 // Start a background goroutine to read messages from the server's stdout.
132 go client.readMessages()
133
134 // Perform the LSP handshake: Initialize and Notify Open.
135 if err := client.initialize(); err != nil {
136 client.Shutdown()
137 return nil, err
138 }
139
140 if err := client.sendDidOpen(fileContent); err != nil {
141 client.Shutdown()
142 return nil, err
143 }
144
145 return client, nil
146}
147
148// nextID increments and returns the next request ID.
149func (c *LSPClient) nextID() int64 {
150 return atomic.AddInt64(&c.messageID, 1)
151}
152
153// sendRequest sends a JSON-RPC request and expects a response.
154func (c *LSPClient) sendRequest(method string, params interface{}) error {
155 id := c.nextID()
156 request := map[string]interface{}{
157 "jsonrpc": "2.0",
158 "id": id,
159 "method": method,
160 "params": params,
161 }
162 return c.sendMessage(request)
163}
164
165// sendNotification sends a JSON-RPC message without expecting a response.
166func (c *LSPClient) sendNotification(method string, params interface{}) error {
167 notification := map[string]interface{}{
168 "jsonrpc": "2.0",
169 "method": method,
170 "params": params,
171 }
172 return c.sendMessage(notification)
173}
174
175// sendMessage writes a JSON-encoded message to the server's stdin.
176func (c *LSPClient) sendMessage(msg interface{}) error {
177 if c.shutdown {
178 return fmt.Errorf("client is shutdown")
179 }
180
181 data, err := json.Marshal(msg)
182 if err != nil {
183 return err
184 }
185
186 // LSP messages use a header similar to HTTP: Content-Length followed by \r\n\r\n.
187 content := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data)
188 _, err = c.stdin.Write([]byte(content))
189 return err
190}
191
192// readMessages loops forever, parsing messages from the server's stdout.
193func (c *LSPClient) readMessages() {
194 reader := bufio.NewReader(c.stdout)
195
196 for {
197 if c.shutdown {
198 return
199 }
200
201 // Parse the Content-Length header to know how many bytes to read next.
202 contentLength := 0
203 for {
204 line, err := reader.ReadString('\n')
205 if err != nil {
206 return
207 }
208
209 line = strings.TrimSpace(line)
210 if line == "" {
211 break
212 }
213
214 var length int
215 if n, _ := fmt.Sscanf(line, "Content-Length: %d", &length); n == 1 {
216 contentLength = length
217 }
218 }
219
220 if contentLength == 0 {
221 continue
222 }
223
224 // Read the JSON body.
225 buf := make([]byte, contentLength)
226 _, err := io.ReadFull(reader, buf)
227 if err != nil {
228 return
229 }
230
231 var msg map[string]interface{}
232 if err := json.Unmarshal(buf, &msg); err != nil {
233 continue
234 }
235
236 // If the message has an "id", it's a response to a request we sent.
237 if idVal, hasID := msg["id"]; hasID {
238 if c.logCallback != nil {
239 c.logCallback("LSP", fmt.Sprintf("Received response with ID: %v (type: %T)", idVal, idVal))
240 }
241 if id, ok := idVal.(float64); ok {
242 idInt := int64(id)
243 if c.logCallback != nil {
244 c.logCallback("LSP", fmt.Sprintf("Looking for response channel with ID=%d", idInt))
245 }
246 c.responseMutex.Lock()
247 ch, exists := c.responses[idInt]
248 if exists {
249 if c.logCallback != nil {
250 c.logCallback("LSP", fmt.Sprintf("Found channel for ID=%d, sending response", idInt))
251 }
252 delete(c.responses, idInt)
253 c.responseMutex.Unlock()
254 ch <- msg // Send response to the goroutine waiting for it.
255 } else {
256 if c.logCallback != nil {
257 c.logCallback("LSP", fmt.Sprintf("No channel found for ID=%d", idInt))
258 }
259 c.responseMutex.Unlock()
260 }
261 } else {
262 if c.logCallback != nil {
263 c.logCallback("LSP", fmt.Sprintf("Failed to convert ID to int64: %v", idVal))
264 }
265 }
266 }
267
268 // If it has no "id", it's an asynchronous notification (like updated diagnostics).
269 if _, hasID := msg["id"]; !hasID {
270 c.handleNotification(msg)
271 }
272 }
273}
274
275// handleNotification processes messages initiated by the server.
276func (c *LSPClient) handleNotification(msg map[string]interface{}) {
277 method, ok := msg["method"].(string)
278 if !ok {
279 return
280 }
281
282 // Server is sending updated errors/warnings for the file.
283 if method == "textDocument/publishDiagnostics" {
284 params, ok := msg["params"].(map[string]interface{})
285 if !ok {
286 return
287 }
288
289 uri, _ := params["uri"].(string)
290 if uri != c.uri {
291 return
292 }
293
294 diagsRaw, ok := params["diagnostics"].([]interface{})
295 if !ok {
296 return
297 }
298
299 var diags []Diagnostic
300 for _, d := range diagsRaw {
301 diagJSON, _ := json.Marshal(d)
302 var diag Diagnostic
303 if json.Unmarshal(diagJSON, &diag) == nil {
304 diags = append(diags, diag)
305 }
306 }
307
308 c.diagMutex.Lock()
309 c.diagnostics = diags
310 c.diagMutex.Unlock()
311
312 // Tell termbox to refresh the UI so signs appear in the gutter.
313 termbox.Interrupt()
314 }
315}
316
317// initialize sends the initial 'initialize' request to the server.
318func (c *LSPClient) initialize() error {
319 rootURI := "file://" + filepath.Dir(c.filename)
320 params := map[string]interface{}{
321 "processId": os.Getpid(),
322 "rootUri": rootURI,
323 "capabilities": map[string]interface{}{
324 "textDocument": map[string]interface{}{
325 "publishDiagnostics": map[string]interface{}{},
326 "hover": map[string]interface{}{
327 "contentFormat": []string{"plaintext"},
328 },
329 "completion": map[string]interface{}{
330 "completionItem": map[string]interface{}{
331 "snippetSupport": false,
332 },
333 },
334 },
335 },
336 }
337
338 if err := c.sendRequest("initialize", params); err != nil {
339 return err
340 }
341
342 return c.sendNotification("initialized", map[string]interface{}{})
343}
344
345// sendDidOpen notifies the server that a file has been opened.
346func (c *LSPClient) sendDidOpen(content string) error {
347 languageID := strings.ToLower(c.fileType.Name)
348 params := map[string]interface{}{
349 "textDocument": map[string]interface{}{
350 "uri": c.uri,
351 "languageId": languageID,
352 "version": 1,
353 "text": content,
354 },
355 }
356 return c.sendNotification("textDocument/didOpen", params)
357}
358
359// SendDidChange notifies the server of changes to the document content.
360func (c *LSPClient) SendDidChange(content string) error {
361 params := map[string]interface{}{
362 "textDocument": map[string]interface{}{
363 "uri": c.uri,
364 "version": c.nextID(),
365 },
366 "contentChanges": []interface{}{
367 map[string]interface{}{
368 "text": content,
369 },
370 },
371 }
372 return c.sendNotification("textDocument/didChange", params)
373}
374
375// GetDiagnostics returns a copy of the current file diagnostics.
376func (c *LSPClient) GetDiagnostics() []Diagnostic {
377 c.diagMutex.RLock()
378 defer c.diagMutex.RUnlock()
379
380 result := make([]Diagnostic, len(c.diagnostics))
381 copy(result, c.diagnostics)
382 return result
383}
384
385// Definition requests the location of the definition of the symbol at cursor.
386func (c *LSPClient) Definition(line, character int) ([]Location, error) {
387 id := c.nextID()
388 params := map[string]interface{}{
389 "textDocument": map[string]interface{}{
390 "uri": c.uri,
391 },
392 "position": map[string]interface{}{
393 "line": line,
394 "character": character,
395 },
396 }
397
398 responseChan := make(chan map[string]interface{}, 1)
399 c.responseMutex.Lock()
400 c.responses[id] = responseChan
401 c.responseMutex.Unlock()
402
403 if err := c.sendRequestWithID(id, "textDocument/definition", params); err != nil {
404 c.responseMutex.Lock()
405 delete(c.responses, id)
406 c.responseMutex.Unlock()
407 return nil, err
408 }
409
410 select {
411 case resp := <-responseChan:
412 if err, ok := resp["error"]; ok {
413 return nil, fmt.Errorf("LSP error: %v", err)
414 }
415
416 result := resp["result"]
417 if result == nil {
418 return nil, nil
419 }
420
421 resJSON, _ := json.Marshal(result)
422
423 // Definition can return a single Location or an array of them.
424 var loc Location
425 if err := json.Unmarshal(resJSON, &loc); err == nil && loc.URI != "" {
426 return []Location{loc}, nil
427 }
428
429 var locs []Location
430 if err := json.Unmarshal(resJSON, &locs); err == nil {
431 return locs, nil
432 }
433
434 return nil, nil
435 case <-time.After(5 * time.Second):
436 c.responseMutex.Lock()
437 delete(c.responses, id)
438 c.responseMutex.Unlock()
439 return nil, fmt.Errorf("LSP request timeout")
440 }
441}
442
443// Hover requests documentation information for the symbol at cursor.
444func (c *LSPClient) Hover(line, character int) (string, error) {
445 id := c.nextID()
446 params := map[string]interface{}{
447 "textDocument": map[string]interface{}{
448 "uri": c.uri,
449 },
450 "position": map[string]interface{}{
451 "line": line,
452 "character": character,
453 },
454 }
455
456 responseChan := make(chan map[string]interface{}, 1)
457 c.responseMutex.Lock()
458 c.responses[id] = responseChan
459 c.responseMutex.Unlock()
460
461 if err := c.sendRequestWithID(id, "textDocument/hover", params); err != nil {
462 c.responseMutex.Lock()
463 delete(c.responses, id)
464 c.responseMutex.Unlock()
465 return "", err
466 }
467
468 select {
469 case resp := <-responseChan:
470 if err, ok := resp["error"]; ok {
471 return "", fmt.Errorf("LSP error: %v", err)
472 }
473
474 result := resp["result"]
475 if result == nil {
476 return "", nil
477 }
478
479 // Hover responses are complex: they can be strings, objects, or arrays.
480 resMap, ok := result.(map[string]interface{})
481 if !ok {
482 return "", nil
483 }
484
485 contents := resMap["contents"]
486 if contents == nil {
487 return "", nil
488 }
489
490 if mc, ok := contents.(map[string]interface{}); ok {
491 if val, ok := mc["value"].(string); ok {
492 return stripMarkdown(val), nil
493 }
494 }
495
496 if s, ok := contents.(string); ok {
497 return stripMarkdown(s), nil
498 }
499
500 if ss, ok := contents.([]interface{}); ok {
501 var result strings.Builder
502 for i, s := range ss {
503 if str, ok := s.(string); ok {
504 result.WriteString(stripMarkdown(str))
505 if i < len(ss)-1 {
506 result.WriteString("\n")
507 }
508 } else if m, ok := s.(map[string]interface{}); ok {
509 if val, ok := m["value"].(string); ok {
510 result.WriteString(stripMarkdown(val))
511 if i < len(ss)-1 {
512 result.WriteString("\n")
513 }
514 }
515 }
516 }
517 return strings.TrimSpace(result.String()), nil
518 }
519
520 return "", nil
521 case <-time.After(5 * time.Second):
522 c.responseMutex.Lock()
523 delete(c.responses, id)
524 c.responseMutex.Unlock()
525 return "", fmt.Errorf("LSP request timeout")
526 }
527}
528
529// Completion requests a list of completion items for the symbol at cursor.
530func (c *LSPClient) Completion(line, character int) ([]CompletionItem, error) {
531 id := c.nextID()
532 params := map[string]interface{}{
533 "textDocument": map[string]interface{}{
534 "uri": c.uri,
535 },
536 "position": map[string]interface{}{
537 "line": line,
538 "character": character,
539 },
540 }
541
542 if c.logCallback != nil {
543 c.logCallback("LSP", fmt.Sprintf("Requesting completion at %d:%d (ID=%d)", line, character, id))
544 }
545
546 responseChan := make(chan map[string]interface{}, 1)
547 c.responseMutex.Lock()
548 c.responses[id] = responseChan
549 c.responseMutex.Unlock()
550
551 if err := c.sendRequestWithID(id, "textDocument/completion", params); err != nil {
552 c.responseMutex.Lock()
553 delete(c.responses, id)
554 c.responseMutex.Unlock()
555 return nil, err
556 }
557
558 select {
559 case resp := <-responseChan:
560 if c.logCallback != nil {
561 c.logCallback("LSP", fmt.Sprintf("Received completion response (ID=%d)", id))
562 }
563 if err, ok := resp["error"]; ok {
564 return nil, fmt.Errorf("LSP error: %v", err)
565 }
566
567 result := resp["result"]
568 if result == nil {
569 return nil, nil
570 }
571
572 resJSON, _ := json.Marshal(result)
573
574 // Completion can return a CompletionList or an array of CompletionItems.
575 var compList CompletionList
576 if err := json.Unmarshal(resJSON, &compList); err == nil {
577 return compList.Items, nil
578 }
579
580 var compItems []CompletionItem
581 if err := json.Unmarshal(resJSON, &compItems); err == nil {
582 return compItems, nil
583 }
584
585 return nil, nil
586 case <-time.After(10 * time.Second):
587 if c.logCallback != nil {
588 c.logCallback("LSP", fmt.Sprintf("Completion request timed out (ID=%d)", id))
589 }
590 c.responseMutex.Lock()
591 delete(c.responses, id)
592 c.responseMutex.Unlock()
593 return nil, fmt.Errorf("LSP request timeout")
594 }
595}
596
597// sendRequestWithID helper to send a request with a pre-generated ID.
598func (c *LSPClient) sendRequestWithID(id int64, method string, params interface{}) error {
599 request := map[string]interface{}{
600 "jsonrpc": "2.0",
601 "id": id,
602 "method": method,
603 "params": params,
604 }
605 return c.sendMessage(request)
606}
607
608// Shutdown gracefully closes the LSP client and stops the server process.
609func (c *LSPClient) Shutdown() {
610 c.shutdownOnce.Do(func() {
611 c.shutdown = true
612
613 c.sendRequest("shutdown", nil)
614 c.sendNotification("exit", nil)
615
616 if c.stdin != nil {
617 c.stdin.Close()
618 }
619 if c.stdout != nil {
620 c.stdout.Close()
621 }
622
623 if c.cmd != nil && c.cmd.Process != nil {
624 c.cmd.Wait()
625 }
626 })
627}
628
629// stripMarkdown provides a very naive way to remove markdown formatting from LSP responses.
630func stripMarkdown(s string) string {
631 lines := strings.Split(s, "\n")
632 var result []string
633 inCodeBlock := false
634 for _, line := range lines {
635 // Ignore code block markers.
636 if strings.HasPrefix(line, "```") {
637 inCodeBlock = !inCodeBlock
638 continue
639 }
640 if inCodeBlock {
641 result = append(result, line)
642 continue
643 }
644
645 l := line
646 l = strings.ReplaceAll(l, "**", "")
647 l = strings.ReplaceAll(l, "__", "")
648
649 // Naive link stripping: [text](url) -> text
650 for {
651 start := strings.Index(l, "[")
652 end := strings.Index(l, "](")
653 if start != -1 && end != -1 && end > start {
654 closeParen := strings.Index(l[end:], ")")
655 if closeParen != -1 {
656 l = l[:start] + l[start+1:end] + l[end+closeParen+1:]
657 continue
658 }
659 }
660 break
661 }
662
663 l = strings.ReplaceAll(l, "`", "")
664 result = append(result, l)
665 }
666
667 return strings.TrimSpace(strings.Join(result, "\n"))
668}