diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..283e48d3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# Summary +This project (mcp-language-server) is an MCP server that exposes language server protocol to AI agents. It helps MCP enabled clients (agents) navigate codebases more easily by giving them access semantic tools like get definition, references, rename, and diagnostics. + +# Build +go build -o mcp-language-server + +# Install locally +go install + +# Format code +gofmt -w . + +# Generate LSP types and methods +go run ./cmd/generate + +# Run code audit checks + gofmt -l . + test -z "$(gofmt -l .)" + go tool staticcheck ./... + go tool errcheck ./... + find . -path "./integrationtests/workspaces" -prune -o \ + -path "./integrationtests/test-output" -prune -o \ + -name "*.go" -print | xargs gopls check + go tool govulncheck ./... + +# Run tests +go test ./... + +# Update snapshot tests +UPDATE_SNAPSHOTS=true go test ./integrationtests/... diff --git a/integrationtests/snapshots/go/codelens/get.snap b/integrationtests/snapshots/go/codelens/get.snap index b82dbfb0..af0861ca 100644 --- a/integrationtests/snapshots/go/codelens/get.snap +++ b/integrationtests/snapshots/go/codelens/get.snap @@ -5,35 +5,48 @@ Command: gopls.reset_go_mod_diagnostics Arguments: /TEST_OUTPUT/workspace/go.mod","DiagnosticSource":""} +{"source":"codelens"} [2] Location: Lines 1-1 + Title: Run govulncheck + Command: gopls.run_govulncheck + Arguments: +/TEST_OUTPUT/workspace/go.mod","Pattern":"./..."} +{"source":"codelens"} + +[3] Location: Lines 1-1 Title: Run go mod tidy Command: gopls.tidy Arguments: /TEST_OUTPUT/workspace/go.mod"]} +{"source":"codelens"} -[3] Location: Lines 1-1 +[4] Location: Lines 1-1 Title: Create vendor directory Command: gopls.vendor Arguments: /TEST_OUTPUT/workspace/go.mod"} +{"source":"codelens"} -[4] Location: Lines 5-5 +[5] Location: Lines 5-5 Title: Check for upgrades Command: gopls.check_upgrades Arguments: /TEST_OUTPUT/workspace/go.mod","Modules":["github.com/stretchr/testify"]} +{"source":"codelens"} -[5] Location: Lines 5-5 +[6] Location: Lines 5-5 Title: Upgrade transitive dependencies Command: gopls.upgrade_dependency Arguments: /TEST_OUTPUT/workspace/go.mod","GoCmdArgs":["-d","-u","-t","./..."],"AddRequire":false} +{"source":"codelens"} -[6] Location: Lines 5-5 +[7] Location: Lines 5-5 Title: Upgrade direct dependencies Command: gopls.upgrade_dependency Arguments: /TEST_OUTPUT/workspace/go.mod","GoCmdArgs":["-d","github.com/stretchr/testify"],"AddRequire":false} +{"source":"codelens"} -Found 6 code lens items. +Found 7 code lens items. diff --git a/integrationtests/snapshots/go/hover/struct-type.snap b/integrationtests/snapshots/go/hover/struct-type.snap index 08c7820a..34aa914f 100644 --- a/integrationtests/snapshots/go/hover/struct-type.snap +++ b/integrationtests/snapshots/go/hover/struct-type.snap @@ -1,5 +1,5 @@ ```go -type SharedStruct struct { // size=56 (0x38) +type SharedStruct struct { // size=56 (0x38), class=64 (0x40) ID int Name string Value float64 diff --git a/integrationtests/tests/common/framework.go b/integrationtests/tests/common/framework.go index 0d6eb1fa..c74567fc 100644 --- a/integrationtests/tests/common/framework.go +++ b/integrationtests/tests/common/framework.go @@ -4,8 +4,11 @@ import ( "context" "fmt" "log" + "net" "os" + "os/exec" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -18,11 +21,13 @@ import ( // LSPTestConfig defines configuration for a language server test type LSPTestConfig struct { - Name string // Name of the language server - Command string // Command to run - Args []string // Arguments - WorkspaceDir string // Template workspace directory - InitializeTimeMs int // Time to wait after initialization in ms + Name string // Name of the language server + Command string // Command to run (ignored if ConnectAddr is set) + Args []string // Arguments (ignored if ConnectAddr is set) + ConnectAddr string // If set, connect to existing LSP at this address (headless) instead of starting Command + HeadlessListenArg string // If set, start LSP with this listen arg (e.g. "-listen=127.0.0.1:6061") and connect via NewClientHeadless + WorkspaceDir string // Template workspace directory + InitializeTimeMs int // Time to wait after initialization in ms } // TestSuite contains everything needed for running integration tests @@ -39,6 +44,8 @@ type TestSuite struct { logFile string t *testing.T LanguageName string + headless bool // true when using ConnectAddr or HeadlessListenArg (affects cleanup) + headlessCmd *exec.Cmd // when we start LSP in listen mode, the process we started (for cleanup) } // NewTestSuite creates a new test suite for the given language server @@ -54,6 +61,45 @@ func NewTestSuite(t *testing.T, config LSPTestConfig) *TestSuite { } } +// startLSPInListenMode reserves a port, starts the LSP with the same Command/Args plus +// HeadlessListenArg (with %d replaced by the port), and waits until the server accepts connections. +// Caller must connect with NewClientHeadless(addr) and is responsible for killing the process on cleanup. +func (ts *TestSuite) startLSPInListenMode() (addr string, cmd *exec.Cmd, err error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", nil, fmt.Errorf("failed to reserve port: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + return "", nil, fmt.Errorf("failed to close listener: %w", err) + } + addr = "127.0.0.1:" + strconv.Itoa(port) + listenArg := fmt.Sprintf(ts.Config.HeadlessListenArg, port) + fullArgs := append(append([]string{}, ts.Config.Args...), listenArg) + cmd = exec.Command(ts.Config.Command, fullArgs...) + cmd.Env = os.Environ() + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + if err := cmd.Start(); err != nil { + return "", nil, fmt.Errorf("failed to start LSP: %w", err) + } + // Wait for server to accept connections (retry with backoff) + const maxWait = 15 * time.Second + deadline := time.Now().Add(maxWait) + for time.Now().Before(deadline) { + conn, dialErr := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if dialErr == nil { + conn.Close() + return addr, cmd, nil + } + time.Sleep(100 * time.Millisecond) + } + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return "", nil, fmt.Errorf("LSP at %s did not accept connections within %v", addr, maxWait) +} + // Setup initializes the test suite, copies the workspace, and starts the LSP func (ts *TestSuite) Setup() error { if ts.initialized { @@ -156,12 +202,35 @@ func (ts *TestSuite) Setup() error { ts.t.Logf("Copied workspace from %s to %s", ts.Config.WorkspaceDir, workspaceDir) // Create and initialize LSP client - client, err := lsp.NewClient(ts.Config.Command, ts.Config.Args...) - if err != nil { - return fmt.Errorf("failed to create LSP client: %w", err) + var client *lsp.Client + if ts.Config.HeadlessListenArg != "" { + // Start LSP in listen mode (same Command/Args as NewClient), then connect via NewClientHeadless + addr, cmd, err := ts.startLSPInListenMode() + if err != nil { + return err + } + ts.headlessCmd = cmd + ts.headless = true + client, err = lsp.NewClientHeadless(addr) + if err != nil { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + return fmt.Errorf("failed to connect to LSP at %s: %w", addr, err) + } + ts.Client = client + ts.t.Logf("Started LSP in listen mode and connected at %s", addr) + } else if ts.Config.ConnectAddr != "" { + return fmt.Errorf("headless via ConnectAddr is disabled; use HeadlessListenArg to run headless (start LSP in listen mode and connect)") + } else { + var err error + client, err = lsp.NewClient(ts.Config.Command, ts.Config.Args...) + if err != nil { + return fmt.Errorf("failed to create LSP client: %w", err) + } + ts.Client = client + ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) } - ts.Client = client - ts.t.Logf("Started LSP: %s %v", ts.Config.Command, ts.Config.Args) // Initialize LSP and set up file watcher initResult, err := client.InitializeLSPClient(ts.Context, workspaceDir) @@ -197,27 +266,52 @@ func (ts *TestSuite) Cleanup() { // Cancel context to stop watchers ts.Cancel() - // Shutdown LSP + // Shutdown LSP: for subprocess we shutdown+exit+close; for headless we only close unless we started the process if ts.Client != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - ts.t.Logf("Shutting down LSP client") - err := ts.Client.Shutdown(shutdownCtx) - if err != nil { - ts.t.Logf("Shutdown failed: %v", err) + if !ts.headless { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ts.t.Logf("Shutting down LSP client") + if err := ts.Client.Shutdown(shutdownCtx); err != nil { + ts.t.Logf("Shutdown failed: %v", err) + } + if err := ts.Client.Exit(shutdownCtx); err != nil { + ts.t.Logf("Exit failed: %v", err) + } + } else if ts.headlessCmd != nil { + // We started the LSP in listen mode; send shutdown/exit so it exits gracefully + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ts.t.Logf("Shutting down LSP client (headless subprocess)") + if err := ts.Client.Shutdown(shutdownCtx); err != nil { + ts.t.Logf("Shutdown failed: %v", err) + } + if err := ts.Client.Exit(shutdownCtx); err != nil { + ts.t.Logf("Exit failed: %v", err) + } } - - err = ts.Client.Exit(shutdownCtx) - if err != nil { - ts.t.Logf("Exit failed: %v", err) - } - - err = ts.Client.Close() - if err != nil { + if err := ts.Client.Close(); err != nil { ts.t.Logf("Close failed: %v", err) } } + if ts.headlessCmd != nil { + done := make(chan struct{}) + go func() { + _ = ts.headlessCmd.Wait() + close(done) + }() + select { + case <-done: + // process exited + case <-time.After(3 * time.Second): + if ts.headlessCmd.Process != nil { + ts.t.Logf("Killing LSP process after timeout") + _ = ts.headlessCmd.Process.Kill() + _ = ts.headlessCmd.Wait() + } + } + } // No need to close log files explicitly, logging package handles that diff --git a/integrationtests/tests/go/codelens/codelens_test.go b/integrationtests/tests/go/codelens/codelens_test.go index b6ef165d..b02a4375 100644 --- a/integrationtests/tests/go/codelens/codelens_test.go +++ b/integrationtests/tests/go/codelens/codelens_test.go @@ -3,6 +3,8 @@ package codelens_test import ( "context" "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" @@ -12,90 +14,108 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestCodeLens tests the codelens functionality with the Go language server +// TestCodeLens tests the codelens functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestCodeLens(t *testing.T) { t.Skip("Remove this line to run codelens tool tests") - // Test GetCodeLens with a file that should have codelenses - t.Run("GetCodeLens", func(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - // The go.mod fixture already has an unused dependency - - // Wait for LSP to process the file - time.Sleep(2 * time.Second) - - // Test GetCodeLens - filePath := filepath.Join(suite.WorkspaceDir, "go.mod") - result, err := tools.GetCodeLens(ctx, suite.Client, filePath) - if err != nil { - t.Fatalf("GetCodeLens failed: %v", err) - } - - // Verify we have at least one code lens - if !strings.Contains(result, "Code Lens results") { - t.Errorf("Expected code lens results but got: %s", result) - } - - // Verify we have a "go mod tidy" code lens - if !strings.Contains(strings.ToLower(result), "tidy") { - t.Errorf("Expected 'tidy' code lens but got: %s", result) - } - - common.SnapshotTest(t, "go", "codelens", "get", result) - }) - - // Test ExecuteCodeLens by running the tidy codelens command - t.Run("ExecuteCodeLens", func(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - // The go.mod fixture already has an unused dependency - // Wait for LSP to process the file - time.Sleep(2 * time.Second) - - // First get the code lenses to find the right index - filePath := filepath.Join(suite.WorkspaceDir, "go.mod") - result, err := tools.GetCodeLens(ctx, suite.Client, filePath) - if err != nil { - t.Fatalf("GetCodeLens failed: %v", err) - } - - // Make sure we have a code lens with "tidy" in it - if !strings.Contains(strings.ToLower(result), "tidy") { - t.Fatalf("Expected 'tidy' code lens but none found: %s", result) - } - - // Typically, the tidy lens should be index 2 (1-based) for gopls, but let's log for debugging - t.Logf("Code lenses: %s", result) - - // Execute the code lens (use index 2 which should be the tidy lens) - execResult, err := tools.ExecuteCodeLens(ctx, suite.Client, filePath, 2) - if err != nil { - t.Fatalf("ExecuteCodeLens failed: %v", err) - } - - t.Logf("ExecuteCodeLens result: %s", execResult) - - // Wait for LSP to update the file - time.Sleep(3 * time.Second) - - // Check if the file was updated (dependency should be removed) - updatedContent, err := suite.ReadFile("go.mod") - if err != nil { - t.Fatalf("Failed to read updated go.mod: %v", err) - } - - // Verify the dependency is gone - if strings.Contains(updatedContent, "github.com/stretchr/testify") { - t.Errorf("Expected dependency to be removed, but it's still there:\n%s", updatedContent) - } - - common.SnapshotTest(t, "go", "codelens", "execute", execResult) - }) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + t.Run(mode.name, func(t *testing.T) { + // Test GetCodeLens with a file that should have codelenses + t.Run("GetCodeLens", func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // The go.mod fixture already has an unused dependency + + // Wait for LSP to process the file + time.Sleep(2 * time.Second) + + // Test GetCodeLens + filePath := filepath.Join(suite.WorkspaceDir, "go.mod") + result, err := tools.GetCodeLens(ctx, suite.Client, filePath) + if err != nil { + t.Fatalf("GetCodeLens failed: %v", err) + } + + // Verify we have at least one code lens + if !strings.Contains(result, "Code Lens results") { + t.Errorf("Expected code lens results but got: %s", result) + } + + // Verify we have a "go mod tidy" code lens + if !strings.Contains(strings.ToLower(result), "tidy") { + t.Errorf("Expected 'tidy' code lens but got: %s", result) + } + + common.SnapshotTest(t, "go", "codelens", "get", result) + }) + + // Test ExecuteCodeLens by running the tidy codelens command + t.Run("ExecuteCodeLens", func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) + + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + // The go.mod fixture already has an unused dependency + // Wait for LSP to process the file + time.Sleep(2 * time.Second) + + // First get the code lenses to find the right index + filePath := filepath.Join(suite.WorkspaceDir, "go.mod") + result, err := tools.GetCodeLens(ctx, suite.Client, filePath) + if err != nil { + t.Fatalf("GetCodeLens failed: %v", err) + } + + // Make sure we have a code lens with "tidy" in it + if !strings.Contains(strings.ToLower(result), "tidy") { + t.Fatalf("Expected 'tidy' code lens but none found: %s", result) + } + // Use regex to find a number in square brackets followed by 'tidy' with no square brackets in between, + // this tells us which codelens index is the `go mod tidy`. + re := regexp.MustCompile(`\[(\d+)\][^\[\]]*tidy`) + match := re.FindStringSubmatch(result) + if len(match) < 2 { + t.Fatalf("Could not find code lens index for 'tidy': %s", result) + } + tidyIndex, err := strconv.Atoi(match[1]) + if err != nil { + t.Fatalf("Failed to parse code lens index for 'tidy': %v", err) + } + + t.Logf("Code lenses: %s", result) + + // Execute the code lens using the index we found for the tidy lens + execResult, err := tools.ExecuteCodeLens(ctx, suite.Client, filePath, tidyIndex) + if err != nil { + t.Fatalf("ExecuteCodeLens failed: %v", err) + } + + t.Logf("ExecuteCodeLens result: %s", execResult) + + // Wait for LSP to update the file + time.Sleep(3 * time.Second) + + // Check if the file was updated (dependency should be removed) + updatedContent, err := suite.ReadFile("go.mod") + if err != nil { + t.Fatalf("Failed to read updated go.mod: %v", err) + } + + // Verify the dependency is gone + if strings.Contains(updatedContent, "github.com/stretchr/testify") { + t.Errorf("Expected dependency to be removed, but it's still there:\n%s", updatedContent) + } + + common.SnapshotTest(t, "go", "codelens", "execute", execResult) + }) + }) + } } diff --git a/integrationtests/tests/go/definition/definition_test.go b/integrationtests/tests/go/definition/definition_test.go index 39ed6d8e..99b6e7f1 100644 --- a/integrationtests/tests/go/definition/definition_test.go +++ b/integrationtests/tests/go/definition/definition_test.go @@ -11,13 +11,9 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestReadDefinition tests the ReadDefinition tool with various Go type definitions +// TestReadDefinition tests the ReadDefinition tool with various Go type definitions. +// Runs in both subprocess and headless (listen-mode) modes. func TestReadDefinition(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - tests := []struct { name string symbolName string @@ -80,21 +76,33 @@ func TestReadDefinition(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Call the ReadDefinition tool - result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) - if err != nil { - t.Fatalf("Failed to read definition: %v", err) - } + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { - // Check that the result contains relevant information - if !strings.Contains(result, tc.expectedText) { - t.Errorf("Definition does not contain expected text: %s", tc.expectedText) - } + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() - // Use snapshot testing to verify exact output - common.SnapshotTest(t, "go", "definition", tc.snapshotName, result) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Call the ReadDefinition tool + result, err := tools.ReadDefinition(ctx, suite.Client, tc.symbolName) + if err != nil { + t.Fatalf("Failed to read definition: %v", err) + } + + // Check that the result contains relevant information + if !strings.Contains(result, tc.expectedText) { + t.Errorf("Definition does not contain expected text: %s", tc.expectedText) + } + + // Use snapshot testing to verify exact output + common.SnapshotTest(t, "go", "definition", tc.snapshotName, result) + }) + } }) } } diff --git a/integrationtests/tests/go/diagnostics/diagnostics_test.go b/integrationtests/tests/go/diagnostics/diagnostics_test.go index 7b7a6660..d14a9b1c 100644 --- a/integrationtests/tests/go/diagnostics/diagnostics_test.go +++ b/integrationtests/tests/go/diagnostics/diagnostics_test.go @@ -14,172 +14,179 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestDiagnostics tests diagnostics functionality with the Go language server +// TestDiagnostics tests diagnostics functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestDiagnostics(t *testing.T) { - // Test with a clean file - t.Run("CleanFile", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - filePath := filepath.Join(suite.WorkspaceDir, "clean.go") - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } - - // Verify we have no diagnostics - if !strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected no diagnostics but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics", "clean", result) - }) - - // Test with a file containing an error - t.Run("FileWithError", func(t *testing.T) { - // Get a test suite with code that contains errors - suite := internal.GetTestSuite(t) - - // Wait for diagnostics to be generated - time.Sleep(2 * time.Second) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - filePath := filepath.Join(suite.WorkspaceDir, "main.go") - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } - - // Verify we have diagnostics about unreachable code - if strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected diagnostics but got none") - } - - if !strings.Contains(result, "unreachable") { - t.Errorf("Expected unreachable code error but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics", "unreachable", result) - }) - - // Test file dependency: file A (helper.go) provides a function, - // file B (consumer.go) uses it, then modify A to break B - t.Run("FileDependency", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) - - // Wait for initial diagnostics to be generated - time.Sleep(2 * time.Second) - - // Verify consumer.go is clean initially - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - // Ensure both helper.go and consumer.go are open in the LSP - helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") - consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") - - err := suite.Client.OpenFile(ctx, helperPath) - if err != nil { - t.Fatalf("Failed to open helper.go: %v", err) - } - - err = suite.Client.OpenFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to open consumer.go: %v", err) - } - - // Wait for files to be processed - time.Sleep(2 * time.Second) - - // Get initial diagnostics for consumer.go - result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed: %v", err) - } - - // Should have no diagnostics initially - if !strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected no diagnostics initially but got: %s", result) - } - - // Now modify the helper function to cause an error in the consumer - modifiedHelperContent := `package main + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + t.Run(mode.name, func(t *testing.T) { + t.Run("CleanFile", func(t *testing.T) { + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + filePath := filepath.Join(suite.WorkspaceDir, "clean.go") + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + // Verify we have no diagnostics + if !strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected no diagnostics but got: %s", result) + } + + common.SnapshotTest(t, "go", "diagnostics", "clean", result) + }) + + // Test with a file containing an error + t.Run("FileWithError", func(t *testing.T) { + // Get a test suite with code that contains errors + suite := internal.GetTestSuite(t, mode.headless) + + // Wait for diagnostics to be generated + time.Sleep(2 * time.Second) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + filePath := filepath.Join(suite.WorkspaceDir, "main.go") + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, filePath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + // Verify we have diagnostics about unreachable code + if strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected diagnostics but got none") + } + + if !strings.Contains(result, "unreachable") { + t.Errorf("Expected unreachable code error but got: %s", result) + } + + common.SnapshotTest(t, "go", "diagnostics", "unreachable", result) + }) + + // Test file dependency: file A (helper.go) provides a function, + // file B (consumer.go) uses it, then modify A to break B + t.Run("FileDependency", func(t *testing.T) { + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + + // Wait for initial diagnostics to be generated + time.Sleep(2 * time.Second) + + // Verify consumer.go is clean initially + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // Ensure both helper.go and consumer.go are open in the LSP + helperPath := filepath.Join(suite.WorkspaceDir, "helper.go") + consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.go") + + err := suite.Client.OpenFile(ctx, helperPath) + if err != nil { + t.Fatalf("Failed to open helper.go: %v", err) + } + + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to open consumer.go: %v", err) + } + + // Wait for files to be processed + time.Sleep(2 * time.Second) + + // Get initial diagnostics for consumer.go + result, err := tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed: %v", err) + } + + // Should have no diagnostics initially + if !strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected no diagnostics initially but got: %s", result) + } + + // Now modify the helper function to cause an error in the consumer + modifiedHelperContent := `package main // HelperFunction now requires an int parameter func HelperFunction(value int) string { return "hello world" } ` - // Write the modified content to the file - err = suite.WriteFile("helper.go", modifiedHelperContent) - if err != nil { - t.Fatalf("Failed to update helper.go: %v", err) - } - - // Explicitly notify the LSP server about the change - helperURI := fmt.Sprintf("file://%s", helperPath) - - // Notify the LSP server about the file change - err = suite.Client.NotifyChange(ctx, helperPath) - if err != nil { - t.Fatalf("Failed to notify change to helper.go: %v", err) - } - - // Also send a didChangeWatchedFiles notification for coverage - // This simulates what the watcher would do - fileChangeParams := protocol.DidChangeWatchedFilesParams{ - Changes: []protocol.FileEvent{ - { - URI: protocol.DocumentUri(helperURI), - Type: protocol.FileChangeType(protocol.Changed), - }, - }, - } - - err = suite.Client.DidChangeWatchedFiles(ctx, fileChangeParams) - if err != nil { - t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) - } - - // Wait for LSP to process the change - time.Sleep(3 * time.Second) - - // Force reopen the consumer file to ensure LSP reevaluates it - err = suite.Client.CloseFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to close consumer.go: %v", err) - } - - err = suite.Client.OpenFile(ctx, consumerPath) - if err != nil { - t.Fatalf("Failed to reopen consumer.go: %v", err) - } - - // Wait for diagnostics to be generated - time.Sleep(3 * time.Second) - - // Check diagnostics again on consumer file - should now have an error - result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) - if err != nil { - t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) - } - - // Should have diagnostics now - if strings.Contains(result, "No diagnostics found") { - t.Errorf("Expected diagnostics after dependency change but got none") - } - - // Should contain an error about function arguments - if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { - t.Errorf("Expected error about wrong arguments but got: %s", result) - } - - common.SnapshotTest(t, "go", "diagnostics", "dependency", result) - }) + // Write the modified content to the file + err = suite.WriteFile("helper.go", modifiedHelperContent) + if err != nil { + t.Fatalf("Failed to update helper.go: %v", err) + } + + // Explicitly notify the LSP server about the change + helperURI := fmt.Sprintf("file://%s", helperPath) + + // Notify the LSP server about the file change + err = suite.Client.NotifyChange(ctx, helperPath) + if err != nil { + t.Fatalf("Failed to notify change to helper.go: %v", err) + } + + // Also send a didChangeWatchedFiles notification for coverage + // This simulates what the watcher would do + fileChangeParams := protocol.DidChangeWatchedFilesParams{ + Changes: []protocol.FileEvent{ + { + URI: protocol.DocumentUri(helperURI), + Type: protocol.FileChangeType(protocol.Changed), + }, + }, + } + + err = suite.Client.DidChangeWatchedFiles(ctx, fileChangeParams) + if err != nil { + t.Fatalf("Failed to send DidChangeWatchedFiles: %v", err) + } + + // Wait for LSP to process the change + time.Sleep(3 * time.Second) + + // Force reopen the consumer file to ensure LSP reevaluates it + err = suite.Client.CloseFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to close consumer.go: %v", err) + } + + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to reopen consumer.go: %v", err) + } + + // Wait for diagnostics to be generated + time.Sleep(3 * time.Second) + + // Check diagnostics again on consumer file - should now have an error + result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, 2, true) + if err != nil { + t.Fatalf("GetDiagnosticsForFile failed after dependency change: %v", err) + } + + // Should have diagnostics now + if strings.Contains(result, "No diagnostics found") { + t.Errorf("Expected diagnostics after dependency change but got none") + } + + // Should contain an error about function arguments + if !strings.Contains(result, "argument") && !strings.Contains(result, "parameter") { + t.Errorf("Expected error about wrong arguments but got: %s", result) + } + + common.SnapshotTest(t, "go", "diagnostics", "dependency", result) + }) + }) + } } diff --git a/integrationtests/tests/go/hover/hover_test.go b/integrationtests/tests/go/hover/hover_test.go index f8796313..a95a9e5a 100644 --- a/integrationtests/tests/go/hover/hover_test.go +++ b/integrationtests/tests/go/hover/hover_test.go @@ -12,7 +12,8 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestHover tests hover functionality with the Go language server +// TestHover tests hover functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestHover(t *testing.T) { tests := []struct { name string @@ -84,43 +85,47 @@ func TestHover(t *testing.T) { }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Get a test suite - suite := internal.GetTestSuite(t) - + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) defer cancel() - filePath := filepath.Join(suite.WorkspaceDir, tt.file) - err := suite.Client.OpenFile(ctx, filePath) - if err != nil { - t.Fatalf("Failed to open %s: %v", tt.file, err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := filepath.Join(suite.WorkspaceDir, tt.file) + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open %s: %v", tt.file, err) + } - // Get hover info - result, err := tools.GetHoverInfo(ctx, suite.Client, filePath, tt.line, tt.column) - if err != nil { - // For the "OutsideFile" test, we expect an error - if tt.name == "OutsideFile" { - // Create a snapshot even for error case - common.SnapshotTest(t, "go", "hover", tt.snapshotName, err.Error()) - return - } - t.Fatalf("GetHoverInfo failed: %v", err) - } + // Get hover info + result, err := tools.GetHoverInfo(ctx, suite.Client, filePath, tt.line, tt.column) + if err != nil { + // For the "OutsideFile" test, we expect an error + if tt.name == "OutsideFile" { + // Create a snapshot even for error case + common.SnapshotTest(t, "go", "hover", tt.snapshotName, err.Error()) + return + } + t.Fatalf("GetHoverInfo failed: %v", err) + } - // Verify expected content - if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) { - t.Errorf("Expected hover info to contain %q but got: %s", tt.expectedText, result) - } + // Verify expected content + if tt.expectedText != "" && !strings.Contains(result, tt.expectedText) { + t.Errorf("Expected hover info to contain %q but got: %s", tt.expectedText, result) + } + // Verify unexpected content is absent + if tt.unexpectedText != "" && strings.Contains(result, tt.unexpectedText) { + t.Errorf("Expected hover info NOT to contain %q but it was found: %s", tt.unexpectedText, result) + } - // Verify unexpected content is absent - if tt.unexpectedText != "" && strings.Contains(result, tt.unexpectedText) { - t.Errorf("Expected hover info NOT to contain %q but it was found: %s", tt.unexpectedText, result) + common.SnapshotTest(t, "go", "hover", tt.snapshotName, result) + }) } - - common.SnapshotTest(t, "go", "hover", tt.snapshotName, result) }) } } diff --git a/integrationtests/tests/go/internal/helpers.go b/integrationtests/tests/go/internal/helpers.go index 4551fe87..448904ff 100644 --- a/integrationtests/tests/go/internal/helpers.go +++ b/integrationtests/tests/go/internal/helpers.go @@ -8,8 +8,8 @@ import ( "github.com/isaacphi/mcp-language-server/integrationtests/tests/common" ) -// GetTestSuite returns a test suite for Go language server tests -func GetTestSuite(t *testing.T) *common.TestSuite { +// GetTestSuite returns a test suite for Go language server tests (either starts gopls as subprocess, or connects to an LSP in headless mode) +func GetTestSuite(t *testing.T, headless bool) *common.TestSuite { // Configure Go LSP repoRoot, err := filepath.Abs("../../../..") if err != nil { @@ -21,22 +21,20 @@ func GetTestSuite(t *testing.T) *common.TestSuite { Command: "gopls", Args: []string{}, WorkspaceDir: filepath.Join(repoRoot, "integrationtests/workspaces/go"), - InitializeTimeMs: 2000, // 2 seconds + InitializeTimeMs: 2000, + } + if headless { + config.HeadlessListenArg = "-listen=127.0.0.1:%d" // Port will be decided at test run time } // Create a test suite suite := common.NewTestSuite(t, config) // Set up the suite - err = suite.Setup() - if err != nil { + if err := suite.Setup(); err != nil { t.Fatalf("Failed to set up test suite: %v", err) } - // Register cleanup - t.Cleanup(func() { - suite.Cleanup() - }) - + t.Cleanup(func() { suite.Cleanup() }) return suite } diff --git a/integrationtests/tests/go/references/references_test.go b/integrationtests/tests/go/references/references_test.go index 895aaeef..ab3f22da 100644 --- a/integrationtests/tests/go/references/references_test.go +++ b/integrationtests/tests/go/references/references_test.go @@ -12,13 +12,8 @@ import ( ) // TestFindReferences tests the FindReferences tool with Go symbols -// that have references across different files +// that have references across different files. Runs in both subprocess and headless (listen-mode) modes. func TestFindReferences(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - tests := []struct { name string symbolName string @@ -91,28 +86,36 @@ func TestFindReferences(t *testing.T) { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - // Call the FindReferences tool - result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) - if err != nil { - t.Fatalf("Failed to find references: %v", err) - } - - // Check that the result contains relevant information - if !strings.Contains(result, tc.expectedText) { - t.Errorf("References do not contain expected text: %s", tc.expectedText) - } + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() - // Count how many different files are mentioned in the result - fileCount := countFilesInResult(result) - if fileCount < tc.expectedFiles { - t.Errorf("Expected references in at least %d files, but found in %d files", - tc.expectedFiles, fileCount) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Call the FindReferences tool + result, err := tools.FindReferences(ctx, suite.Client, tc.symbolName) + if err != nil { + t.Fatalf("Failed to find references: %v", err) + } + // Check that the result contains relevant information + if !strings.Contains(result, tc.expectedText) { + t.Errorf("References do not contain expected text: %s", tc.expectedText) + } + // Count how many different files are mentioned in the result + fileCount := countFilesInResult(result) + if fileCount < tc.expectedFiles { + t.Errorf("Expected references in at least %d files, but found in %d files", + tc.expectedFiles, fileCount) + } + // Use snapshot testing to verify exact output + common.SnapshotTest(t, "go", "references", tc.snapshotName, result) + }) } - - // Use snapshot testing to verify exact output - common.SnapshotTest(t, "go", "references", tc.snapshotName, result) }) } } diff --git a/integrationtests/tests/go/rename_symbol/rename_symbol_test.go b/integrationtests/tests/go/rename_symbol/rename_symbol_test.go index c5ea44a8..3e482b06 100644 --- a/integrationtests/tests/go/rename_symbol/rename_symbol_test.go +++ b/integrationtests/tests/go/rename_symbol/rename_symbol_test.go @@ -12,101 +12,110 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestRenameSymbol tests the RenameSymbol functionality with the Go language server +// TestRenameSymbol tests the RenameSymbol functionality with the Go language server. +// Runs in both subprocess and headless (listen-mode) modes. func TestRenameSymbol(t *testing.T) { - // Test with a successful rename of a symbol that exists - t.Run("SuccessfulRename", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) - - // Wait for initialization - time.Sleep(2 * time.Second) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - // Ensure the file is open - filePath := filepath.Join(suite.WorkspaceDir, "types.go") - err := suite.Client.OpenFile(ctx, filePath) - if err != nil { - t.Fatalf("Failed to open types.go: %v", err) - } - - // Request to rename SharedConstant to UpdatedConstant at its definition - // The constant is defined at line 25, column 7 of types.go - result, err := tools.RenameSymbol(ctx, suite.Client, filePath, 25, 7, "UpdatedConstant") - if err != nil { - t.Fatalf("RenameSymbol failed: %v", err) - } - - // Verify the constant was renamed - if !strings.Contains(result, "Successfully renamed symbol") { - t.Errorf("Expected success message but got: %s", result) - } - - // Verify it's mentioned that it renamed multiple occurrences - if !strings.Contains(result, "occurrences") { - t.Errorf("Expected multiple occurrences to be renamed but got: %s", result) - } - - common.SnapshotTest(t, "go", "rename_symbol", "successful", result) - - // Verify that the rename worked by checking for the updated constant name in the file - fileContent, err := suite.ReadFile("types.go") - if err != nil { - t.Fatalf("Failed to read types.go: %v", err) - } - - if !strings.Contains(fileContent, "UpdatedConstant") { - t.Errorf("Expected to find renamed constant 'UpdatedConstant' in types.go") - } - - // Also check that it was renamed in the consumer file - consumerContent, err := suite.ReadFile("consumer.go") - if err != nil { - t.Fatalf("Failed to read consumer.go: %v", err) - } - - if !strings.Contains(consumerContent, "UpdatedConstant") { - t.Errorf("Expected to find renamed constant 'UpdatedConstant' in consumer.go") - } - }) - - // Test with a symbol that doesn't exist - t.Run("SymbolNotFound", func(t *testing.T) { - // Get a test suite with clean code - suite := internal.GetTestSuite(t) - - // Wait for initialization - time.Sleep(2 * time.Second) - - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) - defer cancel() - - // Ensure the file is open - filePath := filepath.Join(suite.WorkspaceDir, "clean.go") - err := suite.Client.OpenFile(ctx, filePath) - if err != nil { - t.Fatalf("Failed to open clean.go: %v", err) - } - - // Request to rename a symbol at a position where no symbol exists - // The clean.go file doesn't have content at this position - _, err = tools.RenameSymbol(ctx, suite.Client, filePath, 10, 10, "NewName") - - // Expect an error because there's no symbol at that position - if err == nil { - t.Errorf("Expected an error when renaming non-existent symbol, but got success") - } - - // Save the error message for the snapshot - errorMessage := err.Error() - - // Verify it mentions failing to rename - if !strings.Contains(errorMessage, "failed to rename") { - t.Errorf("Expected error message about failed rename but got: %s", errorMessage) - } - - common.SnapshotTest(t, "go", "rename_symbol", "not_found", errorMessage) - }) + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + + t.Run(mode.name, func(t *testing.T) { + // Test with a successful rename of a symbol that exists + t.Run("SuccessfulRename", func(t *testing.T) { + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + + // Wait for initialization + time.Sleep(2 * time.Second) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // Ensure the file is open + filePath := filepath.Join(suite.WorkspaceDir, "types.go") + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open types.go: %v", err) + } + + // Request to rename SharedConstant to UpdatedConstant at its definition + // The constant is defined at line 25, column 7 of types.go + result, err := tools.RenameSymbol(ctx, suite.Client, filePath, 25, 7, "UpdatedConstant") + if err != nil { + t.Fatalf("RenameSymbol failed: %v", err) + } + + // Verify the constant was renamed + if !strings.Contains(result, "Successfully renamed symbol") { + t.Errorf("Expected success message but got: %s", result) + } + + // Verify it's mentioned that it renamed multiple occurrences + if !strings.Contains(result, "occurrences") { + t.Errorf("Expected multiple occurrences to be renamed but got: %s", result) + } + + common.SnapshotTest(t, "go", "rename_symbol", "successful", result) + + // Verify that the rename worked by checking for the updated constant name in the file + fileContent, err := suite.ReadFile("types.go") + if err != nil { + t.Fatalf("Failed to read types.go: %v", err) + } + + if !strings.Contains(fileContent, "UpdatedConstant") { + t.Errorf("Expected to find renamed constant 'UpdatedConstant' in types.go") + } + + // Also check that it was renamed in the consumer file + consumerContent, err := suite.ReadFile("consumer.go") + if err != nil { + t.Fatalf("Failed to read consumer.go: %v", err) + } + + if !strings.Contains(consumerContent, "UpdatedConstant") { + t.Errorf("Expected to find renamed constant 'UpdatedConstant' in consumer.go") + } + }) + + // Test with a symbol that doesn't exist + t.Run("SymbolNotFound", func(t *testing.T) { + // Get a test suite with clean code + suite := internal.GetTestSuite(t, mode.headless) + // Wait for initialization + + time.Sleep(2 * time.Second) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // Ensure the file is open + filePath := filepath.Join(suite.WorkspaceDir, "clean.go") + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open clean.go: %v", err) + } + + // Request to rename a symbol at a position where no symbol exists + // The clean.go file doesn't have content at this position + _, err = tools.RenameSymbol(ctx, suite.Client, filePath, 10, 10, "NewName") + + // Expect an error because there's no symbol at that position + if err == nil { + t.Errorf("Expected an error when renaming non-existent symbol, but got success") + } + + // Save the error message for the snapshot + errorMessage := err.Error() + + // Verify it mentions failing to rename + if !strings.Contains(errorMessage, "failed to rename") && !strings.Contains(errorMessage, "column is beyond") { + t.Errorf("Expected error message about failed rename but got: %s", errorMessage) + } + + common.SnapshotTest(t, "go", "rename_symbol", "not_found", errorMessage) + }) + }) + } } diff --git a/integrationtests/tests/go/text_edit/text_edit_test.go b/integrationtests/tests/go/text_edit/text_edit_test.go index 38f3cdcc..137ade83 100644 --- a/integrationtests/tests/go/text_edit/text_edit_test.go +++ b/integrationtests/tests/go/text_edit/text_edit_test.go @@ -12,17 +12,9 @@ import ( "github.com/isaacphi/mcp-language-server/internal/tools" ) -// TestApplyTextEdits tests the ApplyTextEdits tool with various edit scenarios +// TestApplyTextEdits tests the ApplyTextEdits tool with various edit scenarios. +// Runs in both subprocess and headless (listen-mode) modes. func TestApplyTextEdits(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - // Create a test file with known content we can edit - testFileName := "edit_test.go" - testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) - initialContent := `package main import "fmt" @@ -41,12 +33,6 @@ func AnotherFunction() { } ` - // Write the test file using the suite's method to ensure proper handling - err := suite.WriteFile(testFileName, initialContent) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - tests := []struct { name string edits []tools.TextEdit @@ -163,55 +149,67 @@ func AnotherFunction() { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + testFileName := "edit_test.go" + testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) + // Reset the file before each test err := suite.WriteFile(testFileName, initialContent) if err != nil { - t.Fatalf("Failed to reset test file: %v", err) + t.Fatalf("Failed to create test file: %v", err) } - // Call the ApplyTextEdits tool with the non-URL file path - result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) - if err != nil { - t.Fatalf("Failed to apply text edits: %v", err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := suite.WriteFile(testFileName, initialContent) + if err != nil { + t.Fatalf("Failed to reset test file: %v", err) + } - // Verify the result message - if !strings.Contains(result, "Successfully applied text edits") { - t.Errorf("Result does not contain success message: %s", result) - } + // Call the ApplyTextEdits tool with the non-URL file path + result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) + if err != nil { + t.Fatalf("Failed to apply text edits: %v", err) + } - // Read the file content after edits - content, err := suite.ReadFile(testFileName) - if err != nil { - t.Fatalf("Failed to read test file after edits: %v", err) - } + // Verify the result message + if !strings.Contains(result, "Successfully applied text edits") { + t.Errorf("Result does not contain success message: %s", result) + } - // Run all verification functions - for _, verify := range tc.verifications { - verify(t, content) - } + // Read the file content after edits + content, err := suite.ReadFile(testFileName) + if err != nil { + t.Fatalf("Failed to read test file after edits: %v", err) + } + + // Run all verification functions + for _, verify := range tc.verifications { + verify(t, content) + } - // Use snapshot testing to verify the exact result - snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) - common.SnapshotTest(t, "go", "text_edit", snapshotName, result) + // Use snapshot testing to verify the exact result + snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) + common.SnapshotTest(t, "go", "text_edit", snapshotName, result) + }) + } }) } } -// TestApplyTextEditsWithBorderCases tests edge cases for the ApplyTextEdits tool +// TestApplyTextEditsWithBorderCases tests edge cases for the ApplyTextEdits tool. +// Runs in both subprocess and headless (listen-mode) modes. func TestApplyTextEditsWithBorderCases(t *testing.T) { - suite := internal.GetTestSuite(t) - - ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) - defer cancel() - - // Create a test file with known content we can edit - testFileName := "edge_case_test.go" - testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) - - initialContent := `package main + borderCasesContent := `package main import "fmt" @@ -228,12 +226,6 @@ func LastFunction() { } ` - // Write the test file using the suite's method - err := suite.WriteFile(testFileName, initialContent) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - tests := []struct { name string edits []tools.TextEdit @@ -311,39 +303,58 @@ func NewFunction() { }, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { + for _, mode := range []struct { + name string + headless bool + }{{"Subprocess", false}, {"Headless", true}} { + t.Run(mode.name, func(t *testing.T) { + suite := internal.GetTestSuite(t, mode.headless) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + testFileName := "edge_case_test.go" + testFilePath := filepath.Join(suite.WorkspaceDir, testFileName) + // Reset the file before each test - err := suite.WriteFile(testFileName, initialContent) + err := suite.WriteFile(testFileName, borderCasesContent) if err != nil { - t.Fatalf("Failed to reset test file: %v", err) + t.Fatalf("Failed to create test file: %v", err) } - // Call the ApplyTextEdits tool - result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) - if err != nil { - t.Fatalf("Failed to apply text edits: %v", err) - } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := suite.WriteFile(testFileName, borderCasesContent) + if err != nil { + t.Fatalf("Failed to reset test file: %v", err) + } - // Verify the result message - if !strings.Contains(result, "Successfully applied text edits") { - t.Errorf("Result does not contain success message: %s", result) - } + // Call the ApplyTextEdits tool + result, err := tools.ApplyTextEdits(ctx, suite.Client, testFilePath, tc.edits) + if err != nil { + t.Fatalf("Failed to apply text edits: %v", err) + } - // Read the file content after edits - content, err := suite.ReadFile(testFileName) - if err != nil { - t.Fatalf("Failed to read test file after edits: %v", err) - } + // Verify the result message + if !strings.Contains(result, "Successfully applied text edits") { + t.Errorf("Result does not contain success message: %s", result) + } - // Run all verification functions - for _, verify := range tc.verifications { - verify(t, content) - } + // Read the file content after edits + content, err := suite.ReadFile(testFileName) + if err != nil { + t.Fatalf("Failed to read test file after edits: %v", err) + } - // Use snapshot testing to verify the exact result - snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) - common.SnapshotTest(t, "go", "text_edit", snapshotName, result) + // Run all verification functions + for _, verify := range tc.verifications { + verify(t, content) + } + + // Use snapshot testing to verify the exact result + snapshotName := strings.ToLower(strings.ReplaceAll(tc.name, " ", "_")) + common.SnapshotTest(t, "go", "text_edit", snapshotName, result) + }) + } }) } } diff --git a/internal/lsp/client.go b/internal/lsp/client.go index fc07059d..859c1333 100644 --- a/internal/lsp/client.go +++ b/internal/lsp/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "net" "os" "os/exec" "strings" @@ -101,6 +102,33 @@ func NewClient(command string, args ...string) (*Client, error) { return client, nil } +// NewClientHeadless connects to an already-running LSP server at addr (e.g. "localhost:6061"). +// The server must speak LSP JSON-RPC over the stream (Content-Length + JSON). No process is +// started and stderr is not read. For gopls, start the server with -listen=:6061 so it +// accepts LSP connections on that port (--debug is for the debug HTTP server, not LSP). +func NewClientHeadless(addr string) (*Client, error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to LSP at %s: %w", addr, err) + } + + client := &Client{ + Cmd: nil, + stdin: conn, + stdout: bufio.NewReader(conn), + stderr: nil, + handlers: make(map[string]chan *Message), + notificationHandlers: make(map[string]NotificationHandler), + serverRequestHandlers: make(map[string]ServerRequestHandler), + diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic), + openFiles: make(map[string]*OpenFileInfo), + } + + go client.handleMessages() + + return client, nil +} + func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) { c.notificationMu.Lock() defer c.notificationMu.Unlock() @@ -214,13 +242,15 @@ func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) ( 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 + // LSP-specific initialization (only when we started the process; headless client has no Cmd) + if c.Cmd != nil { + 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 + } } } @@ -235,7 +265,16 @@ func (c *Client) Close() error { // Attempt to close files but continue shutdown regardless c.CloseAllFiles(ctx) - // Force kill the LSP process if it doesn't exit within timeout + if c.Cmd == nil { + // Headless mode: only close the connection; do not send shutdown/exit or kill any process + if err := c.stdin.Close(); err != nil { + lspLogger.Error("Failed to close connection: %v", err) + return err + } + return nil + } + + // Subprocess mode: close stdin then wait for process (with optional force kill) forcedKill := make(chan struct{}) go func() { select { @@ -262,7 +301,7 @@ func (c *Client) Close() error { // Wait for process to exit err := c.Cmd.Wait() - close(forcedKill) // Stop the force kill goroutine + close(forcedKill) return err } diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 6e7a0b8a..73187a1f 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -121,6 +121,10 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc if err != nil { return err } + // Abort scan when context is cancelled so the watcher can exit and the process can terminate + if ctx.Err() != nil { + return ctx.Err() + } // Skip directories that should be excluded if d.IsDir() { @@ -157,6 +161,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc func (w *WorkspaceWatcher) WatchWorkspace(ctx context.Context, workspacePath string) { w.workspacePath = workspacePath + // Unregister the file watch handler when this watcher exits so the LSP + // package does not call into a stopped watcher (avoids hangs/panics in tests + // and when running headless without a watcher). + defer lsp.RegisterFileWatchHandler(nil) + // Initialize gitignore matcher gitignore, err := NewGitignoreMatcher(workspacePath) if err != nil { diff --git a/main.go b/main.go index f6f3ed5c..770a16b3 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ type config struct { workspaceDir string lspCommand string lspArgs []string + lspConnect string // if set, connect to existing LSP at this address (e.g. localhost:6061) instead of starting a process } type mcpServer struct { @@ -33,12 +34,14 @@ type mcpServer struct { ctx context.Context cancelFunc context.CancelFunc workspaceWatcher *watcher.WorkspaceWatcher + headless bool // true when using -lsp-connect (do not send shutdown/exit on cleanup) } 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.lspConnect, "lsp-connect", "", "Connect to existing LSP at address (e.g. localhost:6061) instead of starting a process (for gopls use -listen=:PORT)") flag.Parse() // Get remaining args after -- as LSP arguments @@ -59,13 +62,17 @@ func parseConfig() (*config, error) { return nil, fmt.Errorf("workspace directory does not exist: %s", cfg.workspaceDir) } - // Validate LSP command - if cfg.lspCommand == "" { - return nil, fmt.Errorf("LSP command is required") + // Either lsp-connect or lsp command is required + if cfg.lspConnect == "" && cfg.lspCommand == "" { + return nil, fmt.Errorf("either -lsp-connect or -lsp is required") } - - if _, err := exec.LookPath(cfg.lspCommand); err != nil { - return nil, fmt.Errorf("LSP command not found: %s", cfg.lspCommand) + if cfg.lspConnect != "" && cfg.lspCommand != "" { + return nil, fmt.Errorf("cannot use both -lsp-connect and -lsp") + } + if cfg.lspCommand != "" { + if _, err := exec.LookPath(cfg.lspCommand); err != nil { + return nil, fmt.Errorf("LSP command not found: %s", cfg.lspCommand) + } } return cfg, nil @@ -85,12 +92,26 @@ func (s *mcpServer) initializeLSP() error { 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) + var client *lsp.Client + var err error + if s.config.lspConnect != "" { + s.headless = true + client, err = lsp.NewClientHeadless(s.config.lspConnect) + if err != nil { + return fmt.Errorf("failed to connect to LSP at %s: %v", s.config.lspConnect, err) + } + } else { + client, err = lsp.NewClient(s.config.lspCommand, s.config.lspArgs...) + if err != nil { + return fmt.Errorf("failed to create LSP client: %v", err) + } } s.lspClient = client - s.workspaceWatcher = watcher.NewWorkspaceWatcher(client) + + // In headless mode, disable file watchers to avoid background scanning and fsnotify + if !s.headless { + s.workspaceWatcher = watcher.NewWorkspaceWatcher(client) + } initResult, err := client.InitializeLSPClient(s.ctx, s.config.workspaceDir) if err != nil { @@ -99,7 +120,9 @@ func (s *mcpServer) initializeLSP() error { coreLogger.Debug("Server capabilities: %+v", initResult.Capabilities) - go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.workspaceDir) + if s.workspaceWatcher != nil { + go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.workspaceDir) + } return client.WaitForServerReady(s.ctx) } @@ -201,31 +224,33 @@ func cleanup(s *mcpServer, done chan struct{}) { coreLogger.Info("Closing open files") s.lspClient.CloseAllFiles(ctx) - // Create a shorter timeout context for the shutdown request - shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) - defer shutdownCancel() + if !s.headless { + // Create a shorter timeout context for the shutdown request + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer shutdownCancel() + + // Run shutdown in a goroutine with timeout to avoid blocking if LSP doesn't respond + shutdownDone := make(chan struct{}) + go func() { + coreLogger.Info("Sending shutdown request") + if err := s.lspClient.Shutdown(shutdownCtx); err != nil { + coreLogger.Error("Shutdown request failed: %v", err) + } + close(shutdownDone) + }() - // Run shutdown in a goroutine with timeout to avoid blocking if LSP doesn't respond - shutdownDone := make(chan struct{}) - go func() { - coreLogger.Info("Sending shutdown request") - if err := s.lspClient.Shutdown(shutdownCtx); err != nil { - coreLogger.Error("Shutdown request failed: %v", err) + // Wait for shutdown with timeout + select { + case <-shutdownDone: + coreLogger.Info("Shutdown request completed") + case <-time.After(1 * time.Second): + coreLogger.Warn("Shutdown request timed out, proceeding with exit") } - close(shutdownDone) - }() - // Wait for shutdown with timeout - select { - case <-shutdownDone: - coreLogger.Info("Shutdown request completed") - case <-time.After(1 * time.Second): - coreLogger.Warn("Shutdown request timed out, proceeding with exit") - } - - coreLogger.Info("Sending exit notification") - if err := s.lspClient.Exit(ctx); err != nil { - coreLogger.Error("Exit notification failed: %v", err) + coreLogger.Info("Sending exit notification") + if err := s.lspClient.Exit(ctx); err != nil { + coreLogger.Error("Exit notification failed: %v", err) + } } coreLogger.Info("Closing LSP client")