mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
Ho hum.
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// Package lsp provides a generic LSP client implementation.
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
// Client represents an LSP client connection.
|
||||
type Client struct {
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr io.ReadCloser
|
||||
cmd *exec.Cmd
|
||||
pending map[int64]chan *Response
|
||||
done chan struct{}
|
||||
notifications chan *Notification
|
||||
requestID atomic.Int64
|
||||
runningMu sync.RWMutex
|
||||
stopOnce sync.Once
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// Request represents a JSON-RPC request.
|
||||
type Request struct {
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// Response represents a JSON-RPC response.
|
||||
type Response struct {
|
||||
Error *ResponseError `json:"error,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// ResponseError represents a JSON-RPC error.
|
||||
type ResponseError struct {
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
func (e *ResponseError) Error() string {
|
||||
return fmt.Sprintf("LSP error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Notification represents a JSON-RPC notification.
|
||||
type Notification struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// NewClient creates a new LSP client from a command.
|
||||
func NewClient(cmd *exec.Cmd) (*Client, error) {
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = stdin.Close()
|
||||
_ = stdout.Close()
|
||||
_ = stderr.Close()
|
||||
return nil, fmt.Errorf("failed to start LSP server: %w", err)
|
||||
}
|
||||
|
||||
c := &Client{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
pending: make(map[int64]chan *Response),
|
||||
done: make(chan struct{}),
|
||||
running: true,
|
||||
notifications: make(chan *Notification, 100),
|
||||
}
|
||||
|
||||
// Start reader goroutine
|
||||
go c.readLoop()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Call sends a request and waits for a response.
|
||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||
c.runningMu.RLock()
|
||||
if !c.running {
|
||||
c.runningMu.RUnlock()
|
||||
return nil, fmt.Errorf("client is not running")
|
||||
}
|
||||
c.runningMu.RUnlock()
|
||||
|
||||
id := c.requestID.Add(1)
|
||||
req := &Request{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
// Create response channel
|
||||
respChan := make(chan *Response, 1)
|
||||
c.mu.Lock()
|
||||
c.pending[id] = respChan
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Send request
|
||||
if err := c.send(req); err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.done:
|
||||
return nil, fmt.Errorf("client closed")
|
||||
case resp := <-respChan:
|
||||
if resp.Error != nil {
|
||||
return nil, resp.Error
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Notify sends a notification (no response expected).
|
||||
func (c *Client) Notify(method string, params interface{}) error {
|
||||
c.runningMu.RLock()
|
||||
if !c.running {
|
||||
c.runningMu.RUnlock()
|
||||
return fmt.Errorf("client is not running")
|
||||
}
|
||||
c.runningMu.RUnlock()
|
||||
|
||||
notif := struct {
|
||||
Params interface{} `json:"params,omitempty"`
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
}{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
Params: params,
|
||||
}
|
||||
|
||||
return c.send(notif)
|
||||
}
|
||||
|
||||
// Notifications returns a channel for receiving server notifications.
|
||||
func (c *Client) Notifications() <-chan *Notification {
|
||||
return c.notifications
|
||||
}
|
||||
|
||||
// Close shuts down the client and the LSP server.
|
||||
func (c *Client) Close() error {
|
||||
var err error
|
||||
c.stopOnce.Do(func() {
|
||||
c.runningMu.Lock()
|
||||
c.running = false
|
||||
c.runningMu.Unlock()
|
||||
|
||||
close(c.done)
|
||||
|
||||
// Close stdin to signal the server
|
||||
_ = c.stdin.Close()
|
||||
|
||||
// Wait for process to exit with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = c.cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Clean exit
|
||||
case <-time.After(5 * time.Second):
|
||||
// Force kill
|
||||
_ = c.cmd.Process.Kill()
|
||||
}
|
||||
|
||||
close(c.notifications)
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// send writes a JSON-RPC message to the server.
|
||||
func (c *Client) send(msg interface{}) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// Format with Content-Length header
|
||||
header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data))
|
||||
_, err = c.stdin.Write([]byte(header))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write header: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.stdin.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write body: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readLoop reads and dispatches messages from the server.
|
||||
func (c *Client) readLoop() {
|
||||
reader := bufio.NewReader(c.stdout)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Read headers
|
||||
contentLength := -1
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(line, "Content-Length:") {
|
||||
lengthStr := strings.TrimSpace(strings.TrimPrefix(line, "Content-Length:"))
|
||||
contentLength, _ = strconv.Atoi(lengthStr)
|
||||
}
|
||||
}
|
||||
|
||||
if contentLength <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read body
|
||||
body := make([]byte, contentLength)
|
||||
_, err := io.ReadFull(reader, body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to parse as response first
|
||||
var resp Response
|
||||
if err := json.Unmarshal(body, &resp); err == nil && resp.ID != 0 {
|
||||
c.mu.Lock()
|
||||
if ch, ok := c.pending[resp.ID]; ok {
|
||||
ch <- &resp
|
||||
}
|
||||
c.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse as notification
|
||||
var notif Notification
|
||||
if err := json.Unmarshal(body, ¬if); err == nil && notif.Method != "" {
|
||||
select {
|
||||
case c.notifications <- ¬if:
|
||||
default:
|
||||
// Drop notification if channel is full
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsRunning returns whether the client is running.
|
||||
func (c *Client) IsRunning() bool {
|
||||
c.runningMu.RLock()
|
||||
defer c.runningMu.RUnlock()
|
||||
return c.running
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
// Manager manages LSP servers for different languages.
|
||||
type Manager struct {
|
||||
servers map[protocol.Language]*ManagedServer
|
||||
logger *slog.Logger
|
||||
stopReaper chan struct{}
|
||||
workspaceRoot string
|
||||
timeout time.Duration
|
||||
idleTimeout time.Duration
|
||||
mu sync.RWMutex
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// ManagedServer represents a managed LSP server instance.
|
||||
type ManagedServer struct {
|
||||
lastUsed time.Time
|
||||
initErr error
|
||||
client *Client
|
||||
openDocs map[string]int
|
||||
language protocol.Language
|
||||
capabilities ServerCapabilities
|
||||
mu sync.Mutex
|
||||
ready bool
|
||||
}
|
||||
|
||||
// ServerConfig contains the configuration for an LSP server.
|
||||
type ServerConfig struct {
|
||||
Command []string
|
||||
Args []string
|
||||
}
|
||||
|
||||
// DefaultServerConfigs contains default configurations for LSP servers.
|
||||
var DefaultServerConfigs = map[protocol.Language]ServerConfig{
|
||||
protocol.LangGo: {
|
||||
Command: []string{"gopls"},
|
||||
Args: []string{"serve"},
|
||||
},
|
||||
protocol.LangTypeScript: {
|
||||
Command: []string{"typescript-language-server"},
|
||||
Args: []string{"--stdio"},
|
||||
},
|
||||
protocol.LangJavaScript: {
|
||||
Command: []string{"typescript-language-server"},
|
||||
Args: []string{"--stdio"},
|
||||
},
|
||||
protocol.LangPython: {
|
||||
Command: []string{"pylsp"},
|
||||
},
|
||||
protocol.LangC: {
|
||||
Command: []string{"clangd"},
|
||||
},
|
||||
protocol.LangCpp: {
|
||||
Command: []string{"clangd"},
|
||||
},
|
||||
}
|
||||
|
||||
// NewManager creates a new LSP manager.
|
||||
func NewManager(workspaceRoot string, logger *slog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
servers: make(map[protocol.Language]*ManagedServer),
|
||||
timeout: 10 * time.Second,
|
||||
idleTimeout: 5 * time.Minute,
|
||||
workspaceRoot: workspaceRoot,
|
||||
logger: logger,
|
||||
stopReaper: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start idle reaper
|
||||
go m.reapIdleServers()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
// GetServer returns or creates an LSP server for the given language.
|
||||
func (m *Manager) GetServer(ctx context.Context, lang protocol.Language) (*ManagedServer, error) {
|
||||
m.mu.RLock()
|
||||
srv, exists := m.servers[lang]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if exists && srv.ready {
|
||||
// Update lastUsed with server's own lock to avoid race condition
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
srv.mu.Unlock()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Create new server
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if srv, ok := m.servers[lang]; ok && srv.ready {
|
||||
srv.mu.Lock()
|
||||
srv.lastUsed = time.Now()
|
||||
srv.mu.Unlock()
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Check if server config exists
|
||||
config, ok := DefaultServerConfigs[lang]
|
||||
if !ok {
|
||||
return nil, errors.New(errors.ErrLSPServerNotFound, fmt.Sprintf("no LSP server configured for language: %s", lang)).
|
||||
WithContext("language", string(lang)).
|
||||
WithRemediation("Configure an LSP server for this language or use a supported language")
|
||||
}
|
||||
|
||||
// Check if command is available
|
||||
cmdPath, err := exec.LookPath(config.Command[0])
|
||||
if err != nil {
|
||||
return nil, errors.NewLSPServerNotFound(string(lang), config.Command[0])
|
||||
}
|
||||
|
||||
// Create command
|
||||
args := append(config.Command[1:], config.Args...)
|
||||
cmd := exec.CommandContext(ctx, cmdPath, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Dir = m.workspaceRoot
|
||||
|
||||
// Create client
|
||||
client, err := NewClient(cmd)
|
||||
if err != nil {
|
||||
// Ensure process is killed if client creation fails
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
return nil, errors.Wrap(errors.ErrLSPCommunication, "failed to create LSP client", err).
|
||||
WithContext("language", string(lang)).
|
||||
WithContext("command", config.Command[0]).
|
||||
WithRemediation("Ensure the LSP server binary is executable and compatible with your system")
|
||||
}
|
||||
|
||||
newSrv := &ManagedServer{
|
||||
client: client,
|
||||
language: lang,
|
||||
lastUsed: time.Now(),
|
||||
openDocs: make(map[string]int),
|
||||
}
|
||||
|
||||
// Initialize server
|
||||
if err := m.initializeServer(ctx, newSrv); err != nil {
|
||||
_ = client.Close()
|
||||
// Ensure process is killed on initialization failure
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
newSrv.initErr = err
|
||||
return nil, errors.Wrap(errors.ErrLSPInitFailed, "LSP server initialization failed", err).
|
||||
WithContext("language", string(lang)).
|
||||
WithContext("command", config.Command[0]).
|
||||
WithRemediation("Check LSP server logs for initialization errors")
|
||||
}
|
||||
|
||||
newSrv.ready = true
|
||||
m.servers[lang] = newSrv
|
||||
m.logger.Info("started LSP server", "language", lang, "command", config.Command[0])
|
||||
|
||||
return newSrv, nil
|
||||
}
|
||||
|
||||
// initializeServer performs the LSP initialization handshake.
|
||||
func (m *Manager) initializeServer(ctx context.Context, srv *ManagedServer) error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
|
||||
// Build root URI
|
||||
rootURI := "file://" + m.workspaceRoot
|
||||
|
||||
// Send initialize request
|
||||
params := InitializeParams{
|
||||
ProcessID: os.Getpid(),
|
||||
RootURI: rootURI,
|
||||
Capabilities: Capabilities{
|
||||
TextDocument: TextDocumentClientCapabilities{
|
||||
Hover: HoverCapability{
|
||||
ContentFormat: []string{"markdown", "plaintext"},
|
||||
},
|
||||
Definition: DefinitionCapability{
|
||||
LinkSupport: true,
|
||||
},
|
||||
References: ReferencesCapability{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := srv.client.Call(ctx, "initialize", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse capabilities
|
||||
var result InitializeResult
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse initialize result: %w", err)
|
||||
}
|
||||
srv.capabilities = result.Capabilities
|
||||
|
||||
// Send initialized notification
|
||||
if err := srv.client.Notify("initialized", struct{}{}); err != nil {
|
||||
return fmt.Errorf("initialized notification failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Hover performs a hover request at the given position.
|
||||
func (m *Manager) Hover(ctx context.Context, file string, line, col int) (*HoverResult, error) {
|
||||
lang := protocol.DetectLanguage(file)
|
||||
srv, err := m.GetServer(ctx, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure document is open
|
||||
err = m.ensureDocumentOpen(ctx, srv, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := HoverParams{
|
||||
TextDocumentPositionParams: TextDocumentPositionParams{
|
||||
TextDocument: TextDocumentIdentifier{
|
||||
URI: fileToURI(file),
|
||||
},
|
||||
Position: Position{
|
||||
Line: line - 1, // Convert to 0-indexed
|
||||
Character: col - 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := srv.client.Call(ctx, "textDocument/hover", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hover request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Result == nil || string(resp.Result) == "null" {
|
||||
return nil, nil // No hover info
|
||||
}
|
||||
|
||||
var result HoverResult
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hover result: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Definition finds the definition of the symbol at the given position.
|
||||
func (m *Manager) Definition(ctx context.Context, file string, line, col int) ([]Location, error) {
|
||||
lang := protocol.DetectLanguage(file)
|
||||
srv, err := m.GetServer(ctx, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure document is open
|
||||
err = m.ensureDocumentOpen(ctx, srv, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := DefinitionParams{
|
||||
TextDocumentPositionParams: TextDocumentPositionParams{
|
||||
TextDocument: TextDocumentIdentifier{
|
||||
URI: fileToURI(file),
|
||||
},
|
||||
Position: Position{
|
||||
Line: line - 1,
|
||||
Character: col - 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := srv.client.Call(ctx, "textDocument/definition", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("definition request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Result == nil || string(resp.Result) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Result can be Location, []Location, or []LocationLink
|
||||
var locations []Location
|
||||
if err := json.Unmarshal(resp.Result, &locations); err != nil {
|
||||
// Try single location
|
||||
var single Location
|
||||
if err := json.Unmarshal(resp.Result, &single); err == nil {
|
||||
locations = []Location{single}
|
||||
}
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
// References finds all references to the symbol at the given position.
|
||||
func (m *Manager) References(ctx context.Context, file string, line, col int, includeDeclaration bool) ([]Location, error) {
|
||||
lang := protocol.DetectLanguage(file)
|
||||
srv, err := m.GetServer(ctx, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure document is open
|
||||
err = m.ensureDocumentOpen(ctx, srv, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := ReferenceParams{
|
||||
TextDocumentPositionParams: TextDocumentPositionParams{
|
||||
TextDocument: TextDocumentIdentifier{
|
||||
URI: fileToURI(file),
|
||||
},
|
||||
Position: Position{
|
||||
Line: line - 1,
|
||||
Character: col - 1,
|
||||
},
|
||||
},
|
||||
Context: ReferenceContext{
|
||||
IncludeDeclaration: includeDeclaration,
|
||||
},
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
|
||||
resp, err := srv.client.Call(ctx, "textDocument/references", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("references request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Result == nil || string(resp.Result) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var locations []Location
|
||||
if err := json.Unmarshal(resp.Result, &locations); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse references result: %w", err)
|
||||
}
|
||||
|
||||
return locations, nil
|
||||
}
|
||||
|
||||
// ensureDocumentOpen opens a document if not already open.
|
||||
func (m *Manager) ensureDocumentOpen(ctx context.Context, srv *ManagedServer, file string) error {
|
||||
uri := fileToURI(file)
|
||||
|
||||
srv.mu.Lock()
|
||||
if _, ok := srv.openDocs[uri]; ok {
|
||||
srv.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
srv.mu.Unlock()
|
||||
|
||||
// Read file content
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
// Get language ID
|
||||
langID := languageToLSPID(srv.language)
|
||||
|
||||
params := DidOpenTextDocumentParams{
|
||||
TextDocument: TextDocumentItem{
|
||||
URI: uri,
|
||||
LanguageID: langID,
|
||||
Version: 1,
|
||||
Text: string(content),
|
||||
},
|
||||
}
|
||||
|
||||
if err := srv.client.Notify("textDocument/didOpen", params); err != nil {
|
||||
return fmt.Errorf("didOpen failed: %w", err)
|
||||
}
|
||||
|
||||
srv.mu.Lock()
|
||||
srv.openDocs[uri] = 1
|
||||
srv.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseDocument closes a document in the server.
|
||||
func (m *Manager) CloseDocument(_ context.Context, lang protocol.Language, file string) error {
|
||||
m.mu.RLock()
|
||||
srv, ok := m.servers[lang]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !ok || !srv.ready {
|
||||
return nil
|
||||
}
|
||||
|
||||
uri := fileToURI(file)
|
||||
|
||||
srv.mu.Lock()
|
||||
if _, ok := srv.openDocs[uri]; !ok {
|
||||
srv.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
delete(srv.openDocs, uri)
|
||||
srv.mu.Unlock()
|
||||
|
||||
params := DidCloseTextDocumentParams{
|
||||
TextDocument: TextDocumentIdentifier{
|
||||
URI: uri,
|
||||
},
|
||||
}
|
||||
|
||||
return srv.client.Notify("textDocument/didClose", params)
|
||||
}
|
||||
|
||||
// reapIdleServers periodically closes idle servers.
|
||||
func (m *Manager) reapIdleServers() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-m.stopReaper:
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.mu.Lock()
|
||||
for lang, srv := range m.servers {
|
||||
// Check lastUsed with server's lock to avoid race condition
|
||||
srv.mu.Lock()
|
||||
idle := time.Since(srv.lastUsed) > m.idleTimeout
|
||||
srv.mu.Unlock()
|
||||
|
||||
if idle {
|
||||
m.logger.Info("closing idle LSP server", "language", lang)
|
||||
_ = srv.client.Close()
|
||||
delete(m.servers, lang)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down all LSP servers.
|
||||
func (m *Manager) Close() error {
|
||||
close(m.stopReaper)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.stopped = true
|
||||
|
||||
for lang, srv := range m.servers {
|
||||
m.logger.Info("shutting down LSP server", "language", lang)
|
||||
// Try graceful shutdown
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_, _ = srv.client.Call(ctx, "shutdown", nil)
|
||||
cancel()
|
||||
_ = srv.client.Notify("exit", nil)
|
||||
_ = srv.client.Close()
|
||||
}
|
||||
|
||||
m.servers = make(map[protocol.Language]*ManagedServer)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsAvailable checks if an LSP server is available for the given language.
|
||||
func (m *Manager) IsAvailable(lang protocol.Language) bool {
|
||||
config, ok := DefaultServerConfigs[lang]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
_, err := exec.LookPath(config.Command[0])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// fileToURI converts a file path to a file URI.
|
||||
func fileToURI(file string) string {
|
||||
absPath, err := filepath.Abs(file)
|
||||
if err != nil {
|
||||
return "file://" + file
|
||||
}
|
||||
return "file://" + absPath
|
||||
}
|
||||
|
||||
// URIToFile converts a file URI to a file path.
|
||||
func URIToFile(uri string) string {
|
||||
if len(uri) > 7 && uri[:7] == "file://" {
|
||||
return uri[7:]
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
// languageToLSPID converts a language to LSP language ID.
|
||||
func languageToLSPID(lang protocol.Language) string {
|
||||
switch lang {
|
||||
case protocol.LangGo:
|
||||
return "go"
|
||||
case protocol.LangTypeScript:
|
||||
return "typescript"
|
||||
case protocol.LangJavaScript:
|
||||
return "javascript"
|
||||
case protocol.LangPython:
|
||||
return "python"
|
||||
case protocol.LangC:
|
||||
return "c"
|
||||
case protocol.LangCpp:
|
||||
return "cpp"
|
||||
default:
|
||||
return string(lang)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
|
||||
func TestFileToURI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "absolute path",
|
||||
file: "/Users/test/file.go",
|
||||
want: "file:///Users/test/file.go",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := fileToURI(tt.file)
|
||||
if got != tt.want {
|
||||
t.Errorf("fileToURI() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURIToFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uri string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "file uri",
|
||||
uri: "file:///Users/test/file.go",
|
||||
want: "/Users/test/file.go",
|
||||
},
|
||||
{
|
||||
name: "not a file uri",
|
||||
uri: "/Users/test/file.go",
|
||||
want: "/Users/test/file.go",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := URIToFile(tt.uri)
|
||||
if got != tt.want {
|
||||
t.Errorf("URIToFile() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageToLSPID(t *testing.T) {
|
||||
tests := []struct {
|
||||
lang protocol.Language
|
||||
want string
|
||||
}{
|
||||
{protocol.LangGo, "go"},
|
||||
{protocol.LangTypeScript, "typescript"},
|
||||
{protocol.LangJavaScript, "javascript"},
|
||||
{protocol.LangPython, "python"},
|
||||
{protocol.LangC, "c"},
|
||||
{protocol.LangCpp, "cpp"},
|
||||
{protocol.LangUnknown, "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.lang), func(t *testing.T) {
|
||||
got := languageToLSPID(tt.lang)
|
||||
if got != tt.want {
|
||||
t.Errorf("languageToLSPID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAvailable(t *testing.T) {
|
||||
// This tests the structure of the manager without actually spawning servers
|
||||
// which requires the actual LSP servers to be installed
|
||||
|
||||
// Just verify the DefaultServerConfigs structure
|
||||
expectedLanguages := []protocol.Language{
|
||||
protocol.LangGo,
|
||||
protocol.LangTypeScript,
|
||||
protocol.LangJavaScript,
|
||||
protocol.LangPython,
|
||||
protocol.LangC,
|
||||
protocol.LangCpp,
|
||||
}
|
||||
|
||||
for _, lang := range expectedLanguages {
|
||||
if _, ok := DefaultServerConfigs[lang]; !ok {
|
||||
t.Errorf("missing server config for language: %s", lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultServerConfigs(t *testing.T) {
|
||||
// Verify the command structure
|
||||
for lang, config := range DefaultServerConfigs {
|
||||
if len(config.Command) == 0 {
|
||||
t.Errorf("language %s has empty command", lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package lsp
|
||||
|
||||
// InitializeParams are the parameters for the initialize request.
|
||||
type InitializeParams struct {
|
||||
RootURI string `json:"rootUri"`
|
||||
Capabilities Capabilities `json:"capabilities"`
|
||||
ProcessID int `json:"processId"`
|
||||
}
|
||||
|
||||
// Capabilities represents client capabilities.
|
||||
type Capabilities struct {
|
||||
TextDocument TextDocumentClientCapabilities `json:"textDocument"`
|
||||
}
|
||||
|
||||
// TextDocumentClientCapabilities represents text document capabilities.
|
||||
type TextDocumentClientCapabilities struct {
|
||||
Hover HoverCapability `json:"hover,omitempty"`
|
||||
Definition DefinitionCapability `json:"definition,omitempty"`
|
||||
References ReferencesCapability `json:"references,omitempty"`
|
||||
}
|
||||
|
||||
// HoverCapability represents hover capabilities.
|
||||
type HoverCapability struct {
|
||||
ContentFormat []string `json:"contentFormat,omitempty"`
|
||||
}
|
||||
|
||||
// DefinitionCapability represents definition capabilities.
|
||||
type DefinitionCapability struct {
|
||||
LinkSupport bool `json:"linkSupport,omitempty"`
|
||||
}
|
||||
|
||||
// ReferencesCapability represents references capabilities.
|
||||
type ReferencesCapability struct{}
|
||||
|
||||
// InitializeResult is the result of the initialize request.
|
||||
type InitializeResult struct {
|
||||
Capabilities ServerCapabilities `json:"capabilities"`
|
||||
}
|
||||
|
||||
// ServerCapabilities represents server capabilities.
|
||||
type ServerCapabilities struct {
|
||||
HoverProvider bool `json:"hoverProvider,omitempty"`
|
||||
DefinitionProvider bool `json:"definitionProvider,omitempty"`
|
||||
ReferencesProvider bool `json:"referencesProvider,omitempty"`
|
||||
DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"`
|
||||
TextDocumentSync int `json:"textDocumentSync,omitempty"`
|
||||
}
|
||||
|
||||
// Position represents a position in a document.
|
||||
type Position struct {
|
||||
Line int `json:"line"` // 0-indexed
|
||||
Character int `json:"character"` // 0-indexed
|
||||
}
|
||||
|
||||
// Range represents a range in a document.
|
||||
type Range struct {
|
||||
Start Position `json:"start"`
|
||||
End Position `json:"end"`
|
||||
}
|
||||
|
||||
// Location represents a location in a document.
|
||||
type Location struct {
|
||||
URI string `json:"uri"`
|
||||
Range Range `json:"range"`
|
||||
}
|
||||
|
||||
// TextDocumentIdentifier identifies a text document.
|
||||
type TextDocumentIdentifier struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
// TextDocumentPositionParams represents position parameters.
|
||||
type TextDocumentPositionParams struct {
|
||||
TextDocument TextDocumentIdentifier `json:"textDocument"`
|
||||
Position Position `json:"position"`
|
||||
}
|
||||
|
||||
// HoverParams are the parameters for the hover request.
|
||||
type HoverParams struct {
|
||||
TextDocumentPositionParams
|
||||
}
|
||||
|
||||
// HoverResult is the result of the hover request.
|
||||
type HoverResult struct {
|
||||
Range *Range `json:"range,omitempty"`
|
||||
Contents MarkupContent `json:"contents"`
|
||||
}
|
||||
|
||||
// MarkupContent represents markup content.
|
||||
type MarkupContent struct {
|
||||
Kind string `json:"kind"` // "plaintext" or "markdown"
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// DefinitionParams are the parameters for the definition request.
|
||||
type DefinitionParams struct {
|
||||
TextDocumentPositionParams
|
||||
}
|
||||
|
||||
// ReferenceParams are the parameters for the references request.
|
||||
type ReferenceParams struct {
|
||||
TextDocumentPositionParams
|
||||
Context ReferenceContext `json:"context"`
|
||||
}
|
||||
|
||||
// ReferenceContext represents reference context.
|
||||
type ReferenceContext struct {
|
||||
IncludeDeclaration bool `json:"includeDeclaration"`
|
||||
}
|
||||
|
||||
// TextDocumentItem represents a text document.
|
||||
type TextDocumentItem struct {
|
||||
URI string `json:"uri"`
|
||||
LanguageID string `json:"languageId"`
|
||||
Text string `json:"text"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
// DidOpenTextDocumentParams are the parameters for didOpen.
|
||||
type DidOpenTextDocumentParams struct {
|
||||
TextDocument TextDocumentItem `json:"textDocument"`
|
||||
}
|
||||
|
||||
// DidCloseTextDocumentParams are the parameters for didClose.
|
||||
type DidCloseTextDocumentParams struct {
|
||||
TextDocument TextDocumentIdentifier `json:"textDocument"`
|
||||
}
|
||||
|
||||
// DocumentSymbol represents a symbol in a document.
|
||||
type DocumentSymbol struct {
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Children []DocumentSymbol `json:"children,omitempty"`
|
||||
Range Range `json:"range"`
|
||||
SelectionRange Range `json:"selectionRange"`
|
||||
Kind int `json:"kind"`
|
||||
}
|
||||
|
||||
// SymbolInformation represents symbol information.
|
||||
type SymbolInformation struct {
|
||||
Name string `json:"name"`
|
||||
ContainerName string `json:"containerName,omitempty"`
|
||||
Location Location `json:"location"`
|
||||
Kind int `json:"kind"`
|
||||
}
|
||||
|
||||
// DocumentSymbolParams are the parameters for documentSymbol.
|
||||
type DocumentSymbolParams struct {
|
||||
TextDocument TextDocumentIdentifier `json:"textDocument"`
|
||||
}
|
||||
Reference in New Issue
Block a user