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}