diff --git a/integrationtests/fixtures/snapshots/go/rename_symbol/not_found.snap b/integrationtests/fixtures/snapshots/go/rename_symbol/not_found.snap new file mode 100644 index 0000000..0d99bd9 --- /dev/null +++ b/integrationtests/fixtures/snapshots/go/rename_symbol/not_found.snap @@ -0,0 +1 @@ +failed to rename symbol: request failed: column is beyond end of line (code: 0) \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/go/rename_symbol/successful.snap b/integrationtests/fixtures/snapshots/go/rename_symbol/successful.snap new file mode 100644 index 0000000..f9d03cc --- /dev/null +++ b/integrationtests/fixtures/snapshots/go/rename_symbol/successful.snap @@ -0,0 +1,2 @@ +Successfully renamed symbol to 'UpdatedConstant'. +Updated 4 occurrences across 3 files. \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/python/rename_symbol/not_found.snap b/integrationtests/fixtures/snapshots/python/rename_symbol/not_found.snap new file mode 100644 index 0000000..973d607 --- /dev/null +++ b/integrationtests/fixtures/snapshots/python/rename_symbol/not_found.snap @@ -0,0 +1 @@ +Failed to rename symbol. 0 occurrences found. \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/python/rename_symbol/successful.snap b/integrationtests/fixtures/snapshots/python/rename_symbol/successful.snap new file mode 100644 index 0000000..eaec313 --- /dev/null +++ b/integrationtests/fixtures/snapshots/python/rename_symbol/successful.snap @@ -0,0 +1,2 @@ +Successfully renamed symbol to 'UPDATED_CONSTANT'. +Updated 6 occurrences across 3 files. \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/rust/rename_symbol/not_found.snap b/integrationtests/fixtures/snapshots/rust/rename_symbol/not_found.snap new file mode 100644 index 0000000..e9e96cd --- /dev/null +++ b/integrationtests/fixtures/snapshots/rust/rename_symbol/not_found.snap @@ -0,0 +1 @@ +failed to rename symbol: request failed: No references found at position (code: -32602) \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/rust/rename_symbol/successful.snap b/integrationtests/fixtures/snapshots/rust/rename_symbol/successful.snap new file mode 100644 index 0000000..7115d23 --- /dev/null +++ b/integrationtests/fixtures/snapshots/rust/rename_symbol/successful.snap @@ -0,0 +1,2 @@ +Successfully renamed symbol to 'UPDATED_CONSTANT'. +Updated 5 occurrences across 3 files. \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/typescript/rename_symbol/not_found.snap b/integrationtests/fixtures/snapshots/typescript/rename_symbol/not_found.snap new file mode 100644 index 0000000..973d607 --- /dev/null +++ b/integrationtests/fixtures/snapshots/typescript/rename_symbol/not_found.snap @@ -0,0 +1 @@ +Failed to rename symbol. 0 occurrences found. \ No newline at end of file diff --git a/integrationtests/fixtures/snapshots/typescript/rename_symbol/successful.snap b/integrationtests/fixtures/snapshots/typescript/rename_symbol/successful.snap new file mode 100644 index 0000000..3181da3 --- /dev/null +++ b/integrationtests/fixtures/snapshots/typescript/rename_symbol/successful.snap @@ -0,0 +1,2 @@ +Successfully renamed symbol to 'UpdatedConstant'. +Updated 5 occurrences across 3 files. \ No newline at end of file diff --git a/integrationtests/languages/go/rename_symbol/rename_symbol_test.go b/integrationtests/languages/go/rename_symbol/rename_symbol_test.go new file mode 100644 index 0000000..a6e86fb --- /dev/null +++ b/integrationtests/languages/go/rename_symbol/rename_symbol_test.go @@ -0,0 +1,112 @@ +package rename_symbol_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/languages/common" + "github.com/isaacphi/mcp-language-server/integrationtests/languages/go/internal" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestRenameSymbol tests the RenameSymbol functionality with the Go language server +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) + }) +} diff --git a/integrationtests/languages/python/rename_symbol/rename_symbol_test.go b/integrationtests/languages/python/rename_symbol/rename_symbol_test.go new file mode 100644 index 0000000..2e5f9d8 --- /dev/null +++ b/integrationtests/languages/python/rename_symbol/rename_symbol_test.go @@ -0,0 +1,145 @@ +package rename_symbol_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/languages/common" + "github.com/isaacphi/mcp-language-server/integrationtests/languages/python/internal" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestRenameSymbol tests the RenameSymbol functionality with the Python language server +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, "helper.py") + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open helper.py: %v", err) + } + + // Open the consumer file too to ensure references are indexed + consumerPath := filepath.Join(suite.WorkspaceDir, "consumer.py") + err = suite.Client.OpenFile(ctx, consumerPath) + if err != nil { + t.Fatalf("Failed to open consumer.py: %v", err) + } + + // Give the language server time to process the files + time.Sleep(2 * time.Second) + + // Request to rename SHARED_CONSTANT to UPDATED_CONSTANT at its definition + // The constant is defined at line 8, column 1 of helper.py + result, err := tools.RenameSymbol(ctx, suite.Client, filePath, 8, 1, "UPDATED_CONSTANT") + 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, "python", "rename_symbol", "successful", result) + + // Verify that the rename worked by checking for the updated constant name in the file + fileContent, err := suite.ReadFile("helper.py") + if err != nil { + t.Fatalf("Failed to read helper.py: %v", err) + } + + if !strings.Contains(fileContent, "UPDATED_CONSTANT") { + t.Errorf("Expected to find renamed constant 'UPDATED_CONSTANT' in helper.py") + } + + // Also check that it was renamed in the consumer file + consumerContent, err := suite.ReadFile("consumer.py") + if err != nil { + t.Fatalf("Failed to read consumer.py: %v", err) + } + + if !strings.Contains(consumerContent, "UPDATED_CONSTANT") { + t.Errorf("Expected to find renamed constant 'UPDATED_CONSTANT' in consumer.py") + } + }) + + // 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.py") + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + t.Fatalf("Failed to open clean.py: %v", err) + } + + // Create a simple file with known content first + simpleContent := `"""A simple Python file for testing.""" + +def dummy_function(): + """This is a dummy function.""" + pass +` + err = suite.WriteFile("position_test.py", simpleContent) + if err != nil { + t.Fatalf("Failed to create position_test.py: %v", err) + } + + testFilePath := filepath.Join(suite.WorkspaceDir, "position_test.py") + err = suite.Client.OpenFile(ctx, testFilePath) + if err != nil { + t.Fatalf("Failed to open position_test.py: %v", err) + } + + time.Sleep(1 * time.Second) // Give time for the file to be processed + + // Request to rename a symbol at a position where no symbol exists (in whitespace) + result, err := tools.RenameSymbol(ctx, suite.Client, testFilePath, 4, 1, "NewName") + + // The language server might actually succeed with no rename operations + // In this case, we check if it reports no occurrences + if err == nil { + // Check if result indicates nothing was renamed + if !strings.Contains(result, "0 occurrences") { + t.Errorf("Expected 0 occurrences or error for non-existent symbol, but got: %s", result) + } + common.SnapshotTest(t, "python", "rename_symbol", "not_found", result) + } else { + // If there was an error, check it and snapshot that instead + errorMessage := err.Error() + if !strings.Contains(errorMessage, "failed to rename") && + !strings.Contains(errorMessage, "not found") && + !strings.Contains(errorMessage, "cannot rename") { + t.Errorf("Expected error message about failed rename but got: %s", errorMessage) + } + common.SnapshotTest(t, "python", "rename_symbol", "not_found", errorMessage) + } + }) +} diff --git a/integrationtests/languages/rust/diagnostics/diagnostics_test.go b/integrationtests/languages/rust/diagnostics/diagnostics_test.go index a0754f5..9835341 100644 --- a/integrationtests/languages/rust/diagnostics/diagnostics_test.go +++ b/integrationtests/languages/rust/diagnostics/diagnostics_test.go @@ -158,21 +158,16 @@ pub fn helper_function(value: i32) -> String { } // Wait for LSP to process the change - time.Sleep(3 * time.Second) + time.Sleep(6 * 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.rs: %v", err) - } - err = suite.Client.OpenFile(ctx, consumerPath) if err != nil { t.Fatalf("Failed to reopen consumer.rs: %v", err) } - // Wait for diagnostics to be generated - time.Sleep(3 * time.Second) + // Wait for LSP to process the change + time.Sleep(6 * time.Second) // Check diagnostics again on consumer file - should now have an error result, err = tools.GetDiagnosticsForFile(ctx, suite.Client, consumerPath, true, true) diff --git a/integrationtests/languages/rust/hover/hover_test.go b/integrationtests/languages/rust/hover/hover_test.go index a0af4da..8d5f1fe 100644 --- a/integrationtests/languages/rust/hover/hover_test.go +++ b/integrationtests/languages/rust/hover/hover_test.go @@ -119,7 +119,7 @@ func TestHover(t *testing.T) { // Get a test suite suite := internal.GetTestSuite(t) - ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) defer cancel() // Open all files and wait for rust-analyzer to index them @@ -131,6 +131,8 @@ func TestHover(t *testing.T) { t.Fatalf("Failed to open %s: %v", tt.file, err) } + time.Sleep(3 * time.Second) + // Get hover info result, err := tools.GetHoverInfo(ctx, suite.Client, filePath, tt.line, tt.column) if err != nil { diff --git a/integrationtests/languages/rust/rename_symbol/rename_symbol_test.go b/integrationtests/languages/rust/rename_symbol/rename_symbol_test.go new file mode 100644 index 0000000..e826494 --- /dev/null +++ b/integrationtests/languages/rust/rename_symbol/rename_symbol_test.go @@ -0,0 +1,149 @@ +package rename_symbol_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/languages/common" + "github.com/isaacphi/mcp-language-server/integrationtests/languages/rust/internal" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestRenameSymbol tests the RenameSymbol functionality with the Rust language server +func TestRenameSymbol(t *testing.T) { + // Helper function to open all files and wait for indexing (copied from diagnostics_test.go) + openAllFilesAndWait := func(suite *common.TestSuite, ctx context.Context) { + // Open all files to ensure rust-analyzer indexes everything + filesToOpen := []string{ + "src/main.rs", + "src/types.rs", + "src/helper.rs", + "src/consumer.rs", + "src/another_consumer.rs", + "src/clean.rs", + } + + for _, file := range filesToOpen { + filePath := filepath.Join(suite.WorkspaceDir, file) + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + // Don't fail the test, some files might not exist in certain tests + t.Logf("Note: Failed to open %s: %v", file, err) + } + } + + // Give rust-analyzer time to index + time.Sleep(3 * time.Second) + } + + // 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) + + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + // Open all files and wait for rust-analyzer to index them + openAllFilesAndWait(suite, ctx) + + // Ensure the file is open + typesPath := filepath.Join(suite.WorkspaceDir, "src/types.rs") + + // Request to rename SHARED_CONSTANT to UPDATED_CONSTANT at its definition + // The constant is defined at line 78, column 13 of types.rs + result, err := tools.RenameSymbol(ctx, suite.Client, typesPath, 78, 13, "UPDATED_CONSTANT") + 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, "rust", "rename_symbol", "successful", result) + + // Verify that the rename worked by checking for the updated constant name in the file + fileContent, err := suite.ReadFile("src/types.rs") + if err != nil { + t.Fatalf("Failed to read types.rs: %v", err) + } + + if !strings.Contains(fileContent, "UPDATED_CONSTANT") { + t.Errorf("Expected to find renamed constant 'UPDATED_CONSTANT' in types.rs") + } + + // Also check that it was renamed in the consumer file + consumerContent, err := suite.ReadFile("src/consumer.rs") + if err != nil { + t.Fatalf("Failed to read consumer.rs: %v", err) + } + + if !strings.Contains(consumerContent, "UPDATED_CONSTANT") { + t.Errorf("Expected to find renamed constant 'UPDATED_CONSTANT' in consumer.rs") + } + }) + + // 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) + + ctx, cancel := context.WithTimeout(suite.Context, 10*time.Second) + defer cancel() + + // Open all files and wait for rust-analyzer to index them + openAllFilesAndWait(suite, ctx) + + // Create a simple file with known content first + simpleContent := `// A simple Rust file for testing + +fn dummy_function() { + // This is a dummy function +} +` + err := suite.WriteFile("src/position_test.rs", simpleContent) + if err != nil { + t.Fatalf("Failed to create position_test.rs: %v", err) + } + + testFilePath := filepath.Join(suite.WorkspaceDir, "src/position_test.rs") + err = suite.Client.OpenFile(ctx, testFilePath) + if err != nil { + t.Fatalf("Failed to open position_test.rs: %v", err) + } + + time.Sleep(1 * time.Second) // Give time for the file to be processed + + // Request to rename a symbol at a position where no symbol exists (in whitespace) + result, err := tools.RenameSymbol(ctx, suite.Client, testFilePath, 4, 1, "NewName") + + // The language server might actually succeed with no rename operations + // In this case, we check if it reports no occurrences + if err == nil { + // Check if result indicates nothing was renamed + if !strings.Contains(result, "0 occurrences") { + t.Errorf("Expected 0 occurrences or error for non-existent symbol, but got: %s", result) + } + common.SnapshotTest(t, "rust", "rename_symbol", "not_found", result) + } else { + // If there was an error, check it and snapshot that instead + errorMessage := err.Error() + if !strings.Contains(errorMessage, "failed to rename") && + !strings.Contains(errorMessage, "not found") && + !strings.Contains(errorMessage, "cannot rename") { + t.Errorf("Expected error message about failed rename but got: %s", errorMessage) + } + common.SnapshotTest(t, "rust", "rename_symbol", "not_found", errorMessage) + } + }) +} diff --git a/integrationtests/languages/typescript/rename_symbol/rename_symbol_test.go b/integrationtests/languages/typescript/rename_symbol/rename_symbol_test.go new file mode 100644 index 0000000..e01a2f0 --- /dev/null +++ b/integrationtests/languages/typescript/rename_symbol/rename_symbol_test.go @@ -0,0 +1,146 @@ +package rename_symbol_test + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/isaacphi/mcp-language-server/integrationtests/languages/common" + "github.com/isaacphi/mcp-language-server/integrationtests/languages/typescript/internal" + "github.com/isaacphi/mcp-language-server/internal/tools" +) + +// TestRenameSymbol tests the RenameSymbol functionality with the TypeScript language server +func TestRenameSymbol(t *testing.T) { + // Helper function to open all files and wait for indexing (copied from diagnostics_test.go) + openAllFilesAndWait := func(suite *common.TestSuite, ctx context.Context) { + // Open all files to ensure TypeScript server indexes everything + filesToOpen := []string{ + "main.ts", + "helper.ts", + "consumer.ts", + "another_consumer.ts", + "clean.ts", + } + + for _, file := range filesToOpen { + filePath := filepath.Join(suite.WorkspaceDir, file) + err := suite.Client.OpenFile(ctx, filePath) + if err != nil { + // Don't fail the test, some files might not exist in certain tests + t.Logf("Note: Failed to open %s: %v", file, err) + } + } + + // Give TypeScript server time to process files + time.Sleep(3 * time.Second) + } + + // 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) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // Open all files and wait for TypeScript server to index them + openAllFilesAndWait(suite, ctx) + + // Request to rename SharedConstant to UpdatedConstant at its definition + // The constant is defined at line 39, column 14 of helper.ts + helperPath := filepath.Join(suite.WorkspaceDir, "helper.ts") + result, err := tools.RenameSymbol(ctx, suite.Client, helperPath, 39, 14, "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, "typescript", "rename_symbol", "successful", result) + + // Verify that the rename worked by checking for the updated constant name in the file + fileContent, err := suite.ReadFile("helper.ts") + if err != nil { + t.Fatalf("Failed to read helper.ts: %v", err) + } + + if !strings.Contains(fileContent, "UpdatedConstant") { + t.Errorf("Expected to find renamed constant 'UpdatedConstant' in helper.ts") + } + + // Also check that it was renamed in the consumer file + consumerContent, err := suite.ReadFile("consumer.ts") + if err != nil { + t.Fatalf("Failed to read consumer.ts: %v", err) + } + + if !strings.Contains(consumerContent, "UpdatedConstant") { + t.Errorf("Expected to find renamed constant 'UpdatedConstant' in consumer.ts") + } + }) + + // 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) + + ctx, cancel := context.WithTimeout(suite.Context, 5*time.Second) + defer cancel() + + // Open all files and wait for TypeScript server to index them + openAllFilesAndWait(suite, ctx) + + // Create a simple file with known content first + simpleContent := `// A simple TypeScript file for testing + +function dummyFunction(): void { + // This is a dummy function +} +` + err := suite.WriteFile("position_test.ts", simpleContent) + if err != nil { + t.Fatalf("Failed to create position_test.ts: %v", err) + } + + testFilePath := filepath.Join(suite.WorkspaceDir, "position_test.ts") + err = suite.Client.OpenFile(ctx, testFilePath) + if err != nil { + t.Fatalf("Failed to open position_test.ts: %v", err) + } + + time.Sleep(1 * time.Second) // Give time for the file to be processed + + // Request to rename a symbol at a position where no symbol exists (in whitespace) + result, err := tools.RenameSymbol(ctx, suite.Client, testFilePath, 4, 1, "NewName") + + // The language server might actually succeed with no rename operations + // In this case, we check if it reports no occurrences + if err == nil { + // Check if result indicates nothing was renamed + if !strings.Contains(result, "0 occurrences") { + t.Errorf("Expected 0 occurrences or error for non-existent symbol, but got: %s", result) + } + common.SnapshotTest(t, "typescript", "rename_symbol", "not_found", result) + } else { + // If there was an error, check it and snapshot that instead + errorMessage := err.Error() + if !strings.Contains(errorMessage, "failed to rename") && + !strings.Contains(errorMessage, "not found") && + !strings.Contains(errorMessage, "cannot rename") { + t.Errorf("Expected error message about failed rename but got: %s", errorMessage) + } + common.SnapshotTest(t, "typescript", "rename_symbol", "not_found", errorMessage) + } + }) +} diff --git a/internal/tools/rename-symbol.go b/internal/tools/rename-symbol.go new file mode 100644 index 0000000..c96a9ba --- /dev/null +++ b/internal/tools/rename-symbol.go @@ -0,0 +1,78 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/isaacphi/mcp-language-server/internal/lsp" + "github.com/isaacphi/mcp-language-server/internal/protocol" + "github.com/isaacphi/mcp-language-server/internal/utilities" +) + +// RenameSymbol renames a symbol (variable, function, class, etc.) at the specified position +// It uses the LSP rename functionality to handle all references across files +func RenameSymbol(ctx context.Context, client *lsp.Client, filePath string, line, column int, newName string) (string, error) { + // Open the file if not already open + err := client.OpenFile(ctx, filePath) + if err != nil { + return "", fmt.Errorf("could not open file: %v", err) + } + + // Convert 1-indexed line/column to 0-indexed for LSP protocol + uri := protocol.DocumentUri("file://" + filePath) + position := protocol.Position{ + Line: uint32(line - 1), + Character: uint32(column - 1), + } + + // Create the rename parameters + params := protocol.RenameParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: uri, + }, + Position: position, + NewName: newName, + } + + // Skip the PrepareRename check as it might not be supported by all language servers + // Execute the rename directly + + // Execute the rename operation + workspaceEdit, err := client.Rename(ctx, params) + if err != nil { + return "", fmt.Errorf("failed to rename symbol: %v", err) + } + + // Count the changes that will be made + changeCount := 0 + fileCount := 0 + + // Count changes in Changes field + if workspaceEdit.Changes != nil { + fileCount = len(workspaceEdit.Changes) + for _, edits := range workspaceEdit.Changes { + changeCount += len(edits) + } + } + + // Count changes in DocumentChanges field + for _, change := range workspaceEdit.DocumentChanges { + if change.TextDocumentEdit != nil { + fileCount++ + changeCount += len(change.TextDocumentEdit.Edits) + } + } + + // Apply the workspace edit to files:workspaceEdit + if err := utilities.ApplyWorkspaceEdit(workspaceEdit); err != nil { + return "", fmt.Errorf("failed to apply changes: %v", err) + } + + if fileCount == 0 || changeCount == 0 { + return "Failed to rename symbol. 0 occurrences found.", nil + } + + // Generate a summary of changes made + return fmt.Sprintf("Successfully renamed symbol to '%s'.\nUpdated %d occurrences across %d files.", + newName, changeCount, fileCount), nil +} diff --git a/tools.go b/tools.go index a345de0..fa27838 100644 --- a/tools.go +++ b/tools.go @@ -44,6 +44,13 @@ type GetHoverArgs struct { Column int `json:"column" jsonschema:"required,description=The column number where the hover is requested (1-indexed)"` } +type RenameSymbolArgs struct { + FilePath string `json:"filePath" jsonschema:"required,description=The path to the file containing the symbol to rename"` + Line int `json:"line" jsonschema:"required,description=The line number where the symbol is located (1-indexed)"` + Column int `json:"column" jsonschema:"required,description=The column number where the symbol is located (1-indexed)"` + NewName string `json:"newName" jsonschema:"required,description=The new name for the symbol"` +} + func (s *mcpServer) registerTools() error { coreLogger.Debug("Registering MCP tools") @@ -336,6 +343,67 @@ func (s *mcpServer) registerTools() error { return mcp.NewToolResultText(text), nil }) + renameSymbolTool := mcp.NewTool("rename_symbol", + mcp.WithDescription("Rename a symbol (variable, function, class, etc.) at the specified position and update all references throughout the codebase."), + mcp.WithString("filePath", + mcp.Required(), + mcp.Description("The path to the file containing the symbol to rename"), + ), + mcp.WithNumber("line", + mcp.Required(), + mcp.Description("The line number where the symbol is located (1-indexed)"), + ), + mcp.WithNumber("column", + mcp.Required(), + mcp.Description("The column number where the symbol is located (1-indexed)"), + ), + mcp.WithString("newName", + mcp.Required(), + mcp.Description("The new name for the symbol"), + ), + ) + + s.mcpServer.AddTool(renameSymbolTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // Extract arguments + filePath, ok := request.Params.Arguments["filePath"].(string) + if !ok { + return mcp.NewToolResultError("filePath must be a string"), nil + } + + newName, ok := request.Params.Arguments["newName"].(string) + if !ok { + return mcp.NewToolResultError("newName must be a string"), nil + } + + // Handle both float64 and int for line and column due to JSON parsing + var line, column int + switch v := request.Params.Arguments["line"].(type) { + case float64: + line = int(v) + case int: + line = v + default: + return mcp.NewToolResultError("line must be a number"), nil + } + + switch v := request.Params.Arguments["column"].(type) { + case float64: + column = int(v) + case int: + column = v + default: + return mcp.NewToolResultError("column must be a number"), nil + } + + coreLogger.Debug("Executing rename_symbol for file: %s line: %d column: %d newName: %s", filePath, line, column, newName) + text, err := tools.RenameSymbol(s.ctx, s.lspClient, filePath, line, column, newName) + if err != nil { + coreLogger.Error("Failed to rename symbol: %v", err) + return mcp.NewToolResultError(fmt.Sprintf("failed to rename symbol: %v", err)), nil + } + return mcp.NewToolResultText(text), nil + }) + coreLogger.Info("Successfully registered all MCP tools") return nil }