diff --git a/README.md b/README.md index 350bbc2..7578459 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # MCP Language Server -A Model Context Protocol (MCP) server that runs a language server and provides tools for communicating with it. +**Note:** This repository is a fork of [isaacphi/mcp-language-server](https://github.com/isaacphi/mcp-language-server). It has undergone significant architectural changes (supporting multiple language servers in a single process via a configuration file) and is not intended for merging back into the original repository. -## Motivation +A Model Context Protocol (MCP) server that manages multiple language servers for different programming languages within a single workspace. It provides tools for communicating with the appropriate language server based on file context or explicit language specification. -Claude desktop with the [filesystem](https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem) server feels like magic when working on small projects. This starts to fall apart after you add a few files and imports. With this project, I want to create that experience when working with large projects. +## Motivation -Language servers excel at tasks that LLMs often struggle with, such as precisely understanding types, understanding relationships, and providing accurate symbol references. This project aims to makes bring those tools to LLMs. LSP also seems like a clear inspiration for MCP so why not jam them together? +Language servers excel at tasks that LLMs often struggle with, such as precisely understanding types, navigating complex codebases, and providing accurate symbol references across large projects. This project aims to bring the power of multiple language servers to LLMs through a unified MCP interface, enabling more sophisticated code understanding and manipulation capabilities. ## Status @@ -23,16 +23,18 @@ But it should be compatible with many more. ## Tools -- `read_definition`: Retrieves the complete source code definition of any symbol (function, type, constant, etc.) from your codebase. -- `find_references`: Locates all usages and references of a symbol throughout the codebase. -- `get_diagnostics`: Provides diagnostic information for a specific file, including warnings and errors. -- `get_codelens`: Retrieves code lens hints for additional context and actions on your code. -- `execute_codelens`: Runs a code lens action. -- `apply_text_edit`: Allows making multiple text edits to a file programmatically. +This server provides the following tools, automatically routing requests to the appropriate language server based on file extensions (for file-based tools) or an explicit `language` argument. + +- `read_definition`: Retrieves the complete source code definition of a symbol. **Requires a `language` argument** (e.g., `"typescript"`, `"go"`) to specify which language server to query. +- `find_references`: Locates all usages and references of a symbol. **Requires a `language` argument** (e.g., `"typescript"`, `"go"`) to specify which language server to query. +- `get_diagnostics`: Provides diagnostic information for a specific file (language determined by file extension). +- `get_codelens`: Retrieves code lens hints for a specific file (language determined by file extension). +- `execute_codelens`: Runs a code lens action for a specific file (language determined by file extension). +- `apply_text_edit`: Allows making multiple text edits to a file programmatically (language determined by file extension). Supports simple insert/delete/replace, regex-based replacement (using `isRegex`, `regexPattern`, `regexReplace`), and optional bracket balance protection (using `preserveBrackets`, `bracketTypes`) to prevent edits that break pairs like `()`, `{}`, `[]`. -Behind the scenes, this MCP server can act on `workspace/applyEdit` requests from the language server, so it can apply things like refactor requests, adding imports, formatting code, etc. +Behind the scenes, this MCP server can act on `workspace/applyEdit` requests from the language servers, enabling features like refactoring, adding imports, and code formatting. -Each tool supports various options for customizing output, such as including line numbers or additional context. See the tool documentation for detailed usage. Line numbers are necessary for `apply_text_edit` to be able to make accurate edits. +Most tools support options like `showLineNumbers`. Refer to the tool schemas for detailed usage. ## About @@ -42,17 +44,9 @@ This codebase makes use of edited code from [gopls](https://go.googlesource.com/ ## Prerequisites -Install Go: Follow instructions at - -Fetch or update this server: - -```bash -go install github.com/isaacphi/mcp-language-server@latest -``` - -Install a language server for your codebase: - -- Python (pyright): `npm install -g pyright` +1. **Install Go:** Follow instructions at +2. **Install Language Servers:** Install the language servers for the languages you want to use in your project. Examples: + - Python (pyright): `npm install -g pyright` - TypeScript (tsserver): `npm install -g typescript typescript-language-server` - Go (gopls): `go install golang.org/x/tools/gopls@latest` - Rust (rust-analyzer): `rustup component add rust-analyzer` @@ -60,38 +54,73 @@ Install a language server for your codebase: ## Setup -Add something like the following configuration to your Claude Desktop settings (or similar MCP-enabled client): - -```json -{ - "mcpServers": { - "language-server": { - "command": "go", - "args": [ - "run", - "github.com/isaacphi/mcp-language-server@latest", - "--workspace", - "/Users/you/dev/yourcodebase", - "--lsp", - "/opt/homebrew/bin/pyright-langserver", - "--", - "--stdio" - ], - "env": { - "DEBUG": "1" +1. **Build the Server:** + Clone the repository and build the executable: + ```bash + git clone https://github.com/isaacphi/mcp-language-server.git + cd mcp-language-server + go build -o mcp-language-server . + ``` + +2. **Create Configuration File (`config.json`):** + Create a JSON configuration file (e.g., `config.json` in the project root or another location) to define the language servers you want to manage. + + **`config.json` Example:** + ```json + { + "workspaceDir": "/Users/you/dev/yourcodebase", // Absolute path to your project root + "languageServers": [ + { + "language": "typescript", // Unique name for the language + "command": "typescript-language-server", // Command to run the LSP server + "args": ["--stdio"], // Arguments for the LSP command + "extensions": [".ts", ".tsx", ".js", ".jsx"] // File extensions for this language + }, + { + "language": "go", + "command": "/path/to/your/gopls", // Absolute path or command name for gopls + "args": [], + "extensions": [".go"] + }, + { + "language": "python", + "command": "pyright-langserver", + "args": ["--stdio"], + "extensions": [".py"] + } + // Add entries for other languages as needed + ] + } + ``` + - Replace `/Users/you/dev/yourcodebase` with the absolute path to your project. + - Replace `/path/to/your/gopls` etc. with the correct command or absolute path for each language server. + +3. **Configure MCP Client:** + Add the following configuration to your Claude Desktop settings (or similar MCP-enabled client), adjusting paths as necessary: + + ```json + { + "mcpServers": { + "mcp-language-server": { // You can choose any name here + "command": "/full/path/to/your/clone/mcp-language-server/mcp-language-server", // Absolute path to the built executable + "args": [ + "--config", "/full/path/to/your/config.json" // Absolute path to your config.json + ], + "cwd": "/full/path/to/your/clone/mcp-language-server", // Optional: Set working directory to project root + "env": { + // Add any necessary environment variables for LSP servers (e.g., PATH) + // "PATH": "...", + "DEBUG": "1" // Optional: Enable debug logging + } + } + // Add other MCP servers here if needed } } - } -} -``` - -Replace: - -- `/Users/you/dev/yourcodebase` with the absolute path to your project -- `/opt/homebrew/bin/pyright-langserver` with the path to your language server (found using `which` command e.g. `which pyright-langserver`) -- Any aruments after `--` are sent as arguments to your language server. -- Any env variables are passed on to the language server. Some may be necessary for you language server. For example, `gopls` required `GOPATH` and `GOCACHE` in order for me to get it working properly. -- `DEBUG=1` is optional. See below. + ``` + - Ensure the `command` path points to the `mcp-language-server` executable you built. + - Ensure the `--config` argument points to the `config.json` file you created. + - Set `cwd` if necessary (usually the directory containing the executable). + - Add required environment variables (like `PATH` if using shims like `asdf`) to the `env` object. ## Development @@ -99,7 +128,7 @@ Clone the repository: ```bash git clone https://github.com/isaacphi/mcp-language-server.git -cd mcp-language-server +forcd mcp-language-server ``` Install dev dependencies: @@ -114,28 +143,27 @@ Build: go build ``` -Configure your Claude Desktop (or similar) to use the local binary: +Configure your Claude Desktop (or similar) to use the local binary, similar to the Setup section, ensuring the `command` points to your locally built executable and the `--config` argument points to your development `config.json`: ```json { "mcpServers": { - "language-server": { - "command": "/full/path/to/your/clone/mcp-language-server/mcp-language-server", + "mcp-language-dev": { // Example name for development server + "command": "/full/path/to/your/clone/mcp-language-server/mcp-language-server", // Path to your built binary "args": [ - "--workspace", - "/path/to/workspace", - "--lsp", - "/path/to/language/server" + "--config", "/full/path/to/your/clone/mcp-language-server/config.dev.json" // Path to your development config file ], + "cwd": "/full/path/to/your/clone/mcp-language-server", // Working directory "env": { - "DEBUG": "1" + "DEBUG": "1" // Enable debug logging for development } } } } ``` +Remember to create a `config.dev.json` (or similar) for your development environment. -Rebuild after making changes. +Rebuild (`go build -o mcp-language-server .`) after making code changes. ## Feedback diff --git a/config.json b/config.json new file mode 100644 index 0000000..74ab82a --- /dev/null +++ b/config.json @@ -0,0 +1,26 @@ +{ + "workspaceDir": "/path/to/workspace", + "languageServers": [ + { + "language": "typescript", + "command": "typescript-language-server", + "args": [ + "--stdio" + ], + "extensions": [ + ".ts", + ".tsx", + ".js", + ".jsx" + ] + }, + { + "language": "go", + "command": "gopls", + "args": [], + "extensions": [ + ".go" + ] + } + ] +} diff --git a/go.mod b/go.mod index c5f73ce..915b652 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,13 @@ require ( github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/kisielk/errcheck v1.9.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect diff --git a/go.sum b/go.sum index dee450f..ef02d09 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= diff --git a/internal/lsp/client.go b/internal/lsp/client.go index 3cbbe43..34d22c7 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -3,7 +3,7 @@ package lsp import ( "bufio" "context" - "encoding/json" + "encoding/json" // Keep for potential future use within client.go "fmt" "io" "log" @@ -14,9 +14,11 @@ import ( "sync/atomic" "time" + // Import the protocol package "github.com/isaacphi/mcp-language-server/internal/protocol" ) +// Client manages the connection and state for an LSP server. type Client struct { Cmd *exec.Cmd stdin io.WriteCloser @@ -26,30 +28,46 @@ type Client struct { // Request ID counter nextID atomic.Int32 - // Response handlers - handlers map[int32]chan *Message + // Response handlers (Managed in transport.go) + handlers map[int32]chan *Message // Use Message from this package (lsp) handlersMu sync.RWMutex - // Server request handlers - serverRequestHandlers map[string]ServerRequestHandler + // Server request handlers (Managed via Register method) + serverRequestHandlers map[string]ServerRequestHandler // Use ServerRequestHandler from transport.go serverHandlersMu sync.RWMutex - // Notification handlers - notificationHandlers map[string]NotificationHandler + // Notification handlers (Managed via Register method) + notificationHandlers map[string]NotificationHandler // Use NotificationHandler from transport.go notificationMu sync.RWMutex // Diagnostic cache - diagnostics map[protocol.DocumentUri][]protocol.Diagnostic + diagnostics map[protocol.DocumentUri][]protocol.Diagnostic // Use protocol types diagnosticsMu sync.RWMutex // Files are currently opened by the LSP openFiles map[string]*OpenFileInfo openFilesMu sync.RWMutex + + // Debug flag + debug bool +} + +// OpenFileInfo stores information about an open file. +type OpenFileInfo struct { + Version int32 + URI protocol.DocumentUri // Use protocol.DocumentUri +} + +// --- Helper Functions --- + +// Ptr returns a pointer to the given value. Useful for optional boolean fields. +func Ptr[T any](v T) *T { + return &v } +// NewClient creates and starts a new LSP client. func NewClient(command string, args ...string) (*Client, error) { cmd := exec.Command(command, args...) - // Copy env cmd.Env = os.Environ() stdin, err := cmd.StdinPipe() @@ -72,19 +90,18 @@ func NewClient(command string, args ...string) (*Client, error) { 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), + handlers: make(map[int32]chan *Message), // Use Message from this package + notificationHandlers: make(map[string]NotificationHandler), // Use NotificationHandler from transport.go + serverRequestHandlers: make(map[string]ServerRequestHandler), // Use ServerRequestHandler from transport.go + diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic), // Use protocol types openFiles: make(map[string]*OpenFileInfo), + debug: os.Getenv("MCP_LSP_DEBUG") == "true", } - // 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() { @@ -95,89 +112,118 @@ func NewClient(command string, args ...string) (*Client, error) { } }() - // Start message handling loop + // handleMessages is defined in transport.go, start it here go client.handleMessages() return client, nil } +// RegisterNotificationHandler registers a handler for a specific notification method. +// Assumes NotificationHandler type is defined in transport.go func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) { c.notificationMu.Lock() defer c.notificationMu.Unlock() c.notificationHandlers[method] = handler } +// RegisterServerRequestHandler registers a handler for a specific server request method. +// Assumes ServerRequestHandler type is defined in transport.go func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) { c.serverHandlersMu.Lock() defer c.serverHandlersMu.Unlock() c.serverRequestHandlers[method] = handler } +// InitializeLSPClient sends the initialize request and initialized notification. +// This method orchestrates the initialization sequence using methods defined elsewhere. func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) { + rootURI := "file://" + workspaceDir + // Corrected: Trace field is *protocol.TraceValue + traceValue := protocol.TraceValue("off") + tracePtr := &traceValue + + // Prepare SymbolKind ValueSet + symbolKinds := []protocol.SymbolKind{ + protocol.File, protocol.Module, protocol.Namespace, protocol.Package, protocol.Class, protocol.Method, protocol.Property, protocol.Field, protocol.Constructor, + protocol.Enum, protocol.Interface, protocol.Function, protocol.Variable, protocol.Constant, protocol.String, protocol.Number, protocol.Boolean, protocol.Array, + protocol.Object, protocol.Key, protocol.Null, protocol.EnumMember, protocol.Struct, protocol.Event, protocol.Operator, protocol.TypeParameter, + } + initParams := &protocol.InitializeParams{ WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{ WorkspaceFolders: []protocol.WorkspaceFolder{ { - URI: protocol.URI("file://" + workspaceDir), + URI: protocol.URI(rootURI), Name: workspaceDir, }, }, }, - XInitializeParams: protocol.XInitializeParams{ ProcessID: int32(os.Getpid()), ClientInfo: &protocol.ClientInfo{ Name: "mcp-language-server", - Version: "0.1.0", + Version: "0.1.0", // Consider making version dynamic }, - RootPath: workspaceDir, - RootURI: protocol.DocumentUri("file://" + workspaceDir), + RootURI: protocol.DocumentUri(rootURI), Capabilities: protocol.ClientCapabilities{ + // Corrected: Workspace field is protocol.WorkspaceClientCapabilities (not pointer) Workspace: protocol.WorkspaceClientCapabilities{ - Configuration: true, + ApplyEdit: true, // bool + WorkspaceEdit: &protocol.WorkspaceEditClientCapabilities{ + DocumentChanges: true, // bool + }, + // Configuration: true, // Deprecated + // Corrected: DidChangeConfiguration is protocol.DidChangeConfigurationClientCapabilities (not pointer) DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{ - DynamicRegistration: true, + DynamicRegistration: false, // bool }, - DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{ - DynamicRegistration: true, - RelativePatternSupport: true, + Symbol: &protocol.WorkspaceSymbolClientCapabilities{ + DynamicRegistration: false, // bool + // Corrected: SymbolKind field type is *protocol.ClientSymbolKindOptions + SymbolKind: &protocol.ClientSymbolKindOptions{ + ValueSet: symbolKinds, + }, }, }, + // Corrected: TextDocument field is protocol.TextDocumentClientCapabilities (not pointer) TextDocument: protocol.TextDocumentClientCapabilities{ Synchronization: &protocol.TextDocumentSyncClientCapabilities{ - DynamicRegistration: true, - DidSave: true, - }, - Completion: protocol.CompletionClientCapabilities{ - CompletionItem: protocol.ClientCompletionItemOptions{}, + DynamicRegistration: false, // bool + WillSave: false, // bool + WillSaveWaitUntil: false, // bool + DidSave: true, // bool }, - CodeLens: &protocol.CodeLensClientCapabilities{ - DynamicRegistration: true, + Rename: &protocol.RenameClientCapabilities{ + DynamicRegistration: false, // bool + PrepareSupport: false, // bool }, - DocumentSymbol: protocol.DocumentSymbolClientCapabilities{}, - CodeAction: protocol.CodeActionClientCapabilities{ - CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{ - CodeActionKind: protocol.ClientCodeActionKindOptions{ - ValueSet: []protocol.CodeActionKind{}, - }, + // Corrected: DocumentSymbol is protocol.DocumentSymbolClientCapabilities (not pointer) + DocumentSymbol: protocol.DocumentSymbolClientCapabilities{ + DynamicRegistration: false, // bool + HierarchicalDocumentSymbolSupport: true, // bool + // Corrected: SymbolKind field type is *protocol.ClientSymbolKindOptions + SymbolKind: &protocol.ClientSymbolKindOptions{ + ValueSet: symbolKinds, }, }, - PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{ - VersionSupport: true, + CodeLens: &protocol.CodeLensClientCapabilities{ + // DynamicRegistration: Ptr(true), // Check protocol.go }, - SemanticTokens: protocol.SemanticTokensClientCapabilities{ - Requests: protocol.ClientSemanticTokensRequestOptions{ - Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{}, - Full: &protocol.Or_ClientSemanticTokensRequestOptions_full{}, + // Corrected: PublishDiagnostics is protocol.PublishDiagnosticsClientCapabilities (not pointer) + PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{ + // VersionSupport is bool + VersionSupport: false, + // Initialize embedded DiagnosticsCapabilities fields + DiagnosticsCapabilities: protocol.DiagnosticsCapabilities{ + RelatedInformation: false, // bool + // TagSupport: Ptr(...), // *ClientDiagnosticsTagOptions + // CodeDescriptionSupport: false, // bool + // DataSupport: false, // bool }, - TokenTypes: []string{}, - TokenModifiers: []string{}, - Formats: []protocol.TokenFormat{}, }, }, - Window: protocol.WindowClientCapabilities{}, }, - InitializationOptions: map[string]interface{}{ + InitializationOptions: map[string]any{ // Use any instead of interface{} "codelenses": map[string]bool{ "generate": true, "regenerate_cgo": true, @@ -188,77 +234,103 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) ( "vulncheck": false, }, }, + // Corrected: Trace field is *protocol.TraceValue + Trace: tracePtr, }, } + var result protocol.InitializeResult + // Call is defined in transport.go 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 { + // Initialized is defined in methods.go + if err := c.Initialized(ctx, protocol.InitializedParams{}); err != nil { return nil, fmt.Errorf("initialized notification failed: %w", err) } - // Register handlers + // Register handlers AFTER initialized notification + // Handlers are defined in server-request-handlers.go 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 - // } - } + func(params json.RawMessage) { HandleDiagnostics(c, params) }) // Pass client 'c' return &result, nil } + +// Close sends shutdown and exit messages to the LSP server and waits for it to terminate. 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) + c.CloseAllFiles(ctx) // Close tracked files first + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer shutdownCancel() + // Call is defined in transport.go + if err := c.Call(shutdownCtx, "shutdown", nil, nil); err != nil { + if c.debug { + log.Printf("Shutdown request failed (continuing): %v", err) + } + } + + exitCtx, exitCancel := context.WithTimeout(context.Background(), 1*time.Second) + defer exitCancel() + // Notify is defined in transport.go + if err := c.Notify(exitCtx, "exit", nil); err != nil { + if c.debug { + log.Printf("Exit notification failed (continuing): %v", err) + } + } - // Close stdin to signal the server - if err := c.stdin.Close(); err != nil { - return fmt.Errorf("failed to close stdin: %w", err) + // Close stdin pipe after sending exit + if c.stdin != nil { + if err := c.stdin.Close(); err != nil { + if c.debug { + log.Printf("Failed to close stdin: %v", err) + } + } + c.stdin = nil } - // Use a channel to handle the Wait with timeout + // Wait for the process to exit done := make(chan error, 1) go func() { - done <- c.Cmd.Wait() + if c.Cmd != nil && c.Cmd.Process != nil { + done <- c.Cmd.Wait() + } else { + done <- fmt.Errorf("process already exited or not started") + } }() - // Wait for process to exit with timeout select { case err := <-done: - return err + if exitErr, ok := err.(*exec.ExitError); ok { + if c.debug { + log.Printf("LSP process exited with status: %s", exitErr.Error()) + } + return nil // Expected exit after shutdown/exit + } + return err // Other wait error 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) + // Timeout waiting for exit, kill the process + if c.Cmd != nil && c.Cmd.Process != nil { + if err := c.Cmd.Process.Kill(); err != nil { + return fmt.Errorf("failed to kill process after timeout: %w", err) + } + return fmt.Errorf("process killed after timeout") } - return fmt.Errorf("process killed after timeout") + return fmt.Errorf("process wait timed out, but process was nil") } } +// ServerState represents the readiness state of the LSP server. type ServerState int const ( @@ -267,44 +339,72 @@ const ( StateError ) +// WaitForServerReady waits until the LSP server is likely ready by sending a simple request. func (c *Client) WaitForServerReady(ctx context.Context) error { - // TODO: wait for specific messages or poll workspace/symbol - time.Sleep(time.Second * 1) - return nil -} + timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second) // Adjust timeout as needed + defer cancel() -type OpenFileInfo struct { - Version int32 - URI protocol.DocumentUri + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-timeoutCtx.Done(): + return fmt.Errorf("timed out waiting for server readiness: %w", timeoutCtx.Err()) + case <-ticker.C: + // Use RequestWorkspaceSymbols defined below + _, err := c.RequestWorkspaceSymbols(timeoutCtx, protocol.WorkspaceSymbolParams{Query: ""}) + if err == nil { + if c.debug { + log.Println("LSP server reported ready.") + } + return nil // Success! + } + if c.debug { + log.Printf("Readiness check failed (will retry): %v", err) + } + // Continue loop on error + } + } } +// OpenFile notifies the LSP server that a file has been opened. func (c *Client) OpenFile(ctx context.Context, filepath string) error { - uri := fmt.Sprintf("file://%s", filepath) + uri := "file://" + filepath c.openFilesMu.Lock() if _, exists := c.openFiles[uri]; exists { c.openFilesMu.Unlock() + if c.debug { + log.Printf("File already open: %s", filepath) + } 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) + if c.debug { + log.Printf("Skipping open for non-existent/unreadable file %s: %v", filepath, err) + } + return nil // Treat as non-fatal } params := protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: protocol.DocumentUri(uri), - LanguageID: DetectLanguageID(uri), + LanguageID: DetectLanguageID(uri), // Assign protocol.LanguageKind directly Version: 1, Text: string(content), }, } + // Notify is defined in transport.go if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil { - return err + if c.debug { + log.Printf("Error sending didOpen notification for %s: %v", filepath, err) + } + return fmt.Errorf("didOpen notification failed for %s: %w", filepath, err) } c.openFilesMu.Lock() @@ -314,39 +414,54 @@ func (c *Client) OpenFile(ctx context.Context, filepath string) error { } c.openFilesMu.Unlock() - if debug { - log.Printf("Opened file: %s", filepath) + if c.debug { + log.Printf("Opened file: %s (Version 1)", filepath) } return nil } +// NotifyChange notifies the LSP server that a file has changed. func (c *Client) NotifyChange(ctx context.Context, filepath string) error { - uri := fmt.Sprintf("file://%s", filepath) + uri := "file://" + filepath content, err := os.ReadFile(filepath) if err != nil { - return fmt.Errorf("error reading file: %w", err) + if c.debug { + log.Printf("Skipping change notification for unreadable file %s: %v", filepath, err) + } + return nil // Don't error out if file disappeared } c.openFilesMu.Lock() fileInfo, isOpen := c.openFiles[uri] if !isOpen { c.openFilesMu.Unlock() - return fmt.Errorf("cannot notify change for unopened file: %s", filepath) + if c.debug { + log.Printf("File %s changed but wasn't tracked as open, attempting implicit open.", filepath) + } + openErr := c.OpenFile(ctx, filepath) // Try to open it first + if openErr != nil { + return fmt.Errorf("failed to implicitly open changed file %s: %w", filepath, openErr) + } + c.openFilesMu.Lock() // Re-acquire lock + fileInfo, isOpen = c.openFiles[uri] + if !isOpen { + c.openFilesMu.Unlock() // Should not happen, but handle defensively + return fmt.Errorf("failed to track file %s even after implicit open", filepath) + } } - // Increment version fileInfo.Version++ version := fileInfo.Version - c.openFilesMu.Unlock() + c.openFilesMu.Unlock() // Unlock after getting version params := protocol.DidChangeTextDocumentParams{ TextDocument: protocol.VersionedTextDocumentIdentifier{ TextDocumentIdentifier: protocol.TextDocumentIdentifier{ URI: protocol.DocumentUri(uri), }, - Version: version, + Version: int32(version), // protocol uses int32 }, ContentChanges: []protocol.TextDocumentContentChangeEvent{ { @@ -357,73 +472,193 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error { }, } - return c.Notify(ctx, "textDocument/didChange", params) + // Notify is defined in transport.go + if err := c.Notify(ctx, "textDocument/didChange", params); err != nil { + if c.debug { + log.Printf("Error sending didChange notification for %s (Version %d): %v", filepath, version, err) + } + // Consider reverting version increment on failure? For now, just return error. + return fmt.Errorf("didChange notification failed for %s: %w", filepath, err) + } + + if c.debug { + log.Printf("Notified change for file: %s (Version %d)", filepath, version) + } + return nil } +// CloseFile notifies the LSP server that a file has been closed. func (c *Client) CloseFile(ctx context.Context, filepath string) error { - uri := fmt.Sprintf("file://%s", filepath) + uri := "file://" + filepath c.openFilesMu.Lock() if _, exists := c.openFiles[uri]; !exists { c.openFilesMu.Unlock() + if c.debug { + log.Printf("File already closed or never opened: %s", filepath) + } return nil // Already closed } - c.openFilesMu.Unlock() + // Keep lock until delete + defer c.openFilesMu.Unlock() params := protocol.DidCloseTextDocumentParams{ TextDocument: protocol.TextDocumentIdentifier{ URI: protocol.DocumentUri(uri), }, } - log.Println("Closing", params.TextDocument.URI.Dir()) + if c.debug { + log.Printf("Closing file: %s", filepath) + } + // Notify is defined in transport.go if err := c.Notify(ctx, "textDocument/didClose", params); err != nil { - return err + if c.debug { + log.Printf("Error sending didClose notification for %s: %v", filepath, err) + } + // Continue to remove from tracking even if notify fails } - c.openFilesMu.Lock() delete(c.openFiles, uri) - c.openFilesMu.Unlock() + + // Also clear diagnostics for the closed file + c.diagnosticsMu.Lock() + delete(c.diagnostics, protocol.DocumentUri(uri)) + c.diagnosticsMu.Unlock() return nil } +// IsFileOpen checks if the client is currently tracking the file as open. func (c *Client) IsFileOpen(filepath string) bool { - uri := fmt.Sprintf("file://%s", filepath) + uri := "file://" + filepath c.openFilesMu.RLock() defer c.openFilesMu.RUnlock() _, exists := c.openFiles[uri] return exists } -// CloseAllFiles closes all currently open files +// CloseAllFiles attempts to close all files currently tracked as open. 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() + c.openFilesMu.Unlock() // Unlock before starting to close - // Then close them all + closedCount := 0 for _, filePath := range filesToClose { - err := c.CloseFile(ctx, filePath) - if err != nil && debug { - log.Printf("Error closing file %s: %v", filePath, err) + // Use a timeout for each close operation within the overall context + closeCtx, closeCancel := context.WithTimeout(ctx, 1*time.Second) + err := c.CloseFile(closeCtx, filePath) + closeCancel() // Release resources promptly + if err == nil { + closedCount++ + } else if c.debug { + log.Printf("Error closing file %s during CloseAllFiles: %v", filePath, err) } } - if debug { - log.Printf("Closed %d files", len(filesToClose)) + if c.debug { + log.Printf("Attempted to close %d files, successfully closed %d", len(filesToClose), closedCount) } } +// GetFileDiagnostics returns the cached diagnostics for a given file URI. func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnostic { c.diagnosticsMu.RLock() defer c.diagnosticsMu.RUnlock() - return c.diagnostics[uri] + diags, ok := c.diagnostics[uri] + if !ok || diags == nil { + return nil // Return nil explicitly if no diagnostics exist + } + // Return a copy to prevent external modification of the cache + diagsCopy := make([]protocol.Diagnostic, len(diags)) + copy(diagsCopy, diags) + return diagsCopy +} + +// --- NEW LSP Request Methods --- +// These methods use Call/Notify which are assumed to be defined in transport.go + +// RequestRename sends a textDocument/rename request. +func (c *Client) RequestRename(ctx context.Context, params protocol.RenameParams) (*protocol.WorkspaceEdit, error) { + var result protocol.WorkspaceEdit + if err := c.Call(ctx, "textDocument/rename", params, &result); err != nil { + return nil, fmt.Errorf("textDocument/rename failed: %w", err) + } + return &result, nil +} + +// RequestWorkspaceSymbols sends a workspace/symbol request. +func (c *Client) RequestWorkspaceSymbols(ctx context.Context, params protocol.WorkspaceSymbolParams) ([]protocol.SymbolInformation, error) { + var result []protocol.SymbolInformation + rawResult := json.RawMessage{} + if err := c.Call(ctx, "workspace/symbol", params, &rawResult); err != nil { + return nil, fmt.Errorf("workspace/symbol request failed: %w", err) + } + + if string(rawResult) == "null" { + return []protocol.SymbolInformation{}, nil // Return empty slice, not nil + } + + if err := json.Unmarshal(rawResult, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal workspace/symbol result: %w", err) + } + return result, nil } + +// RequestDocumentSymbols sends a textDocument/documentSymbol request. +func (c *Client) RequestDocumentSymbols(ctx context.Context, params protocol.DocumentSymbolParams) (any, error) { // Use any instead of interface{} + rawResult := json.RawMessage{} + if err := c.Call(ctx, "textDocument/documentSymbol", params, &rawResult); err != nil { + return nil, fmt.Errorf("textDocument/documentSymbol request failed: %w", err) + } + + if string(rawResult) == "null" { + return []protocol.DocumentSymbol{}, nil // Return empty slice of preferred type + } + + // Try hierarchical structure first + var docSymbols []protocol.DocumentSymbol + if err := json.Unmarshal(rawResult, &docSymbols); err == nil { + return docSymbols, nil + } + + // If hierarchical fails, try flat structure + var symbolInfo []protocol.SymbolInformation + if err := json.Unmarshal(rawResult, &symbolInfo); err == nil { + return symbolInfo, nil + } + + return nil, fmt.Errorf("failed to unmarshal textDocument/documentSymbol result into known structures") +} + + +// --- Assumed functions/types (defined elsewhere) --- + +// transport.go: +// func (c *Client) Call(ctx context.Context, method string, params interface{}, result interface{}) error +// func (c *Client) Notify(ctx context.Context, method string, params interface{}) error +// func (c *Client) handleMessages() +// type Message struct { ... } +// type NotificationHandler func(params json.RawMessage) +// type ServerRequestHandler func(params json.RawMessage) (interface{}, error) + +// methods.go: +// func (c *Client) Initialized(ctx context.Context, params protocol.InitializedParams) error + +// server-request-handlers.go: +// func HandleApplyEdit(params json.RawMessage) (interface{}, error) +// func HandleWorkspaceConfiguration(params json.RawMessage) (interface{}, error) +// func HandleRegisterCapability(params json.RawMessage) (interface{}, error) +// func HandleServerMessage(params json.RawMessage) +// func HandleDiagnostics(c *Client, params json.RawMessage) + +// detect-language.go: +// func DetectLanguageID(uri string) protocol.LanguageKind + +// protocol.go: +// (Contains various LSP type definitions like InitializeParams, ClientCapabilities, etc.) diff --git a/internal/lsp/protocol.go b/internal/lsp/protocol.go index 92983ba..9e9673d 100644 --- a/internal/lsp/protocol.go +++ b/internal/lsp/protocol.go @@ -46,3 +46,518 @@ func NewNotification(method string, params interface{}) (*Message, error) { Params: paramsJSON, }, nil } + +// --- Common LSP Types --- + +// TextDocumentIdentifier identifies a text document. +type TextDocumentIdentifier struct { + URI string `json:"uri"` +} + +// Position represents a position in a text document. +type Position struct { + Line int `json:"line"` // 0-based + Character int `json:"character"` // 0-based +} + +// Range represents a range in a text document. +type Range struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +// Location represents a location inside a resource, such as a line inside a text file. +type Location struct { + URI string `json:"uri"` + Range Range `json:"range"` +} + +// TextEdit represents a textual change applicable to a text document. +type TextEdit struct { + Range Range `json:"range"` + NewText string `json:"newText"` +} + +// WorkspaceEdit represents changes to many resources managed in the workspace. +type WorkspaceEdit struct { + Changes map[string][]TextEdit `json:"changes,omitempty"` + DocumentChanges []TextDocumentEdit `json:"documentChanges,omitempty"` // Use TextDocumentEdit for versioned changes +} + +// TextDocumentEdit represents edits to a specific text document. +type TextDocumentEdit struct { + TextDocument VersionedTextDocumentIdentifier `json:"textDocument"` + Edits []TextEdit `json:"edits"` +} + +// VersionedTextDocumentIdentifier identifies a specific version of a text document. +type VersionedTextDocumentIdentifier struct { + TextDocumentIdentifier + Version int `json:"version"` // Use integer for version +} + +// --- Initialize --- + +// InitializeParams parameters for the initialize request. +type InitializeParams struct { + ProcessID int `json:"processId,omitempty"` + RootURI string `json:"rootUri,omitempty"` // Use string for URI + Capabilities ClientCapabilities `json:"capabilities"` + InitializationOptions interface{} `json:"initializationOptions,omitempty"` + Trace string `json:"trace,omitempty"` // off | messages | verbose +} + +// ClientCapabilities capabilities provided by the client. +type ClientCapabilities struct { + // Define necessary client capabilities if needed, otherwise keep it minimal or empty. + Workspace *WorkspaceClientCapabilities `json:"workspace,omitempty"` + TextDocument *TextDocumentClientCapabilities `json:"textDocument,omitempty"` +} + +type WorkspaceClientCapabilities struct { + ApplyEdit *bool `json:"applyEdit,omitempty"` + WorkspaceEdit *WorkspaceEditClientCapabilities `json:"workspaceEdit,omitempty"` + DidChangeConfiguration *DidChangeConfigurationCapabilities `json:"didChangeConfiguration,omitempty"` + Symbol *WorkspaceSymbolClientCapabilities `json:"symbol,omitempty"` + // Add other workspace capabilities as needed +} + +type WorkspaceEditClientCapabilities struct { + DocumentChanges *bool `json:"documentChanges,omitempty"` + // Add other workspace edit capabilities as needed +} + +type DidChangeConfigurationCapabilities struct { + DynamicRegistration *bool `json:"dynamicRegistration,omitempty"` +} + +type TextDocumentClientCapabilities struct { + Completion *CompletionClientCapabilities `json:"completion,omitempty"` + Hover *HoverClientCapabilities `json:"hover,omitempty"` + SignatureHelp *SignatureHelpClientCapabilities `json:"signatureHelp,omitempty"` + References *ReferencesClientCapabilities `json:"references,omitempty"` + DocumentHighlight *DocumentHighlightClientCapabilities `json:"documentHighlight,omitempty"` + DocumentSymbol *DocumentSymbolClientCapabilities `json:"documentSymbol,omitempty"` + Formatting *FormattingClientCapabilities `json:"formatting,omitempty"` + RangeFormatting *RangeFormattingClientCapabilities `json:"rangeFormatting,omitempty"` + OnTypeFormatting *OnTypeFormattingClientCapabilities `json:"onTypeFormatting,omitempty"` + Definition *DefinitionClientCapabilities `json:"definition,omitempty"` + CodeAction *CodeActionClientCapabilities `json:"codeAction,omitempty"` + CodeLens *CodeLensClientCapabilities `json:"codeLens,omitempty"` + DocumentLink *DocumentLinkClientCapabilities `json:"documentLink,omitempty"` + Rename *RenameClientCapabilities `json:"rename,omitempty"` + PublishDiagnostics *PublishDiagnosticsClientCapabilities `json:"publishDiagnostics,omitempty"` + // Add other text document capabilities as needed +} + +// Define specific capability structures if needed, e.g.: +type CompletionClientCapabilities struct { + DynamicRegistration *bool `json:"dynamicRegistration,omitempty"` + CompletionItem *struct { + SnippetSupport *bool `json:"snippetSupport,omitempty"` + // ... other CompletionItem capabilities + } `json:"completionItem,omitempty"` + // ... other completion capabilities +} + +type HoverClientCapabilities struct { + DynamicRegistration *bool `json:"dynamicRegistration,omitempty"` + ContentFormat []string `json:"contentFormat,omitempty"` // e.g., ["markdown", "plaintext"] +} + +type SignatureHelpClientCapabilities struct { + // ... +} + +type ReferencesClientCapabilities struct { + // ... +} + +type DocumentHighlightClientCapabilities struct { + // ... +} + +type FormattingClientCapabilities struct { + // ... +} + +type RangeFormattingClientCapabilities struct { + // ... +} + +type OnTypeFormattingClientCapabilities struct { + // ... +} + +type DefinitionClientCapabilities struct { + // ... +} + +type CodeActionClientCapabilities struct { + // ... +} + +type CodeLensClientCapabilities struct { + // ... +} + +type DocumentLinkClientCapabilities struct { + // ... +} + +type PublishDiagnosticsClientCapabilities struct { + RelatedInformation *bool `json:"relatedInformation,omitempty"` + // ... other diagnostic capabilities +} + + +// InitializeResult result of the initialize request. +type InitializeResult struct { + Capabilities ServerCapabilities `json:"capabilities"` +} + +// ServerCapabilities capabilities provided by the server. +type ServerCapabilities struct { + TextDocumentSync *TextDocumentSyncOptions `json:"textDocumentSync,omitempty"` + CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"` + HoverProvider *bool `json:"hoverProvider,omitempty"` // Or HoverOptions + SignatureHelpProvider *SignatureHelpOptions `json:"signatureHelpProvider,omitempty"` + DefinitionProvider *bool `json:"definitionProvider,omitempty"` // Or DefinitionOptions + ReferencesProvider *bool `json:"referencesProvider,omitempty"` // Or ReferencesOptions + DocumentHighlightProvider *bool `json:"documentHighlightProvider,omitempty"` // Or DocumentHighlightOptions + DocumentSymbolProvider *bool `json:"documentSymbolProvider,omitempty"` // Or DocumentSymbolOptions + WorkspaceSymbolProvider *bool `json:"workspaceSymbolProvider,omitempty"` // Or WorkspaceSymbolOptions + CodeActionProvider *bool `json:"codeActionProvider,omitempty"` // Or CodeActionOptions + CodeLensProvider *CodeLensOptions `json:"codeLensProvider,omitempty"` + DocumentFormattingProvider *bool `json:"documentFormattingProvider,omitempty"` // Or DocumentFormattingOptions + DocumentRangeFormattingProvider *bool `json:"documentRangeFormattingProvider,omitempty"` // Or DocumentRangeFormattingOptions + DocumentOnTypeFormattingProvider *DocumentOnTypeFormattingOptions `json:"documentOnTypeFormattingProvider,omitempty"` + RenameProvider *bool `json:"renameProvider,omitempty"` // Or RenameOptions + DocumentLinkProvider *DocumentLinkOptions `json:"documentLinkProvider,omitempty"` + ExecuteCommandProvider *ExecuteCommandOptions `json:"executeCommandProvider,omitempty"` + Workspace *ServerWorkspaceCapabilities `json:"workspace,omitempty"` + // Add other server capabilities as needed +} + +type TextDocumentSyncOptions struct { + OpenClose *bool `json:"openClose,omitempty"` + Change *TextDocumentSyncKind `json:"change,omitempty"` // Use pointer to enum + // Add other sync options if needed +} + +type TextDocumentSyncKind int + +const ( + // TextDocumentSyncKindNone Documents should not be synced at all. + TextDocumentSyncKindNone TextDocumentSyncKind = 0 + // TextDocumentSyncKindFull Documents are synced by sending the full content of the document. + TextDocumentSyncKindFull TextDocumentSyncKind = 1 + // TextDocumentSyncKindIncremental Documents are synced by sending incremental updates. + TextDocumentSyncKindIncremental TextDocumentSyncKind = 2 +) + +type CompletionOptions struct { + ResolveProvider *bool `json:"resolveProvider,omitempty"` + TriggerCharacters []string `json:"triggerCharacters,omitempty"` + // Add other completion options if needed +} + +type SignatureHelpOptions struct { + TriggerCharacters []string `json:"triggerCharacters,omitempty"` + // Add other signature help options if needed +} + +type CodeLensOptions struct { + ResolveProvider *bool `json:"resolveProvider,omitempty"` +} + +type DocumentOnTypeFormattingOptions struct { + FirstTriggerCharacter string `json:"firstTriggerCharacter"` + MoreTriggerCharacter []string `json:"moreTriggerCharacter,omitempty"` +} + +type DocumentLinkOptions struct { + ResolveProvider *bool `json:"resolveProvider,omitempty"` +} + +type ExecuteCommandOptions struct { + Commands []string `json:"commands"` +} + +type ServerWorkspaceCapabilities struct { + WorkspaceFolders *WorkspaceFoldersServerCapabilities `json:"workspaceFolders,omitempty"` + // Add other server workspace capabilities if needed +} + +type WorkspaceFoldersServerCapabilities struct { + Supported *bool `json:"supported,omitempty"` + ChangeNotifications *bool `json:"changeNotifications,omitempty"` // Can be string ID or bool +} + +// --- Shutdown --- +// No specific params/result types needed for shutdown + +// --- Exit --- +// No specific params/result types needed for exit + +// --- Text Document Synchronization --- + +// DidOpenTextDocumentParams parameters for textDocument/didOpen notification. +type DidOpenTextDocumentParams struct { + TextDocument TextDocumentItem `json:"textDocument"` +} + +// TextDocumentItem describes a text document. +type TextDocumentItem struct { + URI string `json:"uri"` + LanguageID string `json:"languageId"` + Version int `json:"version"` + Text string `json:"text"` +} + +// DidChangeTextDocumentParams parameters for textDocument/didChange notification. +type DidChangeTextDocumentParams struct { + TextDocument VersionedTextDocumentIdentifier `json:"textDocument"` + ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"` +} + +// TextDocumentContentChangeEvent an event describing a change to a text document. +type TextDocumentContentChangeEvent struct { + // Range is the range of the document that changed. + // Optional: If not provided, the whole document content is replaced. + Range *Range `json:"range,omitempty"` + // RangeLength is the length of the range that got replaced. + // Optional: Only used if Range is provided. + RangeLength *int `json:"rangeLength,omitempty"` + // Text is the new text for the provided range or the whole document. + Text string `json:"text"` +} + + +// DidCloseTextDocumentParams parameters for textDocument/didClose notification. +type DidCloseTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} + +// DidSaveTextDocumentParams parameters for textDocument/didSave notification. +type DidSaveTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Text *string `json:"text,omitempty"` // Optional content on save +} + +// --- Diagnostics --- + +// PublishDiagnosticsParams parameters for textDocument/publishDiagnostics notification. +type PublishDiagnosticsParams struct { + URI string `json:"uri"` + Diagnostics []Diagnostic `json:"diagnostics"` + Version *int `json:"version,omitempty"` // Optional document version +} + +// Diagnostic represents a diagnostic, like a compiler error or warning. +type Diagnostic struct { + Range Range `json:"range"` + Severity *DiagnosticSeverity `json:"severity,omitempty"` // Use pointer + Code *DiagnosticCode `json:"code,omitempty"` // Use pointer or interface{} + Source *string `json:"source,omitempty"` + Message string `json:"message"` + Tags []DiagnosticTag `json:"tags,omitempty"` + RelatedInformation []DiagnosticRelatedInformation `json:"relatedInformation,omitempty"` + Data interface{} `json:"data,omitempty"` // Language server specific data +} + +// DiagnosticCode can be a number or string. Use interface{} or a custom type. +type DiagnosticCode interface{} // Or define a struct if structure is known + +// DiagnosticSeverity severity of a diagnostic. +type DiagnosticSeverity int + +const ( + SeverityError DiagnosticSeverity = 1 + SeverityWarning DiagnosticSeverity = 2 + SeverityInfo DiagnosticSeverity = 3 + SeverityHint DiagnosticSeverity = 4 +) + +// DiagnosticTag additional metadata about the diagnostic. +type DiagnosticTag int + +const ( + TagUnnecessary DiagnosticTag = 1 + TagDeprecated DiagnosticTag = 2 +) + +// DiagnosticRelatedInformation represents related diagnostic information. +type DiagnosticRelatedInformation struct { + Location Location `json:"location"` + Message string `json:"message"` +} + +// --- Code Lens --- + +// CodeLensParams parameters for textDocument/codeLens request. +type CodeLensParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} + +// CodeLens represents a command that should be shown along with source text. +type CodeLens struct { + Range Range `json:"range"` + Command *Command `json:"command,omitempty"` // Use pointer + Data interface{} `json:"data,omitempty"` // Optional data field +} + +// Command represents a command like 'run test' or 'apply fix'. +type Command struct { + Title string `json:"title"` + Command string `json:"command"` // Identifier of the command handler + Arguments []interface{} `json:"arguments,omitempty"` +} + +// --- References --- + +// ReferenceParams parameters for textDocument/references request. +type ReferenceParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` + Context ReferenceContext `json:"context"` +} + +// ReferenceContext context for reference request. +type ReferenceContext struct { + IncludeDeclaration bool `json:"includeDeclaration"` +} + +// --- Definition --- + +// DefinitionParams parameters for textDocument/definition request. +type DefinitionParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` +} + +// Definition result can be Location or []Location or LocationLink[] +// Using []Location for simplicity here, adjust if LocationLink is needed. +type Definition = []Location // Type alias for definition result + + +// --- NEW TYPES for Rename and Symbols --- + +// RenameParams parameters for textDocument/rename request. +type RenameParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` + NewName string `json:"newName"` +} + +// RenameClientCapabilities capabilities specific to the rename request. +type RenameClientCapabilities struct { + DynamicRegistration *bool `json:"dynamicRegistration,omitempty"` + PrepareSupport *bool `json:"prepareSupport,omitempty"` // Support for prepareRename request +} + +// RenameOptions server capabilities for rename requests. +type RenameOptions struct { + PrepareProvider *bool `json:"prepareProvider,omitempty"` +} + +// WorkspaceSymbolParams parameters for workspace/symbol request. +type WorkspaceSymbolParams struct { + Query string `json:"query"` +} + +// DocumentSymbolParams parameters for textDocument/documentSymbol request. +type DocumentSymbolParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} + +// SymbolKind kind of symbol. +type SymbolKind int + +const ( + File SymbolKind = 1 + Module SymbolKind = 2 + Namespace SymbolKind = 3 + Package SymbolKind = 4 + Class SymbolKind = 5 + Method SymbolKind = 6 + Property SymbolKind = 7 + Field SymbolKind = 8 + Constructor SymbolKind = 9 + Enum SymbolKind = 10 + Interface SymbolKind = 11 + Function SymbolKind = 12 + Variable SymbolKind = 13 + Constant SymbolKind = 14 + String SymbolKind = 15 + Number SymbolKind = 16 + Boolean SymbolKind = 17 + Array SymbolKind = 18 + Object SymbolKind = 19 + Key SymbolKind = 20 + Null SymbolKind = 21 + EnumMember SymbolKind = 22 + Struct SymbolKind = 23 + Event SymbolKind = 24 + Operator SymbolKind = 25 + TypeParameter SymbolKind = 26 +) + +// SymbolTag additional metadata about a symbol. +type SymbolTag int + +const ( + // SymbolTagDeprecated Render a symbol as obsolete, usually using a strike-out. + SymbolTagDeprecated SymbolTag = 1 +) + + +// SymbolInformation represents information about programming constructs like variables, classes, etc. +type SymbolInformation struct { + Name string `json:"name"` + Kind SymbolKind `json:"kind"` + Tags []SymbolTag `json:"tags,omitempty"` + Deprecated *bool `json:"deprecated,omitempty"` // Deprecated: Use tags instead + Location Location `json:"location"` + ContainerName *string `json:"containerName,omitempty"` // Name of the symbol containing this symbol. +} + +// DocumentSymbol represents programming constructs like variables, classes, interfaces etc. +// specific to a document. DocumentSymbols can be hierarchical. +type DocumentSymbol struct { + Name string `json:"name"` + Detail *string `json:"detail,omitempty"` // More detail for this symbol, e.g., function signature. + Kind SymbolKind `json:"kind"` + Tags []SymbolTag `json:"tags,omitempty"` + Deprecated *bool `json:"deprecated,omitempty"` // Deprecated: Use tags instead + Range Range `json:"range"` // Range encompassing this symbol. + SelectionRange Range `json:"selectionRange"` // Range that should be selected when revealing this symbol. + Children []DocumentSymbol `json:"children,omitempty"` // Children of this symbol, e.g., methods of a class. +} + +// WorkspaceSymbolClientCapabilities capabilities specific to workspace/symbol request. +type WorkspaceSymbolClientCapabilities struct { + DynamicRegistration *bool `json:"dynamicRegistration,omitempty"` + SymbolKind *struct { + ValueSet []SymbolKind `json:"valueSet,omitempty"` // Supported symbol kinds. + } `json:"symbolKind,omitempty"` +} + +// DocumentSymbolClientCapabilities capabilities specific to textDocument/documentSymbol request. +type DocumentSymbolClientCapabilities struct { + DynamicRegistration *bool `json:"dynamicRegistration,omitempty"` + SymbolKind *struct { + ValueSet []SymbolKind `json:"valueSet,omitempty"` // Supported symbol kinds. + } `json:"symbolKind,omitempty"` + HierarchicalDocumentSymbolSupport *bool `json:"hierarchicalDocumentSymbolSupport,omitempty"` // Supports hierarchical document symbols. +} + +// DocumentSymbolOptions server capabilities for document symbol requests. +type DocumentSymbolOptions struct { + // Server supports document symbol requests. +} + +// WorkspaceSymbolOptions server capabilities for workspace symbol requests. +type WorkspaceSymbolOptions struct { + // Server supports workspace symbol requests. +} diff --git a/internal/tools/apply-text-edit.go b/internal/tools/apply-text-edit.go index c09d394..058b42a 100644 --- a/internal/tools/apply-text-edit.go +++ b/internal/tools/apply-text-edit.go @@ -5,14 +5,31 @@ import ( "context" "fmt" "os" + "regexp" // Added for regex support "sort" "strings" + "path/filepath" // Added for absolute path conversion - "github.com/isaacphi/mcp-language-server/internal/lsp" "github.com/isaacphi/mcp-language-server/internal/protocol" "github.com/isaacphi/mcp-language-server/internal/utilities" ) +// FileOpener defines the interface required for opening files, typically implemented by lsp.Client. +type FileOpener interface { + OpenFile(ctx context.Context, filePath string) error +} + +// BracketGuardError represents an error when an edit violates bracket balancing rules. +type BracketGuardError struct { + ViolationType string `json:"violationType"` // e.g., "CrossingPair", "PartialPairStart", "PartialPairEnd" + Message string `json:"message"` + Suggestion *TextEdit `json:"suggestion,omitempty"` // Optional suggestion for a safe edit +} + +func (e *BracketGuardError) Error() string { + return fmt.Sprintf("Bracket balance violation (%s): %s", e.ViolationType, e.Message) +} + type TextEditType string const ( @@ -22,16 +39,30 @@ const ( ) type TextEdit struct { - Type TextEditType `json:"type" jsonschema:"required,enum=replace|insert|delete,description=Type of edit operation (replace, insert, delete)"` - StartLine int `json:"startLine" jsonschema:"required,description=Start line to replace, inclusive"` - EndLine int `json:"endLine" jsonschema:"required,description=End line to replace, inclusive"` - NewText string `json:"newText" jsonschema:"description=Replacement text. Leave blank to clear lines."` + Type TextEditType `json:"type" jsonschema:"required,enum=replace|insert|delete,description=Type of edit operation (replace, insert, delete)"` + StartLine int `json:"startLine" jsonschema:"required,description=Start line of the range, inclusive"` + EndLine int `json:"endLine" jsonschema:"required,description=End line of the range, inclusive"` + NewText string `json:"newText,omitempty" jsonschema:"description=Replacement text for non-regex replace/insert. Leave blank for delete."` + IsRegex bool `json:"isRegex,omitempty" jsonschema:"description=Whether to treat pattern as regex"` + RegexPattern string `json:"regexPattern,omitempty" jsonschema:"description=Regex pattern to search for within the range (if isRegex is true)"` + RegexReplace string `json:"regexReplace,omitempty" jsonschema:"description=Replacement string, supporting capture groups like $1 (if isRegex is true)"` + PreserveBrackets bool `json:"preserveBrackets,omitempty" jsonschema:"description=If true, check and prevent edits that break bracket pairs"` + BracketTypes []string `json:"bracketTypes,omitempty" jsonschema:"description=Types of brackets to check (e.g., '()', '{}', '[]'). Defaults if empty."` } -func ApplyTextEdits(ctx context.Context, client *lsp.Client, filePath string, edits []TextEdit) (string, error) { - err := client.OpenFile(ctx, filePath) +// ApplyTextEdits applies a series of text edits to a file. +// It now accepts a FileOpener interface instead of a concrete *lsp.Client. +func ApplyTextEdits(ctx context.Context, opener FileOpener, filePath string, edits []TextEdit) (string, error) { + // Ensure filePath is absolute + absFilePath, err := filepath.Abs(filePath) if err != nil { - return "", fmt.Errorf("could not open file: %v", err) + return "", fmt.Errorf("could not get absolute path for '%s': %w", filePath, err) + } + filePath = absFilePath // Use absolute path from now on + + err = opener.OpenFile(ctx, filePath) // Use the opener interface + if err != nil { + return "", fmt.Errorf("could not open file '%s': %w", filePath, err) } // Sort edits by line number in descending order to process from bottom to top @@ -48,21 +79,98 @@ func ApplyTextEdits(ctx context.Context, client *lsp.Client, filePath string, ed return "", fmt.Errorf("invalid position: %v", err) } + // --- Bracket Guard Check --- + if edit.PreserveBrackets { + // Read file content just before the check (could be optimized) + contentBytes, readErr := os.ReadFile(filePath) + if readErr != nil { + // Log or handle error? For now, maybe return error as it's critical for the check. + return "", fmt.Errorf("failed to read file for bracket check: %w", readErr) + } + if guardErr := checkBracketBalance(ctx, filePath, edit, contentBytes); guardErr != nil { + // If the check fails, return the specific bracket guard error + return "", guardErr + } + } + // --- End Bracket Guard Check --- + + // Handle Regex Replace first + if edit.IsRegex && edit.Type == Replace { + if edit.RegexPattern == "" { + return "", fmt.Errorf("regex pattern cannot be empty when isRegex is true for edit starting at line %d", edit.StartLine) + } + + // Read file content to get the text within the range + contentBytes, err := os.ReadFile(filePath) + if err != nil { + // Note: This reads the file potentially multiple times in a loop if many regex edits exist. + // Could be optimized by reading once before the loop if performance becomes an issue. + return "", fmt.Errorf("failed to read file for regex replace: %w", err) + } + contentStr := string(contentBytes) + + // Determine line endings (could also be optimized) + lineEnding := "\n" + if strings.Contains(contentStr, "\r\n") { + lineEnding = "\r\n" + } + lines := strings.Split(contentStr, lineEnding) + + // Get 0-based indices, clamping to valid range + startIdx := edit.StartLine - 1 + endIdx := edit.EndLine - 1 + if startIdx < 0 { startIdx = 0 } // Ensure start is not negative + if endIdx >= len(lines) { endIdx = len(lines) - 1 } // Ensure end does not exceed lines available + if startIdx > endIdx { + // If start is beyond end after clamping, it implies an invalid input range or targeting EOF in a weird way. + // For regex replace, we need a valid content range. + return "", fmt.Errorf("invalid range for regex replace: start line %d > end line %d (after bounds check)", edit.StartLine, edit.EndLine) + } + + // Extract the content within the specified lines + contentInRange := strings.Join(lines[startIdx:endIdx+1], lineEnding) + + // Compile the regex + re, err := regexp.Compile(edit.RegexPattern) + if err != nil { + return "", fmt.Errorf("invalid regex pattern '%s' for edit starting at line %d: %w", edit.RegexPattern, edit.StartLine, err) + } + + // Perform the replacement within the extracted content + replacedContent := re.ReplaceAllString(contentInRange, edit.RegexReplace) + + // Create a single edit replacing the original range with the new content + textEdits = append(textEdits, protocol.TextEdit{ + Range: rng, // Use the range covering the original lines + NewText: replacedContent, + }) + continue // Skip the normal switch statement below + } + + // Handle non-regex edits (Insert, Delete, simple Replace) + var currentEdit protocol.TextEdit switch edit.Type { case Insert: - // For insert, make it a zero-width range at the start position - rng.End = rng.Start + rng.End = rng.Start // Make it a zero-width range at the start position + currentEdit = protocol.TextEdit{ + Range: rng, + NewText: edit.NewText, + } case Delete: - // For delete, ensure NewText is empty - edit.NewText = "" - case Replace: - // Replace uses the full range and NewText as-is + currentEdit = protocol.TextEdit{ + Range: rng, + NewText: "", // Ensure NewText is empty for delete + } + case Replace: // Non-regex Replace + currentEdit = protocol.TextEdit{ + Range: rng, + NewText: edit.NewText, // Use the full range and NewText as-is + } + default: + // Should not happen if JSON schema validation works, but good to have + return "", fmt.Errorf("unknown edit type '%s' for edit starting at line %d", edit.Type, edit.StartLine) } - - textEdits = append(textEdits, protocol.TextEdit{ - Range: rng, - NewText: edit.NewText, - }) + textEdits = append(textEdits, currentEdit) } edit := protocol.WorkspaceEdit{ @@ -144,3 +252,119 @@ func getRange(startLine, endLine int, filePath string) (protocol.Range, error) { }, }, nil } + +// checkBracketBalance checks if the proposed edit range (defined by edit) +// would break bracket pairs in the given file content. +// TODO: Implement the actual bracket checking logic. +func checkBracketBalance(ctx context.Context, filePath string, edit TextEdit, content []byte) *BracketGuardError { + // Default bracket pairs to check if not specified + bracketPairs := map[rune]rune{ + '(': ')', + '[': ']', + '{': '}', + } + if len(edit.BracketTypes) > 0 { + bracketPairs = make(map[rune]rune) + for _, pair := range edit.BracketTypes { + if len(pair) == 2 { + runes := []rune(pair) + bracketPairs[runes[0]] = runes[1] + } else { + // Optionally log a warning about invalid pair format + } + } + } + if len(bracketPairs) == 0 { + return nil // No brackets to check + } + + // --- Parsing and Checking Logic --- + + // Define reverse mapping for closing brackets + closingBrackets := make(map[rune]rune) + for open, close := range bracketPairs { + closingBrackets[close] = open + } + + // Convert content to lines + contentStr := string(content) + lineEnding := "\n" + if strings.Contains(contentStr, "\r\n") { + lineEnding = "\r\n" + } + lines := strings.Split(contentStr, lineEnding) + + // Get 0-based line indices for the edit range + editStartLineIdx := edit.StartLine - 1 + editEndLineIdx := edit.EndLine - 1 + + // Basic validation for line indices (should ideally be caught earlier) + if editStartLineIdx < 0 { editStartLineIdx = 0 } + if editEndLineIdx >= len(lines) { editEndLineIdx = len(lines) - 1 } + if editStartLineIdx > editEndLineIdx { + // Invalid range, but not a balance issue itself. + return nil + } + + type bracketInfo struct { + char rune + line int // 0-based index + col int // 0-based index + } + var stack []bracketInfo + + // Iterate through the lines to find bracket pairs and check against the edit range + for lineIdx, line := range lines { + for colIdx, char := range line { + // Check for opening brackets + if _, isOpen := bracketPairs[char]; isOpen { + stack = append(stack, bracketInfo{char: char, line: lineIdx, col: colIdx}) + } + + // Check for closing brackets + if openChar, isClose := closingBrackets[char]; isClose { + if len(stack) > 0 && stack[len(stack)-1].char == openChar { + // Found a matching pair + openBracket := stack[len(stack)-1] + closeBracket := bracketInfo{char: char, line: lineIdx, col: colIdx} + stack = stack[:len(stack)-1] // Pop from stack + + // --- Check for violations based on LINE numbers --- + // Note: Column checks are omitted for simplicity for now. + + isOpenInRange := openBracket.line >= editStartLineIdx && openBracket.line <= editEndLineIdx + isCloseInRange := closeBracket.line >= editStartLineIdx && closeBracket.line <= editEndLineIdx + + // Case 1 & 2: Edit crosses one boundary of the pair + if isOpenInRange != isCloseInRange { + violationType := "CrossingPairStart" + message := fmt.Sprintf("Edit range includes opening bracket '%c' at line %d but not its closing bracket at line %d", openBracket.char, openBracket.line+1, closeBracket.line+1) + if !isOpenInRange && isCloseInRange { + violationType = "CrossingPairEnd" + message = fmt.Sprintf("Edit range includes closing bracket '%c' at line %d but not its opening bracket at line %d", closeBracket.char, closeBracket.line+1, openBracket.line+1) + } + return &BracketGuardError{ + ViolationType: violationType, + Message: message, + // TODO: Add Suggestion (e.g., expand range to include both brackets) + } + } + + // Case 3: Edit is strictly inside the pair but doesn't contain either bracket line (only relevant for multi-line pairs) + // This might be allowed depending on desired strictness. Let's allow it for now. + + // Case 4: Edit contains the pair entirely (this is generally safe) + // if isOpenInRange && isCloseInRange { continue } // Safe + + } else { + // Mismatched closing bracket - ignore for now. + } + } + } + } + + // If stack is not empty, there are unclosed brackets - ignore for now. + + // If no violations were found + return nil +} diff --git a/internal/tools/apply-text-edit_test.go b/internal/tools/apply-text-edit_test.go new file mode 100644 index 0000000..e229a52 --- /dev/null +++ b/internal/tools/apply-text-edit_test.go @@ -0,0 +1,393 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper function to create a temporary file with content +func createTempFile(t *testing.T, content string) string { + t.Helper() + tmpFile, err := os.CreateTemp("", "apply_edit_test_*.txt") + require.NoError(t, err) + _, err = tmpFile.WriteString(content) + require.NoError(t, err) + err = tmpFile.Close() + require.NoError(t, err) + absPath, err := filepath.Abs(tmpFile.Name()) + require.NoError(t, err) + t.Cleanup(func() { os.Remove(absPath) }) // Ensure cleanup + return absPath +} + +// Helper function to read file content +func readFileContent(t *testing.T, filePath string) string { + t.Helper() + content, err := os.ReadFile(filePath) + require.NoError(t, err) + return string(content) +} + +// Mock LSP Client implementing the FileOpener interface +type mockLSPClient struct{} + +// Implement OpenFile for testing purposes +func (m *mockLSPClient) OpenFile(ctx context.Context, filePath string) error { + // Mock implementation - just check if file exists for simplicity + // In a real scenario, you might want more sophisticated mocking + if _, err := os.Stat(filePath); err != nil { + return fmt.Errorf("mock OpenFile failed for '%s': %w", filePath, err) + } + // fmt.Printf("Mock OpenFile called for: %s\n", filePath) // Debug print + return nil +} + +// --- Test Cases --- + +func TestApplyTextEdits_RegexReplace_Simple(t *testing.T) { + ctx := context.Background() + // Instantiate the mock client properly + // We don't need a fully functional real client for these tests, + // so embedding a nil or zero client might be okay if OpenFile is the only method used. + // However, let's create a minimal real client instance just in case. + // If lsp.NewClient exists and is simple, use that. Otherwise, a zero struct. + // Assuming a zero struct is sufficient for embedding here as we override OpenFile. + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Hello World\nThis is a test\nWorld again" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + { + Type: Replace, + StartLine: 1, + EndLine: 3, // Apply regex across all lines + IsRegex: true, + RegexPattern: `World`, + RegexReplace: `Universe`, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + expectedContent := "Hello Universe\nThis is a test\nUniverse again" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_RegexReplace_Multiline(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Start\nLine 1\nLine 2\nEnd" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + { + Type: Replace, + StartLine: 2, // Line 1 + EndLine: 3, // Line 2 + IsRegex: true, + RegexPattern: `(?s)Line 1\nLine 2`, // (?s) for dotall mode + RegexReplace: `Replaced Block`, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + expectedContent := "Start\nReplaced Block\nEnd" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_RegexReplace_CaptureGroup(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Name: Alice\nName: Bob" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + { + Type: Replace, + StartLine: 1, + EndLine: 2, + IsRegex: true, + RegexPattern: `Name: (\w+)`, + RegexReplace: `User: $1`, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + expectedContent := "User: Alice\nUser: Bob" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_RegexReplace_NoMatch(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Hello World" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + { + Type: Replace, + StartLine: 1, + EndLine: 1, + IsRegex: true, + RegexPattern: `NotFound`, + RegexReplace: `Replaced`, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + // Content should remain unchanged + expectedContent := "Hello World" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_InvalidRegexPattern(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Some content" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + { + Type: Replace, + StartLine: 1, + EndLine: 1, + IsRegex: true, + RegexPattern: `[`, // Invalid regex + RegexReplace: `X`, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.Error(t, err) // Expect an error due to invalid regex + assert.Contains(t, err.Error(), "invalid regex pattern") +} + + +// --- Test Existing Functionality (Non-Regex) --- + +func TestApplyTextEdits_SimpleReplace(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Line 1\nLine 2\nLine 3" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + {Type: Replace, StartLine: 2, EndLine: 2, NewText: "Replaced Line 2"}, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + expectedContent := "Line 1\nReplaced Line 2\nLine 3" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_Insert(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Line 1\nLine 3" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + {Type: Insert, StartLine: 2, EndLine: 2, NewText: "Inserted Line 2\n"}, // Insert before Line 3 (which is line 2 now) + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + // Note: The getRange logic might need adjustment for precise insertion points. + // This test assumes insertion happens at the beginning of the StartLine. + // Depending on exact getRange behavior, expected might differ slightly. + // Let's assume it inserts at the start of line index 1 (line 2). + expectedContent := "Line 1\nInserted Line 2\nLine 3" // Adjust if getRange behaves differently + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent, "Insertion behavior might differ based on getRange implementation") +} + + +func TestApplyTextEdits_Delete(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} // Use the simple mock client + initialContent := "Line 1\nLine 2 to delete\nLine 3" + filePath := createTempFile(t, initialContent) + + edits := []TextEdit{ + {Type: Delete, StartLine: 2, EndLine: 2}, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) + + // Expecting the line and its newline to be removed if getRange includes it. + // If getRange only covers content, the newline might remain. + // Assuming getRange covers the line content up to the newline. + expectedContent := "Line 1\nLine 3" // Adjust based on getRange newline handling + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent, "Deletion behavior depends on getRange newline handling") +} + +// TODO: Add more tests: +// - Multiple edits (mix of regex and non-regex) +// - Edits at the beginning/end of the file +// - Edge cases with line endings (\r\n) +// - More complex capture group scenarios +// - Performance test for large files (might require a real LSP client mock) + + +// --- Test Bracket Guard Functionality --- + +func TestApplyTextEdits_BracketGuard_CrossingPair(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} + initialContent := "func main() {\n fmt.Println(\"Hello\")\n}" + filePath := createTempFile(t, initialContent) + + // Edit starts inside {} but ends outside + edits := []TextEdit{ + { + Type: Replace, + StartLine: 2, // Inside {} + EndLine: 3, // Outside {} + NewText: " // Replaced", + PreserveBrackets: true, + BracketTypes: []string{"{}"}, // Check only curly braces + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.Error(t, err) // Expect an error + guardErr, ok := err.(*BracketGuardError) + require.True(t, ok, "Expected BracketGuardError, got %T", err) + assert.Equal(t, "CrossingPairEnd", guardErr.ViolationType) // Adjusted expectation based on current logic + assert.Contains(t, guardErr.Message, "includes closing bracket '}' at line 3 but not its opening bracket at line 1") // Adjusted message check + + // Check that content was NOT modified + actualContent := readFileContent(t, filePath) + assert.Equal(t, initialContent, actualContent) +} + +func TestApplyTextEdits_BracketGuard_PartialPairStart(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} + initialContent := "(\n value\n)" + filePath := createTempFile(t, initialContent) + + // Edit includes opening ( but not closing ) + edits := []TextEdit{ + { + Type: Delete, + StartLine: 1, // Includes ( + EndLine: 2, // Does not include ) + PreserveBrackets: true, + BracketTypes: []string{"()"}, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.Error(t, err) + guardErr, ok := err.(*BracketGuardError) + require.True(t, ok, "Expected BracketGuardError, got %T", err) + assert.Equal(t, "CrossingPairStart", guardErr.ViolationType) // Current logic detects this + assert.Contains(t, guardErr.Message, "includes opening bracket '(' at line 1 but not its closing bracket at line 3") + + actualContent := readFileContent(t, filePath) + assert.Equal(t, initialContent, actualContent) +} + + +func TestApplyTextEdits_BracketGuard_SafeEditInsidePair(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} + initialContent := "{\n \"key\": \"value\"\n}" + filePath := createTempFile(t, initialContent) + + // Edit is entirely inside {} + edits := []TextEdit{ + { + Type: Replace, + StartLine: 2, + EndLine: 2, + NewText: " \"key\": \"new_value\"", + PreserveBrackets: true, + BracketTypes: []string{"{}"}, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) // Expect no error + + expectedContent := "{\n \"key\": \"new_value\"\n}" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_BracketGuard_SafeEditOutsidePair(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} + initialContent := "// Comment\n[\n 1, 2\n]\n// Another comment" + filePath := createTempFile(t, initialContent) + + // Edit is entirely outside [] + edits := []TextEdit{ + { + Type: Replace, + StartLine: 1, + EndLine: 1, + NewText: "// New Comment", + PreserveBrackets: true, + BracketTypes: []string{"[]"}, + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) // Expect no error + + expectedContent := "// New Comment\n[\n 1, 2\n]\n// Another comment" + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} + +func TestApplyTextEdits_BracketGuard_Disabled(t *testing.T) { + ctx := context.Background() + client := &mockLSPClient{} + initialContent := "func main() {\n fmt.Println(\"Hello\")\n}" + filePath := createTempFile(t, initialContent) + + // Edit starts inside {} but ends outside, BUT guard is disabled + edits := []TextEdit{ + { + Type: Replace, + StartLine: 2, // Inside {} + EndLine: 3, // Outside {} + NewText: " // Replaced", + PreserveBrackets: false, // Guard disabled + }, + } + + _, err := ApplyTextEdits(ctx, client, filePath, edits) + require.NoError(t, err) // Expect no error because guard is off + + // Check that content WAS modified (even though it breaks brackets) + // The edit replaces lines 2 and 3 with " // Replaced" + expectedContent := "func main() {\n // Replaced" // Corrected expectation: newline remains after line 1 + actualContent := readFileContent(t, filePath) + assert.Equal(t, expectedContent, actualContent) +} diff --git a/internal/tools/diagnostics.go b/internal/tools/diagnostics.go index d033aab..52dbb1c 100644 --- a/internal/tools/diagnostics.go +++ b/internal/tools/diagnostics.go @@ -7,6 +7,7 @@ import ( "os" "strings" "time" + "path/filepath" // Added for absolute path conversion "github.com/isaacphi/mcp-language-server/internal/lsp" "github.com/isaacphi/mcp-language-server/internal/protocol" @@ -14,9 +15,16 @@ import ( // GetDiagnostics retrieves diagnostics for a specific file from the language server func GetDiagnosticsForFile(ctx context.Context, client *lsp.Client, filePath string, includeContext bool, showLineNumbers bool) (string, error) { - err := client.OpenFile(ctx, filePath) + // Ensure filePath is absolute + absFilePath, err := filepath.Abs(filePath) if err != nil { - return "", fmt.Errorf("could not open file: %v", err) + return "", fmt.Errorf("could not get absolute path for '%s': %w", filePath, err) + } + filePath = absFilePath // Use absolute path from now on + + err = client.OpenFile(ctx, filePath) // Use absolute path + if err != nil { + return "", fmt.Errorf("could not open file '%s': %w", filePath, err) } // Wait for diagnostics diff --git a/internal/tools/execute-codelens.go b/internal/tools/execute-codelens.go index 59227b8..200cc18 100644 --- a/internal/tools/execute-codelens.go +++ b/internal/tools/execute-codelens.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "time" + "path/filepath" // Added for absolute path conversion "github.com/isaacphi/mcp-language-server/internal/lsp" "github.com/isaacphi/mcp-language-server/internal/protocol" @@ -11,10 +12,17 @@ import ( // ExecuteCodeLens executes a specific code lens command from a file. func ExecuteCodeLens(ctx context.Context, client *lsp.Client, filePath string, index int) (string, error) { + // Ensure filePath is absolute + absFilePath, err := filepath.Abs(filePath) + if err != nil { + return "", fmt.Errorf("could not get absolute path for '%s': %w", filePath, err) + } + filePath = absFilePath // Use absolute path from now on + // Open the file - err := client.OpenFile(ctx, filePath) + err = client.OpenFile(ctx, filePath) // Use absolute path if err != nil { - return "", fmt.Errorf("could not open file: %v", err) + return "", fmt.Errorf("could not open file '%s': %w", filePath, err) } // TODO: find a more appropriate way to wait time.Sleep(time.Second) diff --git a/internal/tools/find-symbols.go b/internal/tools/find-symbols.go new file mode 100644 index 0000000..8f4fb05 --- /dev/null +++ b/internal/tools/find-symbols.go @@ -0,0 +1,238 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/isaacphi/mcp-language-server/internal/lsp" + "github.com/isaacphi/mcp-language-server/internal/protocol" +) + +// FindSymbolsTool defines the MCP tool for finding symbols using LSP. +type FindSymbolsTool struct { + Client *lsp.Client // Assuming Client is accessible or passed appropriately +} + +// FindSymbolsArgs defines the arguments for the find_symbols tool. +type FindSymbolsArgs struct { + Query string `json:"query"` // Required: Search query string. + Scope string `json:"scope"` // Required: "workspace" or "document". + FilePath string `json:"filePath,omitempty"` // Optional: Required if scope is "document". + ShowLineNumbers bool `json:"showLineNumbers,omitempty"` // Optional: Default true. +} + +// FindSymbolsResult defines the result structure. +// Returning a simple string list for now. Could be JSON later. +type FindSymbolsResult struct { + Symbols []string `json:"symbols"` +} + +// Name returns the name of the tool. +func (t *FindSymbolsTool) Name() string { + return "find_symbols" +} + +// Description returns the description of the tool. +func (t *FindSymbolsTool) Description() string { + return "Finds symbols in the workspace or a specific document using the Language Server Protocol." +} + +// Schema returns the JSON schema for the tool's arguments and result. +func (t *FindSymbolsTool) Schema() string { + argsSchema := `{ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query string."}, + "scope": {"type": "string", "enum": ["workspace", "document"], "description": "Search scope ('workspace' or 'document')."}, + "filePath": {"type": "string", "description": "Path to the file (required if scope is 'document')."}, + "showLineNumbers": {"type": "boolean", "default": true, "description": "Include line numbers in the result."} + }, + "required": ["query", "scope"] + }` + resultSchema := `{ + "type": "object", + "properties": { + "symbols": { + "type": "array", + "items": {"type": "string"}, + "description": "List of found symbols with their locations." + } + } + }` + return fmt.Sprintf(`{"name": "%s", "description": "%s", "input_schema": %s, "output_schema": %s}`, t.Name(), t.Description(), argsSchema, resultSchema) +} + +// Execute performs the symbol search operation. +func (t *FindSymbolsTool) Execute(ctx context.Context, argsJSON json.RawMessage) (json.RawMessage, error) { + var args FindSymbolsArgs + if err := json.Unmarshal(argsJSON, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + + // Set default for showLineNumbers if not provided + if args.ShowLineNumbers == false { + // Check if it was explicitly set to false or just omitted (zero value) + // A more robust way might involve using a pointer, but this works for now. + var tempArgs map[string]any // Use any instead of interface{} + if json.Unmarshal(argsJSON, &tempArgs) == nil { + if _, ok := tempArgs["showLineNumbers"]; !ok { + args.ShowLineNumbers = true // Default to true if omitted + } + } else { + args.ShowLineNumbers = true // Default to true on unmarshal error too? Or handle error? + } + } + + + var symbolsResult any // Use any instead of interface{} + var err error + + switch args.Scope { + case "workspace": + // Workspace scope search + params := protocol.WorkspaceSymbolParams{ + Query: args.Query, + } + // Note: RequestWorkspaceSymbols returns []protocol.SymbolInformation + symbolsResult, err = t.Client.RequestWorkspaceSymbols(ctx, params) + if err != nil { + return nil, fmt.Errorf("LSP workspace/symbol request failed: %w", err) + } + + case "document": + // Document scope search + if args.FilePath == "" { + return nil, fmt.Errorf("filePath is required for document scope search") + } + absPath, pathErr := filepath.Abs(args.FilePath) + if pathErr != nil { + return nil, fmt.Errorf("failed to get absolute path for %s: %w", args.FilePath, pathErr) + } + + // Ensure file is open + if !t.Client.IsFileOpen(absPath) { + if openErr := t.Client.OpenFile(ctx, absPath); openErr != nil { + // Log or handle error, maybe proceed cautiously + fmt.Printf("Warning: failed to open file %s before document symbol search: %v\n", absPath, openErr) + } + } + + params := protocol.DocumentSymbolParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: protocol.DocumentUri("file://" + absPath), + }, + } + // Note: RequestDocumentSymbols returns interface{} which can be []protocol.DocumentSymbol or []protocol.SymbolInformation + symbolsResult, err = t.Client.RequestDocumentSymbols(ctx, params) + if err != nil { + return nil, fmt.Errorf("LSP textDocument/documentSymbol request failed: %w", err) + } + + default: + return nil, fmt.Errorf("invalid scope: %s. Must be 'workspace' or 'document'", args.Scope) + } + + // Format the result + formattedSymbols := formatSymbols(symbolsResult, args.ShowLineNumbers) + + result := FindSymbolsResult{Symbols: formattedSymbols} + resultJSON, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal find_symbols result: %w", err) + } + + return resultJSON, nil +} + +// formatSymbols converts the LSP symbol result (either []DocumentSymbol or []SymbolInformation) into a string slice. +func formatSymbols(result any, showLineNumbers bool) []string { // Use any instead of interface{} + var formatted []string + + switch symbols := result.(type) { + case []protocol.DocumentSymbol: + for _, symbol := range symbols { + formatted = append(formatted, formatDocumentSymbol(symbol, "", showLineNumbers)...) // Start with empty prefix for top-level + } + case []protocol.SymbolInformation: + for _, symbol := range symbols { + formatted = append(formatted, formatSymbolInformation(symbol, showLineNumbers)) + } + default: + // Should not happen if LSP client returns correctly + formatted = append(formatted, fmt.Sprintf("Error: Unexpected symbol result type %T", result)) + } + + return formatted +} + +// formatDocumentSymbol recursively formats DocumentSymbol and its children. +func formatDocumentSymbol(symbol protocol.DocumentSymbol, prefix string, showLineNumbers bool) []string { + var results []string + line := "" + if showLineNumbers { + // Add 1 to line numbers because LSP uses 0-based lines + line = fmt.Sprintf(" (L%d)", symbol.SelectionRange.Start.Line+1) + } + symbolKindStr := symbolKindToString(symbol.Kind) // Helper function needed + results = append(results, fmt.Sprintf("%s%s: %s%s", prefix, symbolKindStr, symbol.Name, line)) + + // Recursively format children with indentation + childPrefix := prefix + " " + for _, child := range symbol.Children { + results = append(results, formatDocumentSymbol(child, childPrefix, showLineNumbers)...) + } + return results +} + +// formatSymbolInformation formats SymbolInformation. +func formatSymbolInformation(symbol protocol.SymbolInformation, showLineNumbers bool) string { + line := "" + filePath := strings.TrimPrefix(string(symbol.Location.URI), "file://") + if showLineNumbers { + // Add 1 to line numbers because LSP uses 0-based lines + line = fmt.Sprintf(" (L%d)", symbol.Location.Range.Start.Line+1) + } + symbolKindStr := symbolKindToString(symbol.Kind) // Helper function needed + container := "" + if symbol.ContainerName != "" { + container = fmt.Sprintf(" in %s", symbol.ContainerName) + } + return fmt.Sprintf("%s: %s%s%s - %s", symbolKindStr, symbol.Name, container, line, filePath) +} + +// symbolKindToString converts protocol.SymbolKind to a readable string. +// This needs to be implemented based on the SymbolKind constants. +func symbolKindToString(kind protocol.SymbolKind) string { + switch kind { + case protocol.File: return "File" + case protocol.Module: return "Module" + case protocol.Namespace: return "Namespace" + case protocol.Package: return "Package" + case protocol.Class: return "Class" + case protocol.Method: return "Method" + case protocol.Property: return "Property" + case protocol.Field: return "Field" + case protocol.Constructor: return "Constructor" + case protocol.Enum: return "Enum" + case protocol.Interface: return "Interface" + case protocol.Function: return "Function" + case protocol.Variable: return "Variable" + case protocol.Constant: return "Constant" + case protocol.String: return "String" + case protocol.Number: return "Number" + case protocol.Boolean: return "Boolean" + case protocol.Array: return "Array" + case protocol.Object: return "Object" + case protocol.Key: return "Key" + case protocol.Null: return "Null" + case protocol.EnumMember: return "EnumMember" + case protocol.Struct: return "Struct" + case protocol.Event: return "Event" + case protocol.Operator: return "Operator" + case protocol.TypeParameter: return "TypeParameter" + default: return fmt.Sprintf("UnknownKind(%d)", kind) + } +} diff --git a/internal/tools/get-codelens.go b/internal/tools/get-codelens.go index bc71536..c71217d 100644 --- a/internal/tools/get-codelens.go +++ b/internal/tools/get-codelens.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" "time" + "path/filepath" // Added for absolute path conversion "github.com/isaacphi/mcp-language-server/internal/lsp" "github.com/isaacphi/mcp-language-server/internal/protocol" @@ -12,9 +13,16 @@ import ( // GetCodeLens retrieves code lens hints for a given file location func GetCodeLens(ctx context.Context, client *lsp.Client, filePath string) (string, error) { - err := client.OpenFile(ctx, filePath) + // Ensure filePath is absolute + absFilePath, err := filepath.Abs(filePath) if err != nil { - return "", fmt.Errorf("could not open file: %v", err) + return "", fmt.Errorf("could not get absolute path for '%s': %w", filePath, err) + } + filePath = absFilePath // Use absolute path from now on + + err = client.OpenFile(ctx, filePath) // Use absolute path + if err != nil { + return "", fmt.Errorf("could not open file '%s': %w", filePath, err) } // TODO: find a more appropriate way to wait time.Sleep(time.Second) diff --git a/internal/tools/rename-symbol.go b/internal/tools/rename-symbol.go new file mode 100644 index 0000000..059f44c --- /dev/null +++ b/internal/tools/rename-symbol.go @@ -0,0 +1,203 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" // Import filepath for Abs + "strings" + + "github.com/isaacphi/mcp-language-server/internal/lsp" + "github.com/isaacphi/mcp-language-server/internal/protocol" + // "github.com/isaacphi/mcp-language-server/internal/utilities" // Removed unused import +) + +// RenameSymbolTool defines the MCP tool for renaming symbols using LSP. +type RenameSymbolTool struct { + Client *lsp.Client // Assuming Client is accessible or passed appropriately +} + +// RenameSymbolArgs defines the arguments for the rename_symbol tool. +type RenameSymbolArgs struct { + FilePath string `json:"filePath"` // Required: Path to the file containing the symbol. + Line int `json:"line"` // Required: 0-based line number of the symbol. + Character int `json:"character"` // Required: 0-based character offset of the symbol. + NewName string `json:"newName"` // Required: The new name for the symbol. +} + +// RenameSymbolResult defines the result structure (delegating to apply_text_edit format). +type RenameSymbolResult struct { + Changes map[string][]protocol.TextEdit `json:"changes"` +} + + +// Name returns the name of the tool. +func (t *RenameSymbolTool) Name() string { + return "rename_symbol" +} + +// Description returns the description of the tool. +func (t *RenameSymbolTool) Description() string { + return "Renames a symbol across the workspace using the Language Server Protocol." +} + +// Schema returns the JSON schema for the tool's arguments and result. +func (t *RenameSymbolTool) Schema() string { + argsSchema := `{ + "type": "object", + "properties": { + "filePath": {"type": "string", "description": "Path to the file containing the symbol."}, + "line": {"type": "integer", "description": "0-based line number of the symbol."}, + "character": {"type": "integer", "description": "0-based character offset of the symbol."}, + "newName": {"type": "string", "description": "The new name for the symbol."} + }, + "required": ["filePath", "line", "character", "newName"] + }` + resultSchema := `{ + "type": "object", + "properties": { + "changes": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "range": { + "type": "object", + "properties": { + "start": {"$ref": "#/definitions/position"}, + "end": {"$ref": "#/definitions/position"} + }, + "required": ["start", "end"] + }, + "newText": {"type": "string"} + }, + "required": ["range", "newText"] + } + } + } + }, + "definitions": { + "position": { + "type": "object", + "properties": { + "line": {"type": "integer"}, + "character": {"type": "integer"} + }, + "required": ["line", "character"] + } + } + }` + return fmt.Sprintf(`{"name": "%s", "description": "%s", "input_schema": %s, "output_schema": %s}`, t.Name(), t.Description(), argsSchema, resultSchema) +} + +// Execute performs the rename operation. +func (t *RenameSymbolTool) Execute(ctx context.Context, argsJSON json.RawMessage) (json.RawMessage, error) { + var args RenameSymbolArgs + if err := json.Unmarshal(argsJSON, &args); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + + // Corrected: Use filepath.Abs to ensure absolute path + absPath, err := filepath.Abs(args.FilePath) + if err != nil { + return nil, fmt.Errorf("failed to get absolute path for %s: %w", args.FilePath, err) + } + + // Ensure the file is open in the LSP client + if !t.Client.IsFileOpen(absPath) { + if err := t.Client.OpenFile(ctx, absPath); err != nil { + // Log warning but continue, server might handle it + fmt.Fprintf(os.Stderr, "Warning: failed to open file %s before rename: %v\n", absPath, err) + } + } + + + params := protocol.RenameParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: protocol.DocumentUri("file://" + absPath), + }, + Position: protocol.Position{ + Line: uint32(args.Line), + Character: uint32(args.Character), + }, + NewName: args.NewName, + } + + workspaceEdit, err := t.Client.RequestRename(ctx, params) + if err != nil { + return nil, fmt.Errorf("LSP rename request failed: %w", err) + } + + if workspaceEdit == nil { + result := RenameSymbolResult{Changes: make(map[string][]protocol.TextEdit)} + return json.Marshal(result) + } + + // Convert WorkspaceEdit.Changes (map[protocol.DocumentUri][]protocol.TextEdit) + convertedChanges := make(map[string][]protocol.TextEdit) + if workspaceEdit.Changes != nil { + for uri, edits := range workspaceEdit.Changes { + // Use TrimPrefix to convert file URI to path for the map key + filePathKey := strings.TrimPrefix(string(uri), "file://") + convertedChanges[filePathKey] = edits + } + } + + + // Handle DocumentChanges as well + if len(workspaceEdit.DocumentChanges) > 0 { + for _, docChange := range workspaceEdit.DocumentChanges { + // Corrected: Check if TextDocumentEdit field is not nil + if docChange.TextDocumentEdit != nil { + textDocEdit := docChange.TextDocumentEdit + // Corrected: Cast protocol.DocumentUri to string and remove "file://" prefix + filePath := strings.TrimPrefix(string(textDocEdit.TextDocument.URI), "file://") + + // Convert edits from Or_TextDocumentEdit_edits_Elem to protocol.TextEdit + actualEdits := make([]protocol.TextEdit, 0, len(textDocEdit.Edits)) + for _, editUnion := range textDocEdit.Edits { + // Corrected: Use AsTextEdit method from the union type + if te, err := editUnion.AsTextEdit(); err == nil { + actualEdits = append(actualEdits, te) + } else { + // Handle AnnotatedTextEdit or SnippetTextEdit if necessary, or log warning + fmt.Fprintf(os.Stderr, "Warning: Skipping non-plain TextEdit in rename result: %v\n", err) + } + } + + // Append edits, potentially overwriting if the same file exists in Changes map + convertedChanges[filePath] = append(convertedChanges[filePath], actualEdits...) + } else { + // Handle other types like CreateFile, RenameFile, DeleteFile if necessary + // For rename, we only care about TextDocumentEdit + // Use specific fields like CreateFile, RenameFile, DeleteFile to check type + kind := "" + if docChange.CreateFile != nil { kind = "CreateFile" } + if docChange.RenameFile != nil { kind = "RenameFile" } + if docChange.DeleteFile != nil { kind = "DeleteFile" } + if kind != "" { + fmt.Fprintf(os.Stderr, "Warning: Unsupported document change type '%s' received in rename result.\n", kind) + } else { + fmt.Fprintf(os.Stderr, "Warning: Unknown document change type received in rename result.\n") + } + } + } + } + + result := RenameSymbolResult{ + Changes: convertedChanges, + } + + resultJSON, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("failed to marshal rename result: %w", err) + } + + return resultJSON, nil +} + +// Removed the compile-time check as Tool interface might not be explicitly defined/needed in this structure +// var _ Tool = (*RenameSymbolTool)(nil) diff --git a/main.go b/main.go index c880c54..398adc6 100644 --- a/main.go +++ b/main.go @@ -2,13 +2,15 @@ package main import ( "context" + "encoding/json" // Added for config parsing "flag" "fmt" "log" "os" - "os/exec" + "os/exec" // Re-enable for command validation "os/signal" - "path/filepath" + "path/filepath" // Re-enable for path manipulation + "strings" // Added for extension checking "syscall" "time" @@ -19,26 +21,48 @@ import ( ) var debug = os.Getenv("DEBUG") != "" +var configPath string // Variable to hold the config file path -type config struct { - workspaceDir string - lspCommand string - lspArgs []string +func init() { + // Define command-line flags + flag.StringVar(&configPath, "config", "config.json", "Path to the configuration JSON file") + // Add other flags here if needed in the future + // Need to parse flags early before loading config + flag.Parse() } +// Note: Old 'config' struct definition removed + type server struct { - config config - lspClient *lsp.Client - mcpServer *mcp_golang.Server - ctx context.Context - cancelFunc context.CancelFunc - workspaceWatcher *watcher.WorkspaceWatcher + config Config // Use new Config type + lspClients map[string]*lsp.Client // Map language name to LSP client + extensionToLanguage map[string]string // Map file extension to language name + mcpServer *mcp_golang.Server + ctx context.Context + cancelFunc context.CancelFunc + workspaceWatcher *watcher.WorkspaceWatcher +} + +// LanguageServerConfig defines the configuration for a single language server +type LanguageServerConfig struct { + Language string `json:"language"` // e.g., "typescript", "go" + Command string `json:"command"` // e.g., "typescript-language-server", "gopls" + Args []string `json:"args"` // Arguments for the LSP command + Extensions []string `json:"extensions"` // File extensions associated with this language, e.g., [".ts", ".tsx"] } +// Config holds the overall configuration for the mcp-language-server +type Config struct { + WorkspaceDir string `json:"workspaceDir"` + LanguageServers []LanguageServerConfig `json:"languageServers"` +} + +/* // Comment out the old parseConfig function func parseConfig() (*config, error) { cfg := &config{} flag.StringVar(&cfg.workspaceDir, "workspace", "", "Path to workspace directory") flag.StringVar(&cfg.lspCommand, "lsp", "", "LSP command to run (args should be passed after --)") + flag.StringVar(&cfg.Language, "language", "", "Target language for the LSP server (e.g., typescript, go)") // Added language flag flag.Parse() // Get remaining args after -- as LSP arguments @@ -70,39 +94,171 @@ func parseConfig() (*config, error) { return cfg, nil } +*/ // End comment for old parseConfig function + +// loadConfig loads the server configuration from the specified JSON file path +func loadConfig(configPath string) (*Config, error) { + log.Printf("Loading configuration from: %s", configPath) + data, err := os.ReadFile(configPath) + if err != nil { + // If the default config.json doesn't exist, maybe return a default config or a clearer error? + // For now, return the error. + return nil, fmt.Errorf("failed to read config file '%s': %w", configPath, err) + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse config file '%s': %w", configPath, err) + } + + // --- Validation (Optional but recommended) --- + if config.WorkspaceDir == "" { + return nil, fmt.Errorf("config error: workspaceDir is required") + } + // Ensure WorkspaceDir is absolute? + absWorkspaceDir, err := filepath.Abs(config.WorkspaceDir) + if err != nil { + return nil, fmt.Errorf("config error: failed to get absolute path for workspaceDir '%s': %w", config.WorkspaceDir, err) + } + config.WorkspaceDir = absWorkspaceDir + if _, err := os.Stat(config.WorkspaceDir); os.IsNotExist(err) { + return nil, fmt.Errorf("config error: workspaceDir '%s' does not exist", config.WorkspaceDir) + } + + + if len(config.LanguageServers) == 0 { + log.Printf("Warning: No language servers defined in config file '%s'", configPath) + // Return error or allow running without language servers? Allow for now. + } + + // Validate each language server config (e.g., check command exists) + for i := range config.LanguageServers { + lsConfig := &config.LanguageServers[i] // Get pointer to modify original + if lsConfig.Language == "" { + return nil, fmt.Errorf("config error: language name is required for server at index %d", i) + } + if lsConfig.Command == "" { + return nil, fmt.Errorf("config error: command is required for language '%s'", lsConfig.Language) + } + // Check if command exists in PATH or is an absolute path + if _, err := exec.LookPath(lsConfig.Command); err != nil { + // Check if it's an absolute path that exists + if !filepath.IsAbs(lsConfig.Command) || os.IsNotExist(err) { + return nil, fmt.Errorf("config error: command '%s' for language '%s' not found in PATH or as absolute path: %w", lsConfig.Command, lsConfig.Language, err) + } + // If it's an absolute path that exists, LookPath might fail if it doesn't have execute permissions, + // but we might allow it here and let the execution fail later. Or add an explicit check? + } + + if len(lsConfig.Extensions) == 0 { + log.Printf("Warning: No file extensions specified for language '%s'", lsConfig.Language) + } + // Ensure extensions start with '.'? + for j, ext := range lsConfig.Extensions { + if !strings.HasPrefix(ext, ".") { + log.Printf("Warning: Extension '%s' for language '%s' does not start with '.'. Adding '.' automatically.", ext, lsConfig.Language) + lsConfig.Extensions[j] = "." + ext + } + } + } + + + log.Printf("Configuration loaded successfully.") + return &config, nil +} + -func newServer(config *config) (*server, error) { +func newServer(config *Config) (*server, error) { // Use new Config type ctx, cancel := context.WithCancel(context.Background()) - return &server{ - config: *config, - ctx: ctx, - cancelFunc: cancel, - }, nil + s := &server{ + config: *config, // Assign the new Config + lspClients: make(map[string]*lsp.Client), + extensionToLanguage: make(map[string]string), + ctx: ctx, + cancelFunc: cancel, + } + + // Build extension to language map + for _, lsConfig := range config.LanguageServers { + for _, ext := range lsConfig.Extensions { + if _, exists := s.extensionToLanguage[ext]; exists { + log.Printf("Warning: Extension %s is associated with multiple languages. Using %s.", ext, lsConfig.Language) + } + s.extensionToLanguage[ext] = lsConfig.Language + } + } + + return s, nil } +// initializeLSP initializes LSP clients for all configured language servers func (s *server) initializeLSP() error { - if err := os.Chdir(s.config.workspaceDir); err != nil { + if err := os.Chdir(s.config.WorkspaceDir); err != nil { // Use s.config.WorkspaceDir return fmt.Errorf("failed to change to workspace directory: %v", err) } - client, err := lsp.NewClient(s.config.lspCommand, s.config.lspArgs...) - if err != nil { - return fmt.Errorf("failed to create LSP client: %v", err) + // Initialize a single workspace watcher (shared by all clients for now) + // TODO: Consider if watcher needs client-specific handling or if one is sufficient + if len(s.config.LanguageServers) > 0 { + // Create a temporary client just for the watcher? Or pick the first one? + // Let's pick the first one for now, assuming watcher registration is similar. + firstLangCfg := s.config.LanguageServers[0] + tempClientForWatcher, err := lsp.NewClient(firstLangCfg.Command, firstLangCfg.Args...) + if err != nil { + log.Printf("Warning: Failed to create temporary LSP client for watcher: %v. File watching might not work.", err) + // Continue without watcher if temp client fails? Or return error? For now, continue. + } else { + s.workspaceWatcher = watcher.NewWorkspaceWatcher(tempClientForWatcher) + // We don't need to fully initialize this temp client, just use it for watcher registration. + // Maybe there's a better way? Refactor watcher later if needed. + // Close the temp client immediately? No, watcher needs it. Manage its lifecycle? + log.Printf("Workspace watcher initialized using LSP client for %s", firstLangCfg.Language) + go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.WorkspaceDir) // Use s.config.WorkspaceDir + } } - s.lspClient = client - s.workspaceWatcher = watcher.NewWorkspaceWatcher(client) - initResult, err := client.InitializeLSPClient(s.ctx, s.config.workspaceDir) - if err != nil { - return fmt.Errorf("initialize failed: %v", err) + for _, langCfg := range s.config.LanguageServers { + log.Printf("Initializing LSP client for %s: %s %v", langCfg.Language, langCfg.Command, langCfg.Args) + client, err := lsp.NewClient(langCfg.Command, langCfg.Args...) + if err != nil { + // Log error but continue trying to initialize other servers + log.Printf("Error creating LSP client for %s: %v", langCfg.Language, err) + continue + } + + // Store the client in the map + s.lspClients[langCfg.Language] = client + + // Initialize the client (sends 'initialize' request) + initResult, err := client.InitializeLSPClient(s.ctx, s.config.WorkspaceDir) // Use s.config.WorkspaceDir + if err != nil { + log.Printf("Error initializing LSP client for %s: %v", langCfg.Language, err) + // Remove the client from the map if initialization fails? + delete(s.lspClients, langCfg.Language) + client.Close() // Attempt to clean up the failed client process + continue + } + + if debug { + log.Printf("Initialized %s LSP server. Capabilities: %+v\n\n", langCfg.Language, initResult.Capabilities) + } + + // Wait for server ready (optional, might need adjustment) + // Doing this sequentially might slow down startup if many servers + // Maybe wait in parallel? For now, sequential. + if err := client.WaitForServerReady(s.ctx); err != nil { + log.Printf("Error waiting for %s LSP server to be ready: %v", langCfg.Language, err) + // Consider this non-fatal for now? + } + log.Printf("%s LSP client ready.", langCfg.Language) } - if debug { - log.Printf("Server capabilities: %+v\n\n", initResult.Capabilities) + if len(s.lspClients) == 0 { + return fmt.Errorf("failed to initialize any LSP clients") } - go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.workspaceDir) - return client.WaitForServerReady(s.ctx) + log.Printf("Finished initializing %d LSP client(s)", len(s.lspClients)) + return nil } func (s *server) start() error { @@ -124,9 +280,12 @@ func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - config, err := parseConfig() + // Load configuration from file (replace parseConfig) + // Config path is now determined by the global 'configPath' variable, set by flags in init() + log.Printf("Using configuration file path from flag: %s", configPath) // Add log here + config, err := loadConfig(configPath) if err != nil { - log.Fatal(err) + log.Fatalf("Failed to load config from %s: %v", configPath, err) } server, err := newServer(config) @@ -193,24 +352,35 @@ func cleanup(s *server, done chan struct{}) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if s.lspClient != nil { - log.Printf("Closing open files") - s.lspClient.CloseAllFiles(ctx) - - log.Printf("Sending shutdown request") - if err := s.lspClient.Shutdown(ctx); err != nil { - log.Printf("Shutdown request failed: %v", err) - } + // Cleanup all LSP clients + if s.lspClients != nil { + log.Printf("Cleaning up %d LSP client(s)...", len(s.lspClients)) + for lang, client := range s.lspClients { + log.Printf("Cleaning up %s LSP client...", lang) + if client != nil { + log.Printf("Closing open files for %s", lang) + client.CloseAllFiles(ctx) // Close files first + + log.Printf("Sending shutdown request to %s", lang) + if err := client.Shutdown(ctx); err != nil { + log.Printf("Shutdown request failed for %s: %v", lang, err) + } - log.Printf("Sending exit notification") - if err := s.lspClient.Exit(ctx); err != nil { - log.Printf("Exit notification failed: %v", err) - } + // Exit notification might not be strictly necessary after shutdown, + // but let's keep it for now, similar to the original logic. + log.Printf("Sending exit notification to %s", lang) + if err := client.Exit(ctx); err != nil { + log.Printf("Exit notification failed for %s: %v", lang, err) + } - log.Printf("Closing LSP client") - if err := s.lspClient.Close(); err != nil { - log.Printf("Failed to close LSP client: %v", err) + log.Printf("Closing %s LSP client connection", lang) + if err := client.Close(); err != nil { + log.Printf("Failed to close %s LSP client: %v", lang, err) + } + log.Printf("Finished cleanup for %s LSP client.", lang) + } } + log.Printf("Finished cleaning up all LSP clients.") } // Send signal to the done channel diff --git a/tools.go b/tools.go index b3ecc1a..3ff14a7 100644 --- a/tools.go +++ b/tools.go @@ -1,25 +1,89 @@ package main import ( + "encoding/json" // Import encoding/json "fmt" + "path/filepath" // For extension checking - "github.com/isaacphi/mcp-language-server/internal/tools" + "github.com/isaacphi/mcp-language-server/internal/lsp" // For lsp.Client type + internalTools "github.com/isaacphi/mcp-language-server/internal/tools" // Alias internal/tools to avoid name clash "github.com/metoro-io/mcp-golang" ) +// Helper function to get the appropriate LSP client based on file extension +func (s *server) getClientForFile(filePath string) (*lsp.Client, error) { + ext := filepath.Ext(filePath) + language, ok := s.extensionToLanguage[ext] + if !ok { + return nil, fmt.Errorf("language not supported for file extension: %s (file: %s)", ext, filePath) + } + + client, ok := s.lspClients[language] + if !ok { + // This should ideally not happen if initialization succeeded for this language + return nil, fmt.Errorf("LSP client for language '%s' not found or not initialized", language) + } + return client, nil +} + type ReadDefinitionArgs struct { SymbolName string `json:"symbolName" jsonschema:"required,description=The name of the symbol whose definition you want to find (e.g. 'mypackage.MyFunction', 'MyType.MyMethod')"` ShowLineNumbers bool `json:"showLineNumbers" jsonschema:"required,default=true,description=Include line numbers in the returned source code"` + Language string `json:"language" jsonschema:"required,description=The programming language of the symbol (e.g., 'typescript', 'go')"` // Added language argument } type FindReferencesArgs struct { SymbolName string `json:"symbolName" jsonschema:"required,description=The name of the symbol to search for (e.g. 'mypackage.MyFunction', 'MyType')"` ShowLineNumbers bool `json:"showLineNumbers" jsonschema:"required,default=true,description=Include line numbers when showing where the symbol is used"` + Language string `json:"language" jsonschema:"required,description=The programming language of the symbol (e.g., 'typescript', 'go')"` // Added language argument } type ApplyTextEditArgs struct { - FilePath string `json:"filePath"` - Edits []tools.TextEdit `json:"edits"` + FilePath string `json:"filePath" jsonschema:"required,description=The path to the file to apply edits to."` // Added description + Edits []internalTools.TextEdit `json:"edits" jsonschema:"required,description=An array of text edit objects defining the changes to apply.",items={ + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["replace", "insert", "delete"], + "description": "Type of edit operation (replace, insert, delete)" + }, + "startLine": { + "type": "integer", + "description": "Start line of the range, inclusive" + }, + "endLine": { + "type": "integer", + "description": "End line of the range, inclusive" + }, + "newText": { + "type": "string", + "description": "Replacement text for non-regex replace/insert. Leave blank for delete." + }, + "isRegex": { + "type": "boolean", + "description": "Whether to treat pattern as regex" + }, + "regexPattern": { + "type": "string", + "description": "Regex pattern to search for within the range (if isRegex is true)" + }, + "regexReplace": { + "type": "string", + "description": "Replacement string, supporting capture groups like $1 (if isRegex is true)" + }, + "preserveBrackets": { + "type": "boolean", + "description": "If true, check and prevent edits that break bracket pairs" + }, + "bracketTypes": { + "type": "array", + "items": { "type": "string" }, + "description": "Types of brackets to check (e.g., '()', '{}', '[]'). Defaults if empty." + } + }, + "required": ["type", "startLine", "endLine"] + }` // Use internalTools alias, Inlined TextEdit schema description } type GetDiagnosticsArgs struct { @@ -37,15 +101,40 @@ type ExecuteCodeLensArgs struct { Index int `json:"index" jsonschema:"required,description=The index of the code lens to execute (from get_codelens output), 1 indexed"` } +// Define args struct for rename_symbol tool +type RenameSymbolArgs struct { + FilePath string `json:"filePath" jsonschema:"required,description=Path to the file containing the symbol."` + Line int `json:"line" jsonschema:"required,description=0-based line number of the symbol."` + Character int `json:"character" jsonschema:"required,description=0-based character offset of the symbol."` + NewName string `json:"newName" jsonschema:"required,description=The new name for the symbol."` +} + +// Define args struct for find_symbols tool +type FindSymbolsArgs struct { + Query string `json:"query" jsonschema:"required,description=Search query string."` + Scope string `json:"scope" jsonschema:"required,enum=[\"workspace\", \"document\"],description=Search scope ('workspace' or 'document')."` + FilePath string `json:"filePath,omitempty" jsonschema:"description=Path to the file (required if scope is 'document')."` + ShowLineNumbers bool `json:"showLineNumbers,omitempty" jsonschema:"default=true,description=Include line numbers in the result."` +} + + func (s *server) registerTools() error { + // Register apply_text_edit tool err := s.mcpServer.RegisterTool( "apply_text_edit", - "Apply multiple text edits to a file.", + "Apply multiple text edits to a file specified by `filePath`. Each edit in the `edits` array defines the operation (`replace`, `insert`, `delete`), range (`startLine`, `endLine`), and optionally `newText` or regex patterns for advanced replacements.", // Even more detailed description! ✨ func(args ApplyTextEditArgs) (*mcp_golang.ToolResponse, error) { - response, err := tools.ApplyTextEdits(s.ctx, s.lspClient, args.FilePath, args.Edits) + // Get LSP client based on file extension + client, err := s.getClientForFile(args.FilePath) if err != nil { - return nil, fmt.Errorf("Failed to apply edits: %v", err) + return nil, err // Error includes context like "language not supported" + } + + // Call the actual tool implementation with the selected client + response, err := internalTools.ApplyTextEdits(s.ctx, client, args.FilePath, args.Edits) // Use internalTools alias + if err != nil { + return nil, fmt.Errorf("failed to apply edits: %v", err) } return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(response)), nil }) @@ -53,13 +142,21 @@ func (s *server) registerTools() error { return fmt.Errorf("failed to register tool: %v", err) } + // Register read_definition tool err = s.mcpServer.RegisterTool( "read_definition", - "Read the source code definition of a symbol (function, type, constant, etc.) from the codebase. Returns the complete implementation code where the symbol is defined.", + "Read the source code definition of a symbol (function, type, constant, etc.) specified by `symbolName` and `language`. Returns the complete implementation code where the symbol is defined.", // Updated description func(args ReadDefinitionArgs) (*mcp_golang.ToolResponse, error) { - text, err := tools.ReadDefinition(s.ctx, s.lspClient, args.SymbolName, args.ShowLineNumbers) + // Get LSP client based on language argument + client, ok := s.lspClients[args.Language] + if !ok { + return nil, fmt.Errorf("LSP client for language '%s' not found or not initialized", args.Language) + } + + // Call the actual tool implementation with the selected client + text, err := internalTools.ReadDefinition(s.ctx, client, args.SymbolName, args.ShowLineNumbers) // Use internalTools alias if err != nil { - return nil, fmt.Errorf("Failed to get definition: %v", err) + return nil, fmt.Errorf("failed to get definition: %v", err) } return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(text)), nil }) @@ -67,13 +164,21 @@ func (s *server) registerTools() error { return fmt.Errorf("failed to register tool: %v", err) } + // Register find_references tool err = s.mcpServer.RegisterTool( "find_references", - "Find all usages and references of a symbol throughout the codebase. Returns a list of all files and locations where the symbol appears.", + "Find all usages and references of a symbol specified by `symbolName` and `language` throughout the codebase. Returns a list of all files and locations where the symbol appears.", // Updated description func(args FindReferencesArgs) (*mcp_golang.ToolResponse, error) { - text, err := tools.FindReferences(s.ctx, s.lspClient, args.SymbolName, args.ShowLineNumbers) + // Get LSP client based on language argument + client, ok := s.lspClients[args.Language] + if !ok { + return nil, fmt.Errorf("LSP client for language '%s' not found or not initialized", args.Language) + } + + // Call the actual tool implementation with the selected client + text, err := internalTools.FindReferences(s.ctx, client, args.SymbolName, args.ShowLineNumbers) // Use internalTools alias if err != nil { - return nil, fmt.Errorf("Failed to find references: %v", err) + return nil, fmt.Errorf("failed to find references: %v", err) } return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(text)), nil }) @@ -81,13 +186,21 @@ func (s *server) registerTools() error { return fmt.Errorf("failed to register tool: %v", err) } + // Register get_diagnostics tool err = s.mcpServer.RegisterTool( "get_diagnostics", - "Get diagnostic information for a specific file from the language server.", + "Get diagnostic information (errors, warnings) for a specific file specified by `filePath` from the language server.", // Updated description func(args GetDiagnosticsArgs) (*mcp_golang.ToolResponse, error) { - text, err := tools.GetDiagnosticsForFile(s.ctx, s.lspClient, args.FilePath, args.IncludeContext, args.ShowLineNumbers) + // Get LSP client based on file extension + client, err := s.getClientForFile(args.FilePath) + if err != nil { + return nil, err + } + + // Call the actual tool implementation with the selected client + text, err := internalTools.GetDiagnosticsForFile(s.ctx, client, args.FilePath, args.IncludeContext, args.ShowLineNumbers) // Use internalTools alias if err != nil { - return nil, fmt.Errorf("Failed to get diagnostics: %v", err) + return nil, fmt.Errorf("failed to get diagnostics: %v", err) } return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(text)), nil }, @@ -96,13 +209,21 @@ func (s *server) registerTools() error { return fmt.Errorf("failed to register tool: %v", err) } + // Register get_codelens tool err = s.mcpServer.RegisterTool( "get_codelens", - "Get code lens hints for a given file from the language server.", + "Get code lens hints (e.g., run test, references) for a given file specified by `filePath` from the language server.", // Updated description func(args GetCodeLensArgs) (*mcp_golang.ToolResponse, error) { - text, err := tools.GetCodeLens(s.ctx, s.lspClient, args.FilePath) + // Get LSP client based on file extension + client, err := s.getClientForFile(args.FilePath) if err != nil { - return nil, fmt.Errorf("Failed to get code lens: %v", err) + return nil, err + } + + // Call the actual tool implementation with the selected client + text, err := internalTools.GetCodeLens(s.ctx, client, args.FilePath) // Use internalTools alias + if err != nil { + return nil, fmt.Errorf("failed to get code lens: %v", err) } return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(text)), nil }, @@ -111,13 +232,21 @@ func (s *server) registerTools() error { return fmt.Errorf("failed to register tool: %v", err) } + // Register execute_codelens tool err = s.mcpServer.RegisterTool( "execute_codelens", - "Execute a code lens command for a given file and lens index.", + "Execute a code lens command (obtained from `get_codelens`) for a given file specified by `filePath` and the lens `index`.", // Updated description func(args ExecuteCodeLensArgs) (*mcp_golang.ToolResponse, error) { - text, err := tools.ExecuteCodeLens(s.ctx, s.lspClient, args.FilePath, args.Index) + // Get LSP client based on file extension + client, err := s.getClientForFile(args.FilePath) + if err != nil { + return nil, err + } + + // Call the actual tool implementation with the selected client + text, err := internalTools.ExecuteCodeLens(s.ctx, client, args.FilePath, args.Index) // Use internalTools alias if err != nil { - return nil, fmt.Errorf("Failed to execute code lens: %v", err) + return nil, fmt.Errorf("failed to execute code lens: %v", err) } return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(text)), nil }, @@ -126,5 +255,96 @@ func (s *server) registerTools() error { return fmt.Errorf("failed to register tool: %v", err) } + // Register rename_symbol tool + err = s.mcpServer.RegisterTool( + "rename_symbol", + "Renames a symbol across the workspace using the Language Server Protocol.", + func(args RenameSymbolArgs) (*mcp_golang.ToolResponse, error) { + // Get LSP client based on file extension + client, err := s.getClientForFile(args.FilePath) + if err != nil { + return nil, err + } + + // Instantiate the tool struct (assuming it needs the client) + renameTool := internalTools.RenameSymbolTool{Client: client} + + // Corrected: Marshal args to json.RawMessage before passing to Execute + argsJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("failed to marshal rename args: %v", err) + } + + + // Execute the tool's logic + resultJSON, err := renameTool.Execute(s.ctx, argsJSON) // Pass marshaled JSON + if err != nil { + return nil, fmt.Errorf("failed to execute rename symbol: %v", err) + } + + // Corrected: Return result as text (string representation of the JSON) + return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(string(resultJSON))), nil + }, + ) + if err != nil { + return fmt.Errorf("failed to register rename_symbol tool: %v", err) + } + + // Register find_symbols tool + err = s.mcpServer.RegisterTool( + "find_symbols", + "Finds symbols in the workspace or a specific document using the Language Server Protocol.", + func(args FindSymbolsArgs) (*mcp_golang.ToolResponse, error) { + var client *lsp.Client + var err error + + // Determine client based on scope + if args.Scope == "document" { + if args.FilePath == "" { + return nil, fmt.Errorf("filePath is required for document scope search") + } + client, err = s.getClientForFile(args.FilePath) + if err != nil { + return nil, err + } + } else if args.Scope == "workspace" { + // For workspace scope, we might need a default client or iterate through all? + // Let's assume a primary client exists or pick the first one for now. + // This logic might need refinement based on how workspace symbols should work across languages. + if len(s.lspClients) == 0 { + return nil, fmt.Errorf("no LSP clients available for workspace symbol search") + } + for _, c := range s.lspClients { // Just pick the first one + client = c + break + } + } else { + return nil, fmt.Errorf("invalid scope: %s. Must be 'workspace' or 'document'", args.Scope) + } + + // Instantiate the tool struct + findTool := internalTools.FindSymbolsTool{Client: client} + + // Marshal args to json.RawMessage + argsJSON, err := json.Marshal(args) + if err != nil { + return nil, fmt.Errorf("failed to marshal find_symbols args: %v", err) + } + + // Execute the tool's logic + resultJSON, err := findTool.Execute(s.ctx, argsJSON) + if err != nil { + return nil, fmt.Errorf("failed to execute find symbols: %v", err) + } + + // Return result as text (string representation of the JSON containing the list) + return mcp_golang.NewToolResponse(mcp_golang.NewTextContent(string(resultJSON))), nil + }, + ) + if err != nil { + return fmt.Errorf("failed to register find_symbols tool: %v", err) + } + + return nil }