summaryrefslogtreecommitdiffhomepage
path: root/internal/lsp/client.go
diff options
context:
space:
mode:
authorKujtim Hoxha <[email protected]>2025-04-03 15:20:15 +0200
committerKujtim Hoxha <[email protected]>2025-04-03 17:23:41 +0200
commitcfdd687216799cb5b47f099f1e7cd5dd16b3bdd0 (patch)
treea822bfde1463a7080c0ea06dd17796d7a1617d3d /internal/lsp/client.go
parentafd9ad0560d76c2a6d161dad52553b10ff428905 (diff)
downloadopencode-cfdd687216799cb5b47f099f1e7cd5dd16b3bdd0.tar.gz
opencode-cfdd687216799cb5b47f099f1e7cd5dd16b3bdd0.zip
add initial lsp support
Diffstat (limited to 'internal/lsp/client.go')
-rw-r--r--internal/lsp/client.go429
1 files changed, 429 insertions, 0 deletions
diff --git a/internal/lsp/client.go b/internal/lsp/client.go
new file mode 100644
index 000000000..ddf28ca2a
--- /dev/null
+++ b/internal/lsp/client.go
@@ -0,0 +1,429 @@
+package lsp
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/kujtimiihoxha/termai/internal/lsp/protocol"
+)
+
+type Client struct {
+ Cmd *exec.Cmd
+ stdin io.WriteCloser
+ stdout *bufio.Reader
+ stderr io.ReadCloser
+
+ // Request ID counter
+ nextID atomic.Int32
+
+ // Response handlers
+ handlers map[int32]chan *Message
+ handlersMu sync.RWMutex
+
+ // Server request handlers
+ serverRequestHandlers map[string]ServerRequestHandler
+ serverHandlersMu sync.RWMutex
+
+ // Notification handlers
+ notificationHandlers map[string]NotificationHandler
+ notificationMu sync.RWMutex
+
+ // Diagnostic cache
+ diagnostics map[protocol.DocumentUri][]protocol.Diagnostic
+ diagnosticsMu sync.RWMutex
+
+ // Files are currently opened by the LSP
+ openFiles map[string]*OpenFileInfo
+ openFilesMu sync.RWMutex
+}
+
+func NewClient(command string, args ...string) (*Client, error) {
+ cmd := exec.Command(command, args...)
+ // Copy env
+ cmd.Env = os.Environ()
+
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create stdin pipe: %w", err)
+ }
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create stderr pipe: %w", err)
+ }
+
+ client := &Client{
+ Cmd: cmd,
+ stdin: stdin,
+ stdout: bufio.NewReader(stdout),
+ stderr: stderr,
+ handlers: make(map[int32]chan *Message),
+ notificationHandlers: make(map[string]NotificationHandler),
+ serverRequestHandlers: make(map[string]ServerRequestHandler),
+ diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic),
+ openFiles: make(map[string]*OpenFileInfo),
+ }
+
+ // Start the LSP server process
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("failed to start LSP server: %w", err)
+ }
+
+ // Handle stderr in a separate goroutine
+ go func() {
+ scanner := bufio.NewScanner(stderr)
+ for scanner.Scan() {
+ fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text())
+ }
+ if err := scanner.Err(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err)
+ }
+ }()
+
+ // Start message handling loop
+ go client.handleMessages()
+
+ return client, nil
+}
+
+func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) {
+ c.notificationMu.Lock()
+ defer c.notificationMu.Unlock()
+ c.notificationHandlers[method] = handler
+}
+
+func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) {
+ c.serverHandlersMu.Lock()
+ defer c.serverHandlersMu.Unlock()
+ c.serverRequestHandlers[method] = handler
+}
+
+func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) {
+ initParams := &protocol.InitializeParams{
+ WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{
+ WorkspaceFolders: []protocol.WorkspaceFolder{
+ {
+ URI: protocol.URI("file://" + workspaceDir),
+ Name: workspaceDir,
+ },
+ },
+ },
+
+ XInitializeParams: protocol.XInitializeParams{
+ ProcessID: int32(os.Getpid()),
+ ClientInfo: &protocol.ClientInfo{
+ Name: "mcp-language-server",
+ Version: "0.1.0",
+ },
+ RootPath: workspaceDir,
+ RootURI: protocol.DocumentUri("file://" + workspaceDir),
+ Capabilities: protocol.ClientCapabilities{
+ Workspace: protocol.WorkspaceClientCapabilities{
+ Configuration: true,
+ DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{
+ DynamicRegistration: true,
+ },
+ DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{
+ DynamicRegistration: true,
+ RelativePatternSupport: true,
+ },
+ },
+ TextDocument: protocol.TextDocumentClientCapabilities{
+ Synchronization: &protocol.TextDocumentSyncClientCapabilities{
+ DynamicRegistration: true,
+ DidSave: true,
+ },
+ Completion: protocol.CompletionClientCapabilities{
+ CompletionItem: protocol.ClientCompletionItemOptions{},
+ },
+ CodeLens: &protocol.CodeLensClientCapabilities{
+ DynamicRegistration: true,
+ },
+ DocumentSymbol: protocol.DocumentSymbolClientCapabilities{},
+ CodeAction: protocol.CodeActionClientCapabilities{
+ CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{
+ CodeActionKind: protocol.ClientCodeActionKindOptions{
+ ValueSet: []protocol.CodeActionKind{},
+ },
+ },
+ },
+ PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{
+ VersionSupport: true,
+ },
+ SemanticTokens: protocol.SemanticTokensClientCapabilities{
+ Requests: protocol.ClientSemanticTokensRequestOptions{
+ Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{},
+ Full: &protocol.Or_ClientSemanticTokensRequestOptions_full{},
+ },
+ TokenTypes: []string{},
+ TokenModifiers: []string{},
+ Formats: []protocol.TokenFormat{},
+ },
+ },
+ Window: protocol.WindowClientCapabilities{},
+ },
+ InitializationOptions: map[string]any{
+ "codelenses": map[string]bool{
+ "generate": true,
+ "regenerate_cgo": true,
+ "test": true,
+ "tidy": true,
+ "upgrade_dependency": true,
+ "vendor": true,
+ "vulncheck": false,
+ },
+ },
+ },
+ }
+
+ var result protocol.InitializeResult
+ if err := c.Call(ctx, "initialize", initParams, &result); err != nil {
+ return nil, fmt.Errorf("initialize failed: %w", err)
+ }
+
+ if err := c.Notify(ctx, "initialized", struct{}{}); err != nil {
+ return nil, fmt.Errorf("initialized notification failed: %w", err)
+ }
+
+ // Register handlers
+ c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit)
+ c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration)
+ c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability)
+ c.RegisterNotificationHandler("window/showMessage", HandleServerMessage)
+ c.RegisterNotificationHandler("textDocument/publishDiagnostics",
+ func(params json.RawMessage) { HandleDiagnostics(c, params) })
+
+ // Notify the LSP server
+ err := c.Initialized(ctx, protocol.InitializedParams{})
+ if err != nil {
+ return nil, fmt.Errorf("initialization failed: %w", err)
+ }
+
+ // LSP sepecific Initialization
+ path := strings.ToLower(c.Cmd.Path)
+ switch {
+ case strings.Contains(path, "typescript-language-server"):
+ // err := initializeTypescriptLanguageServer(ctx, c, workspaceDir)
+ // if err != nil {
+ // return nil, err
+ // }
+ }
+
+ return &result, nil
+}
+
+func (c *Client) Close() error {
+ // Try to close all open files first
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ // Attempt to close files but continue shutdown regardless
+ c.CloseAllFiles(ctx)
+
+ // Close stdin to signal the server
+ if err := c.stdin.Close(); err != nil {
+ return fmt.Errorf("failed to close stdin: %w", err)
+ }
+
+ // Use a channel to handle the Wait with timeout
+ done := make(chan error, 1)
+ go func() {
+ done <- c.Cmd.Wait()
+ }()
+
+ // Wait for process to exit with timeout
+ select {
+ case err := <-done:
+ return err
+ case <-time.After(2 * time.Second):
+ // If we timeout, try to kill the process
+ if err := c.Cmd.Process.Kill(); err != nil {
+ return fmt.Errorf("failed to kill process: %w", err)
+ }
+ return fmt.Errorf("process killed after timeout")
+ }
+}
+
+type ServerState int
+
+const (
+ StateStarting ServerState = iota
+ StateReady
+ StateError
+)
+
+func (c *Client) WaitForServerReady(ctx context.Context) error {
+ // TODO: wait for specific messages or poll workspace/symbol
+ time.Sleep(time.Second * 1)
+ return nil
+}
+
+type OpenFileInfo struct {
+ Version int32
+ URI protocol.DocumentUri
+}
+
+func (c *Client) OpenFile(ctx context.Context, filepath string) error {
+ uri := fmt.Sprintf("file://%s", filepath)
+
+ c.openFilesMu.Lock()
+ if _, exists := c.openFiles[uri]; exists {
+ c.openFilesMu.Unlock()
+ return nil // Already open
+ }
+ c.openFilesMu.Unlock()
+
+ // Skip files that do not exist or cannot be read
+ content, err := os.ReadFile(filepath)
+ if err != nil {
+ return fmt.Errorf("error reading file: %w", err)
+ }
+
+ params := protocol.DidOpenTextDocumentParams{
+ TextDocument: protocol.TextDocumentItem{
+ URI: protocol.DocumentUri(uri),
+ LanguageID: DetectLanguageID(uri),
+ Version: 1,
+ Text: string(content),
+ },
+ }
+
+ if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil {
+ return err
+ }
+
+ c.openFilesMu.Lock()
+ c.openFiles[uri] = &OpenFileInfo{
+ Version: 1,
+ URI: protocol.DocumentUri(uri),
+ }
+ c.openFilesMu.Unlock()
+
+ return nil
+}
+
+func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
+ uri := fmt.Sprintf("file://%s", filepath)
+
+ content, err := os.ReadFile(filepath)
+ if err != nil {
+ return fmt.Errorf("error reading file: %w", err)
+ }
+
+ c.openFilesMu.Lock()
+ fileInfo, isOpen := c.openFiles[uri]
+ if !isOpen {
+ c.openFilesMu.Unlock()
+ return fmt.Errorf("cannot notify change for unopened file: %s", filepath)
+ }
+
+ // Increment version
+ fileInfo.Version++
+ version := fileInfo.Version
+ c.openFilesMu.Unlock()
+
+ params := protocol.DidChangeTextDocumentParams{
+ TextDocument: protocol.VersionedTextDocumentIdentifier{
+ TextDocumentIdentifier: protocol.TextDocumentIdentifier{
+ URI: protocol.DocumentUri(uri),
+ },
+ Version: version,
+ },
+ ContentChanges: []protocol.TextDocumentContentChangeEvent{
+ {
+ Value: protocol.TextDocumentContentChangeWholeDocument{
+ Text: string(content),
+ },
+ },
+ },
+ }
+
+ return c.Notify(ctx, "textDocument/didChange", params)
+}
+
+func (c *Client) CloseFile(ctx context.Context, filepath string) error {
+ uri := fmt.Sprintf("file://%s", filepath)
+
+ c.openFilesMu.Lock()
+ if _, exists := c.openFiles[uri]; !exists {
+ c.openFilesMu.Unlock()
+ return nil // Already closed
+ }
+ c.openFilesMu.Unlock()
+
+ params := protocol.DidCloseTextDocumentParams{
+ TextDocument: protocol.TextDocumentIdentifier{
+ URI: protocol.DocumentUri(uri),
+ },
+ }
+ log.Println("Closing", params.TextDocument.URI.Dir())
+ if err := c.Notify(ctx, "textDocument/didClose", params); err != nil {
+ return err
+ }
+
+ c.openFilesMu.Lock()
+ delete(c.openFiles, uri)
+ c.openFilesMu.Unlock()
+
+ return nil
+}
+
+func (c *Client) IsFileOpen(filepath string) bool {
+ uri := fmt.Sprintf("file://%s", filepath)
+ c.openFilesMu.RLock()
+ defer c.openFilesMu.RUnlock()
+ _, exists := c.openFiles[uri]
+ return exists
+}
+
+// CloseAllFiles closes all currently open files
+func (c *Client) CloseAllFiles(ctx context.Context) {
+ c.openFilesMu.Lock()
+ filesToClose := make([]string, 0, len(c.openFiles))
+
+ // First collect all URIs that need to be closed
+ for uri := range c.openFiles {
+ // Convert URI back to file path by trimming "file://" prefix
+ filePath := strings.TrimPrefix(uri, "file://")
+ filesToClose = append(filesToClose, filePath)
+ }
+ c.openFilesMu.Unlock()
+
+ // Then close them all
+ for _, filePath := range filesToClose {
+ err := c.CloseFile(ctx, filePath)
+ if err != nil && debug {
+ log.Printf("Error closing file %s: %v", filePath, err)
+ }
+ }
+
+ if debug {
+ log.Printf("Closed %d files", len(filesToClose))
+ }
+}
+
+func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnostic {
+ c.diagnosticsMu.RLock()
+ defer c.diagnosticsMu.RUnlock()
+
+ return c.diagnostics[uri]
+}
+
+func (c *Client) GetDiagnostics() map[protocol.DocumentUri][]protocol.Diagnostic {
+ return c.diagnostics
+}