├── bunfig.toml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── question.md │ ├── bug_report.md │ ├── feature_request.md │ └── language_support.md └── workflows │ └── ci.yml ├── .gitattributes ├── cclsp.json ├── .npmignore ├── .claude ├── settings.json └── cclsp.json ├── src ├── utils.ts ├── lsp │ └── adapters │ │ ├── registry.ts │ │ ├── pyright.ts │ │ ├── registry.test.ts │ │ ├── vue.ts │ │ ├── types.ts │ │ ├── vue.test.ts │ │ └── pyright.test.ts ├── types.ts ├── file-scanner.ts ├── server-selection.test.ts ├── file-editor-rollback.test.ts ├── file-scanner.test.ts ├── language-servers.ts ├── get-diagnostics.test.ts ├── file-editor-symlink.test.ts ├── setup-execution.test.ts ├── file-editor.ts ├── mcp-tools.test.ts ├── file-editor.test.ts └── multi-position.test.ts ├── tsconfig.json ├── .gitignore ├── biome.json ├── LICENSE ├── test ├── fixtures │ ├── typescript-example.ts │ ├── python-example.py │ └── go-example.go └── manual-rename-test.ts ├── package.json ├── SECURITY.md ├── .cursor └── rules │ └── use-bun-instead-of-node-vite-npm-pnpm.mdc ├── ROADMAP.md ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── debug-tsgo.cjs ├── CLAUDE.md └── CHANGELOG.md /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | # Disable parallel test execution to prevent test interference 3 | # This ensures beforeEach hooks run properly before each test 4 | concurrency = 1 -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Documentation 4 | url: https://github.com/ktnyt/cclsp#readme 5 | about: Please check the README first for usage instructions 6 | - name: Discussions 7 | url: https://github.com/ktnyt/cclsp/discussions 8 | about: For general discussions and help from the community -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto eol=lf 3 | 4 | # Force LF for specific file types 5 | *.ts text eol=lf 6 | *.js text eol=lf 7 | *.json text eol=lf 8 | *.md text eol=lf 9 | *.yml text eol=lf 10 | *.yaml text eol=lf 11 | 12 | # Binary files 13 | *.png binary 14 | *.jpg binary 15 | *.jpeg binary 16 | *.gif binary 17 | *.ico binary 18 | *.pdf binary -------------------------------------------------------------------------------- /cclsp.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [ 3 | { 4 | "extensions": [ 5 | "js", 6 | "ts", 7 | "jsx", 8 | "tsx" 9 | ], 10 | "command": [ 11 | "npx", 12 | "--", 13 | "typescript-language-server", 14 | "--stdio" 15 | ], 16 | "rootDir": "." 17 | }, 18 | { 19 | "extensions": [ 20 | "go" 21 | ], 22 | "command": [ 23 | "gopls" 24 | ], 25 | "rootDir": "." 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | index.ts 4 | *.ts 5 | !dist/ 6 | 7 | # Development files 8 | .cursor/ 9 | node_modules/ 10 | bun.lock 11 | tsconfig.json 12 | biome.json 13 | 14 | # Test files 15 | test_* 16 | *.test.ts 17 | *.test.js 18 | 19 | # Development configs 20 | .env 21 | .env.* 22 | 23 | # Git files 24 | .git/ 25 | .gitignore 26 | 27 | # Documentation (keep README.md and CLAUDE.md) 28 | # Other markdown files except main docs 29 | 30 | # Build artifacts that shouldn't be published 31 | *.tsbuildinfo -------------------------------------------------------------------------------- /.claude/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "PostToolUse": [ 4 | { 5 | "matcher": "Write|Edit|MultiEdit", 6 | "hooks": [ 7 | { 8 | "type": "command", 9 | "command": "jq -r '.tool_input.file_path' | xargs npx biome check --fix --unsafe" 10 | }, 11 | { 12 | "type": "command", 13 | "command": "jq -r '.tool_input.file_path' | xargs npx biome format --write" 14 | } 15 | ] 16 | } 17 | ] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, pathToFileURL } from 'node:url'; 2 | 3 | /** 4 | * Convert a file path to a proper file:// URI 5 | * Handles Windows paths correctly (e.g., C:\path -> file:///C:/path) 6 | */ 7 | export function pathToUri(filePath: string): string { 8 | return pathToFileURL(filePath).toString(); 9 | } 10 | 11 | /** 12 | * Convert a file:// URI to a file path 13 | * Handles Windows URIs correctly (e.g., file:///C:/path -> C:\path) 14 | */ 15 | export function uriToPath(uri: string): string { 16 | return fileURLToPath(uri); 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about cclsp 4 | title: '[QUESTION] ' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Question 11 | 12 | 13 | ## Context 14 | 15 | 16 | ## What I've tried 17 | 18 | 19 | ## Environment (if relevant) 20 | - **OS**: 21 | - **Node.js version**: 22 | - **cclsp version**: 23 | - **MCP client**: 24 | 25 | ## Related documentation 26 | 27 | 28 | ## Code example (if applicable) 29 | ```json 30 | // Your configuration or code 31 | ``` -------------------------------------------------------------------------------- /.claude/cclsp.json: -------------------------------------------------------------------------------- 1 | { 2 | "servers": [ 3 | { 4 | "extensions": [ 5 | "js", 6 | "ts", 7 | "jsx", 8 | "tsx" 9 | ], 10 | "command": [ 11 | "npx", 12 | "--", 13 | "typescript-language-server", 14 | "--stdio" 15 | ], 16 | "rootDir": "." 17 | }, 18 | { 19 | "extensions": [ 20 | "py", 21 | "pyi" 22 | ], 23 | "command": [ 24 | "uvx", 25 | "--from", 26 | "python-lsp-server", 27 | "pylsp" 28 | ], 29 | "rootDir": ".", 30 | "restartInterval": 5 31 | }, 32 | { 33 | "extensions": [ 34 | "go" 35 | ], 36 | "command": [ 37 | "gopls" 38 | ], 39 | "rootDir": "." 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "Preserve", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | .npm 4 | .yarn/ 5 | yarn-error.log* 6 | bun.lockb 7 | 8 | # Build output 9 | out/ 10 | dist/ 11 | build/ 12 | *.tgz 13 | *.tsbuildinfo 14 | 15 | # Coverage and testing 16 | coverage/ 17 | *.lcov 18 | .nyc_output 19 | 20 | # Logs 21 | logs/ 22 | *.log 23 | npm-debug.log* 24 | yarn-debug.log* 25 | bun-debug.log* 26 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 27 | 28 | # Environment variables 29 | .env 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # Caches 36 | .cache/ 37 | .parcel-cache/ 38 | .eslintcache 39 | .bun 40 | 41 | # IDE and editor files 42 | .idea/ 43 | .vscode/ 44 | *.swp 45 | *.swo 46 | *~ 47 | 48 | # OS generated files 49 | .DS_Store 50 | .DS_Store? 51 | ._* 52 | .Spotlight-V100 53 | .Trashes 54 | ehthumbs.db 55 | Thumbs.db 56 | 57 | # Temporary files 58 | tmp/ 59 | temp/ 60 | test-tmp/ 61 | 62 | # MCP specific 63 | mcp/*/storage.json 64 | 65 | # Local claude settings 66 | .claude/settings.local.json 67 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true, 10 | "style": { 11 | "noUselessElse": "warn", 12 | "useConst": "error" 13 | }, 14 | "suspicious": { 15 | "noExplicitAny": "warn" 16 | } 17 | } 18 | }, 19 | "overrides": [ 20 | { 21 | "include": ["**/*.test.ts"], 22 | "linter": { 23 | "rules": { 24 | "suspicious": { 25 | "noExplicitAny": "off" 26 | } 27 | } 28 | } 29 | } 30 | ], 31 | "formatter": { 32 | "enabled": true, 33 | "indentStyle": "space", 34 | "indentWidth": 2, 35 | "lineWidth": 100, 36 | "lineEnding": "lf" 37 | }, 38 | "javascript": { 39 | "formatter": { 40 | "semicolons": "always", 41 | "quoteStyle": "single", 42 | "trailingCommas": "es5" 43 | } 44 | }, 45 | "files": { 46 | "ignore": ["node_modules/**", "dist/**", "*.json"] 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 ktnyt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/fixtures/typescript-example.ts: -------------------------------------------------------------------------------- 1 | // TypeScript test fixture for rename operations 2 | export class UserService { 3 | private users: Map = new Map(); 4 | 5 | constructor(private database: Database) {} 6 | 7 | async getUser(id: string): Promise { 8 | // Check cache first 9 | if (this.users.has(id)) { 10 | return this.users.get(id); 11 | } 12 | 13 | // Fetch from database 14 | const user = await this.database.findUser(id); 15 | if (user) { 16 | this.users.set(id, user); 17 | } 18 | return user; 19 | } 20 | 21 | async createUser(data: CreateUserData): Promise { 22 | const user = await this.database.createUser(data); 23 | this.users.set(user.id, user); 24 | return user; 25 | } 26 | 27 | clearCache(): void { 28 | this.users.clear(); 29 | } 30 | } 31 | 32 | interface User { 33 | id: string; 34 | name: string; 35 | email: string; 36 | } 37 | 38 | interface CreateUserData { 39 | name: string; 40 | email: string; 41 | } 42 | 43 | interface Database { 44 | findUser(id: string): Promise; 45 | createUser(data: CreateUserData): Promise; 46 | } 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ## To Reproduce 14 | Steps to reproduce the behavior: 15 | 1. Set up configuration with '...' 16 | 2. Run command '...' 17 | 3. Try to use tool '...' 18 | 4. See error 19 | 20 | ## Expected behavior 21 | A clear and concise description of what you expected to happen. 22 | 23 | ## Actual behavior 24 | What actually happened instead. 25 | 26 | ## Error messages 27 | ``` 28 | Paste any error messages or logs here 29 | ``` 30 | 31 | ## Environment 32 | - **OS**: [e.g. macOS 14.0, Ubuntu 22.04, Windows 11] 33 | - **Node.js version**: [e.g. 20.10.0] 34 | - **cclsp version**: [e.g. 1.0.0] 35 | - **Language server**: [e.g. typescript-language-server 4.1.0] 36 | - **MCP client**: [e.g. Claude Code 0.2.0] 37 | 38 | ## Configuration 39 | ```json 40 | // Your cclsp.json configuration 41 | ``` 42 | 43 | ## Additional context 44 | Add any other context about the problem here. 45 | 46 | ## Screenshots 47 | If applicable, add screenshots to help explain your problem. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe. 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Describe alternatives you've considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ## Use case 20 | Describe specific scenarios where this feature would be useful: 21 | - When working with [...] 22 | - In projects that need [...] 23 | - For developers who [...] 24 | 25 | ## Implementation ideas (optional) 26 | If you have ideas about how this could be implemented: 27 | ```typescript 28 | // Example code or pseudocode 29 | ``` 30 | 31 | ## Additional context 32 | Add any other context, mockups, or examples about the feature request here. 33 | 34 | ## Would you be willing to help implement this feature? 35 | - [ ] Yes, I'd like to submit a PR 36 | - [ ] Yes, but I'd need guidance 37 | - [ ] No, but I can help test it -------------------------------------------------------------------------------- /test/fixtures/python-example.py: -------------------------------------------------------------------------------- 1 | # Python test fixture for rename operations 2 | class Calculator: 3 | """A simple calculator class for testing rename operations.""" 4 | 5 | def __init__(self): 6 | self.history = [] 7 | self.last_result = 0 8 | 9 | def add(self, a, b): 10 | """Add two numbers and store the result.""" 11 | result = a + b 12 | self.last_result = result 13 | self.history.append(f"add({a}, {b}) = {result}") 14 | return result 15 | 16 | def multiply(self, a, b): 17 | """Multiply two numbers and store the result.""" 18 | result = a * b 19 | self.last_result = result 20 | self.history.append(f"multiply({a}, {b}) = {result}") 21 | return result 22 | 23 | def get_history(self): 24 | """Return the calculation history.""" 25 | return self.history 26 | 27 | def clear_history(self): 28 | """Clear the calculation history.""" 29 | self.history = [] 30 | self.last_result = 0 31 | 32 | 33 | # Usage example 34 | if __name__ == "__main__": 35 | calc = Calculator() 36 | print(calc.add(5, 3)) 37 | print(calc.multiply(4, 7)) 38 | print(calc.get_history()) 39 | calc.clear_history() -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/language_support.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Language support request 3 | about: Request support for a new programming language 4 | title: '[LANG] Add support for ' 5 | labels: 'enhancement, language-support' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Language Information 11 | - **Language name**: 12 | - **File extensions**: [e.g. .rs, .rust] 13 | - **LSP server name**: 14 | - **LSP server repository/website**: 15 | 16 | ## Installation 17 | How to install the language server: 18 | ```bash 19 | # Example: npm install -g rust-analyzer 20 | ``` 21 | 22 | ## Configuration 23 | Suggested configuration for `cclsp.json`: 24 | ```json 25 | { 26 | "extensions": ["rs"], 27 | "command": ["rust-analyzer"], 28 | "rootDir": "." 29 | } 30 | ``` 31 | 32 | ## Testing 33 | Have you tested this configuration locally? 34 | - [ ] Yes, it works 35 | - [ ] Yes, but with issues (describe below) 36 | - [ ] No, I haven't tested it 37 | 38 | ## Additional requirements 39 | Does this language server need any special: 40 | - Environment variables? 41 | - Initialization options? 42 | - Project setup (e.g., config files)? 43 | 44 | ## Example use cases 45 | What LSP features are most important for this language? 46 | - [ ] Go to definition 47 | - [ ] Find references 48 | - [ ] Rename symbol 49 | - [ ] Other: 50 | 51 | ## Notes 52 | Any other information about this language server that would be helpful. -------------------------------------------------------------------------------- /test/fixtures/go-example.go: -------------------------------------------------------------------------------- 1 | // Go test fixture for rename operations 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "sync" 7 | ) 8 | 9 | // DataStore represents a thread-safe data storage 10 | type DataStore struct { 11 | mu sync.RWMutex 12 | items map[string]interface{} 13 | } 14 | 15 | // NewDataStore creates a new DataStore instance 16 | func NewDataStore() *DataStore { 17 | return &DataStore{ 18 | items: make(map[string]interface{}), 19 | } 20 | } 21 | 22 | // Set stores a value with the given key 23 | func (ds *DataStore) Set(key string, value interface{}) { 24 | ds.mu.Lock() 25 | defer ds.mu.Unlock() 26 | ds.items[key] = value 27 | } 28 | 29 | // Get retrieves a value by key 30 | func (ds *DataStore) Get(key string) (interface{}, bool) { 31 | ds.mu.RLock() 32 | defer ds.mu.RUnlock() 33 | val, ok := ds.items[key] 34 | return val, ok 35 | } 36 | 37 | // Delete removes a key from the store 38 | func (ds *DataStore) Delete(key string) { 39 | ds.mu.Lock() 40 | defer ds.mu.Unlock() 41 | delete(ds.items, key) 42 | } 43 | 44 | // Size returns the number of items in the store 45 | func (ds *DataStore) Size() int { 46 | ds.mu.RLock() 47 | defer ds.mu.RUnlock() 48 | return len(ds.items) 49 | } 50 | 51 | func main() { 52 | store := NewDataStore() 53 | store.Set("name", "Alice") 54 | store.Set("age", 30) 55 | 56 | if val, ok := store.Get("name"); ok { 57 | fmt.Printf("Name: %v\n", val) 58 | } 59 | 60 | fmt.Printf("Store size: %d\n", store.Size()) 61 | } -------------------------------------------------------------------------------- /src/lsp/adapters/registry.ts: -------------------------------------------------------------------------------- 1 | import type { LSPServerConfig } from '../../types.js'; 2 | import { PyrightAdapter } from './pyright.js'; 3 | import type { ServerAdapter } from './types.js'; 4 | import { VueLanguageServerAdapter } from './vue.js'; 5 | 6 | /** 7 | * Registry of built-in server adapters. 8 | * This is NOT extensible by users - internal use only. 9 | * 10 | * The registry automatically detects which adapter to use based on 11 | * the server command in the configuration. 12 | */ 13 | class AdapterRegistry { 14 | private readonly adapters: ServerAdapter[]; 15 | 16 | constructor() { 17 | // Register all built-in adapters 18 | // Order matters - first match wins 19 | this.adapters = [ 20 | new VueLanguageServerAdapter(), 21 | new PyrightAdapter(), 22 | // Add more built-in adapters here as needed 23 | ]; 24 | } 25 | 26 | /** 27 | * Find adapter for given server config. 28 | * Returns undefined if no adapter matches (standard LSP behavior). 29 | */ 30 | getAdapter(config: LSPServerConfig): ServerAdapter | undefined { 31 | return this.adapters.find((adapter) => adapter.matches(config)); 32 | } 33 | 34 | /** 35 | * Get list of all registered adapter names. 36 | * Useful for logging and debugging. 37 | */ 38 | getAdapterNames(): string[] { 39 | return this.adapters.map((adapter) => adapter.name); 40 | } 41 | } 42 | 43 | // Singleton instance - internal use only 44 | export const adapterRegistry = new AdapterRegistry(); 45 | -------------------------------------------------------------------------------- /src/lsp/adapters/pyright.ts: -------------------------------------------------------------------------------- 1 | import type { LSPServerConfig } from '../../types.js'; 2 | import type { InitializeParams, ServerAdapter } from './types.js'; 3 | 4 | /** 5 | * Adapter for Pyright Language Server. 6 | * 7 | * Pyright and basedpyright can be slow on large Python projects. 8 | * This adapter extends timeouts for operations that may take longer. 9 | */ 10 | export class PyrightAdapter implements ServerAdapter { 11 | readonly name = 'pyright'; 12 | 13 | matches(config: LSPServerConfig): boolean { 14 | return config.command.some((c: string) => c.includes('pyright') || c.includes('basedpyright')); 15 | } 16 | 17 | customizeInitializeParams(params: InitializeParams): InitializeParams { 18 | // Pyright works better with specific workspace configuration 19 | // Preserve any existing initializationOptions from config 20 | const existingOptions = 21 | typeof params.initializationOptions === 'object' && params.initializationOptions !== null 22 | ? params.initializationOptions 23 | : {}; 24 | 25 | return { 26 | ...params, 27 | initializationOptions: { 28 | ...existingOptions, 29 | // Pyright-specific options can be added here if needed 30 | }, 31 | }; 32 | } 33 | 34 | getTimeout(method: string): number | undefined { 35 | // Pyright can be slow on large projects 36 | // Extend timeouts for operations that may analyze many files 37 | const timeouts: Record = { 38 | 'textDocument/definition': 45000, // 45 seconds 39 | 'textDocument/references': 60000, // 60 seconds 40 | 'textDocument/rename': 60000, // 60 seconds 41 | 'textDocument/documentSymbol': 45000, // 45 seconds 42 | }; 43 | return timeouts[method]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cclsp", 3 | "version": "0.6.2", 4 | "description": "MCP server for accessing LSP functionality", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "cclsp": "dist/index.js" 8 | }, 9 | "type": "module", 10 | "scripts": { 11 | "build": "bun build --target=node --outdir=dist index.ts", 12 | "dev": "bun run --watch index.ts", 13 | "start": "node dist/index.js", 14 | "test": "bun test", 15 | "test:execution": "RUN_EXECUTION_TESTS=1 bun test src/setup-execution.test.ts", 16 | "test:all": "bun test && bun run test:execution", 17 | "test:manual": "node test_mcp_client.cjs", 18 | "lint": "biome check .", 19 | "lint:fix": "biome check --fix .", 20 | "lint:fix-unsafe": "biome check --fix --unsafe .", 21 | "format": "biome format --write .", 22 | "typecheck": "tsc --noEmit", 23 | "prepublishOnly": "npm run build && npm run test:all && npm run typecheck" 24 | }, 25 | "keywords": [ 26 | "mcp", 27 | "lsp", 28 | "language-server", 29 | "model-context-protocol", 30 | "claude-code" 31 | ], 32 | "author": "nano", 33 | "license": "MIT", 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/ktnyt/cclsp.git" 37 | }, 38 | "homepage": "https://github.com/ktnyt/cclsp#readme", 39 | "bugs": { 40 | "url": "https://github.com/ktnyt/cclsp/issues" 41 | }, 42 | "files": [ 43 | "dist", 44 | "README.md", 45 | "CHANGELOG.md", 46 | "LICENSE", 47 | "CLAUDE.md", 48 | "cclsp.config.json" 49 | ], 50 | "dependencies": { 51 | "@modelcontextprotocol/sdk": "^1.12.3", 52 | "@types/inquirer": "^9.0.8", 53 | "ignore": "^7.0.5", 54 | "inquirer": "^12.6.3", 55 | "typescript-language-server": "^4.3.4" 56 | }, 57 | "devDependencies": { 58 | "@biomejs/biome": "^1.9.4", 59 | "@types/bun": "latest", 60 | "@types/node": "^24.0.1", 61 | "memfs": "^4.36.3" 62 | }, 63 | "peerDependencies": { 64 | "typescript": "^5.8.3" 65 | }, 66 | "engines": { 67 | "node": ">=18" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/lsp/adapters/registry.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test'; 2 | import { adapterRegistry } from './registry.js'; 3 | 4 | describe('AdapterRegistry', () => { 5 | describe('getAdapter', () => { 6 | it('should return VueLanguageServerAdapter for vue-language-server', () => { 7 | const adapter = adapterRegistry.getAdapter({ 8 | extensions: ['vue'], 9 | command: ['vue-language-server', '--stdio'], 10 | }); 11 | 12 | expect(adapter).toBeDefined(); 13 | expect(adapter?.name).toBe('vue-language-server'); 14 | }); 15 | 16 | it('should return PyrightAdapter for pyright', () => { 17 | const adapter = adapterRegistry.getAdapter({ 18 | extensions: ['py'], 19 | command: ['pyright-langserver', '--stdio'], 20 | }); 21 | 22 | expect(adapter).toBeDefined(); 23 | expect(adapter?.name).toBe('pyright'); 24 | }); 25 | 26 | it('should return undefined for unknown server', () => { 27 | const adapter = adapterRegistry.getAdapter({ 28 | extensions: ['ts'], 29 | command: ['typescript-language-server', '--stdio'], 30 | }); 31 | 32 | expect(adapter).toBeUndefined(); 33 | }); 34 | 35 | it('should return undefined for pylsp', () => { 36 | const adapter = adapterRegistry.getAdapter({ 37 | extensions: ['py'], 38 | command: ['pylsp'], 39 | }); 40 | 41 | expect(adapter).toBeUndefined(); 42 | }); 43 | 44 | it('should match first adapter when multiple adapters could match', () => { 45 | // If we had two adapters that could match the same server, 46 | // the first one registered should win 47 | const adapter = adapterRegistry.getAdapter({ 48 | extensions: ['vue'], 49 | command: ['vue-language-server', '--stdio'], 50 | }); 51 | 52 | expect(adapter?.name).toBe('vue-language-server'); 53 | }); 54 | }); 55 | 56 | describe('getAdapterNames', () => { 57 | it('should return list of all registered adapter names', () => { 58 | const names = adapterRegistry.getAdapterNames(); 59 | 60 | expect(names).toContain('vue-language-server'); 61 | expect(names).toContain('pyright'); 62 | expect(names.length).toBeGreaterThanOrEqual(2); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | timeout-minutes: 10 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest, macos-latest, windows-latest] 16 | node-version: [18.x, 20.x, 22.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - name: Setup Bun 22 | uses: oven-sh/setup-bun@v2 23 | 24 | - name: Install dependencies 25 | run: bun install 26 | 27 | - name: Run linter 28 | run: bun run lint 29 | 30 | - name: Run type check 31 | run: bun run typecheck 32 | 33 | - name: Run unit tests 34 | run: bun test 35 | 36 | - name: Run execution tests 37 | run: bun run test:execution 38 | env: 39 | CI: true 40 | 41 | - name: Build 42 | run: bun run build 43 | 44 | release: 45 | needs: test 46 | runs-on: ubuntu-latest 47 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 48 | 49 | steps: 50 | - uses: actions/checkout@v4 51 | 52 | - name: Setup Node.js 53 | uses: actions/setup-node@v4 54 | with: 55 | node-version: '20.x' 56 | registry-url: 'https://registry.npmjs.org' 57 | 58 | - name: Setup Bun 59 | uses: oven-sh/setup-bun@v2 60 | 61 | - name: Install dependencies 62 | run: bun install 63 | 64 | - name: Build 65 | run: bun run build 66 | 67 | - name: Check if version should be published 68 | id: version 69 | run: | 70 | CURRENT_VERSION=$(node -p "require('./package.json').version") 71 | PUBLISHED_VERSION=$(npm view cclsp version 2>/dev/null || echo "0.0.0") 72 | 73 | echo "Current version: $CURRENT_VERSION" 74 | echo "Published version: $PUBLISHED_VERSION" 75 | 76 | if [ "$CURRENT_VERSION" != "$PUBLISHED_VERSION" ]; then 77 | echo "changed=true" >> $GITHUB_OUTPUT 78 | echo "Version changed from $PUBLISHED_VERSION to $CURRENT_VERSION" 79 | else 80 | echo "changed=false" >> $GITHUB_OUTPUT 81 | echo "Version unchanged: $CURRENT_VERSION" 82 | fi 83 | 84 | - name: Publish to npm 85 | if: steps.version.outputs.changed == 'true' 86 | run: npm publish 87 | env: 88 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We release patches for security vulnerabilities. Currently supported versions: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 1.x.x | :white_check_mark: | 10 | | < 1.0 | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | We take the security of cclsp seriously. If you believe you have found a security vulnerability, please report it to us as described below. 15 | 16 | ### Please do NOT: 17 | - Open a public issue 18 | - Post about it on social media 19 | - Exploit the vulnerability 20 | 21 | ### Please DO: 22 | - Email us at [INSERT SECURITY EMAIL] with details 23 | - Include steps to reproduce if possible 24 | - Allow us reasonable time to respond and fix the issue 25 | 26 | ### What to expect: 27 | 1. **Acknowledgment**: We'll acknowledge receipt within 48 hours 28 | 2. **Assessment**: We'll assess the vulnerability and determine its impact 29 | 3. **Fix**: We'll work on a fix and coordinate a release 30 | 4. **Disclosure**: We'll publicly disclose the issue after the fix is released 31 | 32 | ## Security Considerations 33 | 34 | ### Language Server Protocol (LSP) Servers 35 | 36 | cclsp spawns external LSP server processes based on configuration. Users should: 37 | 38 | 1. **Trust your LSP servers**: Only use LSP servers from trusted sources 39 | 2. **Review configurations**: Carefully review any shared `cclsp.json` configurations 40 | 3. **Use official servers**: Prefer official language servers when available 41 | 42 | ### Configuration Security 43 | 44 | - Never include sensitive information in `cclsp.json` 45 | - Be cautious with configurations that execute arbitrary commands 46 | - Review command arguments carefully 47 | 48 | ### MCP Protocol Security 49 | 50 | cclsp follows MCP protocol security best practices: 51 | - No arbitrary code execution without explicit configuration 52 | - Clear boundaries between tool capabilities 53 | - Transparent operation logging 54 | 55 | ## Best Practices for Users 56 | 57 | 1. **Keep cclsp updated**: Always use the latest version 58 | 2. **Audit configurations**: Review `cclsp.json` before using 59 | 3. **Use trusted sources**: Only install language servers from official sources 60 | 4. **Report issues**: If something seems wrong, report it immediately 61 | 62 | ## Acknowledgments 63 | 64 | We appreciate security researchers who responsibly disclose vulnerabilities. Contributors will be acknowledged here unless they prefer to remain anonymous. -------------------------------------------------------------------------------- /src/lsp/adapters/vue.ts: -------------------------------------------------------------------------------- 1 | import type { LSPServerConfig } from '../../types.js'; 2 | import type { ServerAdapter, ServerState } from './types.js'; 3 | 4 | /** 5 | * Adapter for Vue Language Server (@vue/language-server). 6 | * 7 | * Vue Language Server uses a non-standard tsserver/request protocol 8 | * for TypeScript integration. This adapter handles these custom requests 9 | * to prevent timeouts and errors. 10 | * 11 | * Issues addressed: 12 | * - Responds to tsserver/request notifications 13 | * - Extended timeouts for operations that require TypeScript analysis 14 | */ 15 | export class VueLanguageServerAdapter implements ServerAdapter { 16 | readonly name = 'vue-language-server'; 17 | 18 | matches(config: LSPServerConfig): boolean { 19 | return config.command.some( 20 | (c: string) => c.includes('vue-language-server') || c.includes('@vue/language-server') 21 | ); 22 | } 23 | 24 | handleRequest(method: string, params: unknown, state: ServerState): Promise { 25 | // Handle vue-language-server's custom tsserver/request protocol 26 | if (method === 'tsserver/request') { 27 | const requestParams = params as [number, string, unknown]; 28 | const [id, requestType] = requestParams; 29 | 30 | process.stderr.write( 31 | `[DEBUG VueAdapter] Handling tsserver/request: ${requestType} (id: ${id})\n` 32 | ); 33 | 34 | // Respond to project info requests 35 | if (requestType === '_vue:projectInfo') { 36 | // Return minimal response to unblock the server 37 | // The server can work without full TypeScript project info 38 | return Promise.resolve([ 39 | id, 40 | { 41 | configFiles: [], 42 | sourceFiles: [], 43 | }, 44 | ]); 45 | } 46 | 47 | // Default empty response for other tsserver requests 48 | // This prevents the server from hanging waiting for responses 49 | return Promise.resolve([id, {}]); 50 | } 51 | 52 | return Promise.reject(new Error(`Unhandled request: ${method}`)); 53 | } 54 | 55 | getTimeout(method: string): number | undefined { 56 | // Vue language server can be slow on certain operations 57 | // that require TypeScript analysis 58 | const timeouts: Record = { 59 | 'textDocument/documentSymbol': 60000, // 60 seconds 60 | 'textDocument/definition': 45000, // 45 seconds 61 | 'textDocument/references': 45000, // 45 seconds 62 | 'textDocument/rename': 45000, // 45 seconds 63 | }; 64 | return timeouts[method]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Use Bun instead of Node.js, npm, pnpm, or vite. 3 | globs: *.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json 4 | alwaysApply: false 5 | --- 6 | 7 | Default to using Bun instead of Node.js. 8 | 9 | - Use `bun ` instead of `node ` or `ts-node ` 10 | - Use `bun test` instead of `jest` or `vitest` 11 | - Use `bun build ` instead of `webpack` or `esbuild` 12 | - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 13 | - Use `bun run 69 | 70 | 71 | ``` 72 | 73 | With the following `frontend.tsx`: 74 | 75 | ```tsx#frontend.tsx 76 | import React from "react"; 77 | 78 | // import .css files directly and it works 79 | import './index.css'; 80 | 81 | import { createRoot } from "react-dom/client"; 82 | 83 | const root = createRoot(document.body); 84 | 85 | export default function Frontend() { 86 | return

Hello, world!

; 87 | } 88 | 89 | root.render(); 90 | ``` 91 | 92 | Then, run index.ts 93 | 94 | ```sh 95 | bun --hot ./index.ts 96 | ``` 97 | 98 | For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`. 99 | -------------------------------------------------------------------------------- /src/lsp/adapters/types.ts: -------------------------------------------------------------------------------- 1 | import type { LSPServerConfig } from '../../types.js'; 2 | 3 | // Forward declaration to avoid circular dependency 4 | // ServerState is defined in lsp-client.ts 5 | export interface ServerState { 6 | process: import('node:child_process').ChildProcess; 7 | initialized: boolean; 8 | initializationPromise: Promise; 9 | openFiles: Set; 10 | fileVersions: Map; 11 | startTime: number; 12 | config: LSPServerConfig; 13 | restartTimer?: NodeJS.Timeout; 14 | initializationResolve?: () => void; 15 | diagnostics: Map; 16 | lastDiagnosticUpdate: Map; 17 | diagnosticVersions: Map; 18 | adapter?: ServerAdapter; 19 | } 20 | 21 | /** 22 | * LSP server adapter for handling server-specific behavior. 23 | * This is an internal interface - no user extensions supported. 24 | * 25 | * Adapters allow cclsp to handle LSP servers that deviate from the standard 26 | * protocol or have special requirements. 27 | */ 28 | export interface ServerAdapter { 29 | /** Adapter name for logging */ 30 | readonly name: string; 31 | 32 | /** 33 | * Check if this adapter should be used for the given config. 34 | * Called during server initialization to auto-detect the appropriate adapter. 35 | */ 36 | matches(config: LSPServerConfig): boolean; 37 | 38 | /** 39 | * Customize initialization parameters before sending to server. 40 | * Use this to add server-specific initialization options. 41 | */ 42 | customizeInitializeParams?(params: InitializeParams): InitializeParams; 43 | 44 | /** 45 | * Handle custom notifications from server. 46 | * Return true if handled, false to fall through to standard handling. 47 | */ 48 | handleNotification?(method: string, params: unknown, state: ServerState): boolean; 49 | 50 | /** 51 | * Handle custom requests from server. 52 | * Should return a promise that resolves to the response. 53 | * Throw an error to indicate the request was not handled. 54 | */ 55 | handleRequest?(method: string, params: unknown, state: ServerState): Promise; 56 | 57 | /** 58 | * Get custom timeout for specific LSP methods. 59 | * Return undefined to use the default timeout (30000ms). 60 | */ 61 | getTimeout?(method: string): number | undefined; 62 | 63 | /** 64 | * Check if a method is actually supported. 65 | * Some servers declare capabilities they don't properly implement. 66 | * Return false to prevent the method from being called. 67 | */ 68 | isMethodSupported?(method: string): boolean; 69 | 70 | /** 71 | * Provide fallback implementation when method is not supported. 72 | * This is called when isMethodSupported returns false. 73 | */ 74 | provideFallback?(method: string, params: unknown, state: ServerState): Promise; 75 | } 76 | 77 | /** 78 | * LSP InitializeParams type 79 | * Subset of the full LSP specification 80 | */ 81 | export interface InitializeParams { 82 | processId: number | null; 83 | clientInfo: { name: string; version: string }; 84 | capabilities: unknown; 85 | rootUri: string; 86 | workspaceFolders: Array<{ uri: string; name: string }>; 87 | initializationOptions?: unknown; 88 | } 89 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # cclsp Roadmap 2 | 3 | This document outlines the future direction and planned features for cclsp. 4 | 5 | ## Vision 6 | 7 | Make cclsp the go-to MCP server for Language Server Protocol integration, enabling AI assistants to understand and navigate codebases as effectively as human developers. 8 | 9 | ## Current Status (v1.x) 10 | 11 | - ✅ Core LSP functionality (go to definition, find references, rename symbol) 12 | - ✅ Multi-language support via configurable LSP servers 13 | - ✅ TypeScript/JavaScript support out of the box 14 | - ✅ Basic error handling and logging 15 | 16 | ## Short-term Goals (Next 3 months) 17 | 18 | ### v1.1 - Enhanced Language Support 19 | - [ ] Auto-detection of installed language servers 20 | - [ ] Built-in configurations for top 20 programming languages 21 | - [ ] Language-specific initialization options 22 | - [ ] Better error messages for missing language servers 23 | 24 | ### v1.2 - Performance Improvements 25 | - [ ] Connection pooling for LSP servers 26 | - [ ] Lazy loading of language servers 27 | - [ ] Caching of symbol information 28 | - [ ] Parallel request handling 29 | 30 | ### v1.3 - Developer Experience 31 | - [ ] Interactive configuration generator 32 | - [ ] Debugging mode with detailed logs 33 | - [ ] Health check command 34 | - [ ] Integration test suite for each language 35 | 36 | ## Medium-term Goals (6-12 months) 37 | 38 | ### v2.0 - Advanced LSP Features 39 | - [ ] Code completion support 40 | - [ ] Hover information 41 | - [ ] Signature help 42 | - [ ] Document symbols 43 | - [ ] Workspace symbols 44 | 45 | ### v2.1 - Project Intelligence 46 | - [ ] Project-wide symbol search 47 | - [ ] Call hierarchy navigation 48 | - [ ] Type hierarchy support 49 | - [ ] Import/dependency analysis 50 | 51 | ### v2.2 - Integration Ecosystem 52 | - [ ] Plugin system for custom tools 53 | - [ ] Integration with popular IDEs 54 | - [ ] Docker support for isolated environments 55 | - [ ] Cloud-hosted LSP server option 56 | 57 | ## Long-term Vision (1+ years) 58 | 59 | ### Semantic Code Understanding 60 | - [ ] Cross-language reference tracking 61 | - [ ] Semantic diff analysis 62 | - [ ] Code pattern recognition 63 | - [ ] Refactoring suggestions 64 | 65 | ### AI-Enhanced Features 66 | - [ ] Natural language to symbol mapping 67 | - [ ] Context-aware code navigation 68 | - [ ] Intelligent code summarization 69 | - [ ] Automated documentation generation 70 | 71 | ### Enterprise Features 72 | - [ ] Multi-repository support 73 | - [ ] Access control and security policies 74 | - [ ] Audit logging 75 | - [ ] Performance analytics 76 | 77 | ## Community Driven Features 78 | 79 | We're open to community suggestions! Features requested by users: 80 | - [ ] Support for notebooks (Jupyter, Observable) 81 | - [ ] GraphQL schema navigation 82 | - [ ] Database schema integration 83 | - [ ] API documentation linking 84 | 85 | ## How to Contribute 86 | 87 | 1. **Vote on features**: Use 👍 reactions on issues to show interest 88 | 2. **Propose new features**: Open a feature request issue 89 | 3. **Implement features**: Check issues labeled "help wanted" 90 | 4. **Add language support**: See CONTRIBUTING.md 91 | 92 | ## Release Schedule 93 | 94 | - **Patch releases**: As needed for bug fixes 95 | - **Minor releases**: Monthly with new features 96 | - **Major releases**: Annually with breaking changes 97 | 98 | ## Success Metrics 99 | 100 | - Number of supported languages 101 | - Response time for LSP operations 102 | - Community contributions 103 | - User satisfaction (GitHub stars, npm downloads) 104 | 105 | --- 106 | 107 | This roadmap is a living document and will be updated based on community feedback and project evolution. -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface LSPServerConfig { 2 | extensions: string[]; 3 | command: string[]; 4 | rootDir?: string; 5 | restartInterval?: number; // in minutes, optional auto-restart interval 6 | initializationOptions?: unknown; // LSP initialization options 7 | } 8 | 9 | export interface Config { 10 | servers: LSPServerConfig[]; 11 | } 12 | 13 | export interface Position { 14 | line: number; 15 | character: number; 16 | } 17 | 18 | export interface Location { 19 | uri: string; 20 | range: { 21 | start: Position; 22 | end: Position; 23 | }; 24 | } 25 | 26 | export interface DefinitionResult { 27 | locations: Location[]; 28 | } 29 | 30 | export interface ReferenceResult { 31 | locations: Location[]; 32 | } 33 | 34 | export interface SymbolSearchParams { 35 | file_path: string; 36 | symbol_name: string; 37 | symbol_kind: string; 38 | } 39 | 40 | export interface LSPError { 41 | code: number; 42 | message: string; 43 | data?: unknown; 44 | } 45 | 46 | export interface LSPLocation { 47 | uri: string; 48 | range: { 49 | start: Position; 50 | end: Position; 51 | }; 52 | } 53 | 54 | export interface DocumentSymbol { 55 | name: string; 56 | detail?: string; 57 | kind: SymbolKind; 58 | tags?: SymbolTag[]; 59 | deprecated?: boolean; 60 | range: { 61 | start: Position; 62 | end: Position; 63 | }; 64 | selectionRange: { 65 | start: Position; 66 | end: Position; 67 | }; 68 | children?: DocumentSymbol[]; 69 | } 70 | 71 | export enum SymbolKind { 72 | File = 1, 73 | Module = 2, 74 | Namespace = 3, 75 | Package = 4, 76 | Class = 5, 77 | Method = 6, 78 | Property = 7, 79 | Field = 8, 80 | Constructor = 9, 81 | Enum = 10, 82 | Interface = 11, 83 | Function = 12, 84 | Variable = 13, 85 | Constant = 14, 86 | String = 15, 87 | Number = 16, 88 | Boolean = 17, 89 | Array = 18, 90 | Object = 19, 91 | Key = 20, 92 | Null = 21, 93 | EnumMember = 22, 94 | Struct = 23, 95 | Event = 24, 96 | Operator = 25, 97 | TypeParameter = 26, 98 | } 99 | 100 | export enum SymbolTag { 101 | Deprecated = 1, 102 | } 103 | 104 | export interface SymbolInformation { 105 | name: string; 106 | kind: SymbolKind; 107 | tags?: SymbolTag[]; 108 | deprecated?: boolean; 109 | location: { 110 | uri: string; 111 | range: { 112 | start: Position; 113 | end: Position; 114 | }; 115 | }; 116 | containerName?: string; 117 | } 118 | 119 | export interface SymbolMatch { 120 | name: string; 121 | kind: SymbolKind; 122 | position: Position; 123 | range: { 124 | start: Position; 125 | end: Position; 126 | }; 127 | detail?: string; 128 | } 129 | 130 | export enum DiagnosticSeverity { 131 | Error = 1, 132 | Warning = 2, 133 | Information = 3, 134 | Hint = 4, 135 | } 136 | 137 | export interface DiagnosticRelatedInformation { 138 | location: Location; 139 | message: string; 140 | } 141 | 142 | export interface CodeDescription { 143 | href: string; 144 | } 145 | 146 | export interface Diagnostic { 147 | range: { 148 | start: Position; 149 | end: Position; 150 | }; 151 | severity?: DiagnosticSeverity; 152 | code?: number | string; 153 | codeDescription?: CodeDescription; 154 | source?: string; 155 | message: string; 156 | tags?: DiagnosticTag[]; 157 | relatedInformation?: DiagnosticRelatedInformation[]; 158 | data?: unknown; 159 | } 160 | 161 | export enum DiagnosticTag { 162 | Unnecessary = 1, 163 | Deprecated = 2, 164 | } 165 | 166 | export interface DocumentDiagnosticReport { 167 | kind: 'full' | 'unchanged'; 168 | resultId?: string; 169 | items?: Diagnostic[]; 170 | } 171 | -------------------------------------------------------------------------------- /src/lsp/adapters/vue.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test'; 2 | import { VueLanguageServerAdapter } from './vue.js'; 3 | 4 | describe('VueLanguageServerAdapter', () => { 5 | const adapter = new VueLanguageServerAdapter(); 6 | 7 | describe('matches', () => { 8 | it('should match vue-language-server command', () => { 9 | expect( 10 | adapter.matches({ 11 | extensions: ['vue'], 12 | command: ['vue-language-server', '--stdio'], 13 | }) 14 | ).toBe(true); 15 | }); 16 | 17 | it('should match @vue/language-server command', () => { 18 | expect( 19 | adapter.matches({ 20 | extensions: ['vue'], 21 | command: ['@vue/language-server', '--stdio'], 22 | }) 23 | ).toBe(true); 24 | }); 25 | 26 | it('should match command with vue-language-server in path', () => { 27 | expect( 28 | adapter.matches({ 29 | extensions: ['vue'], 30 | command: ['/usr/local/bin/vue-language-server', '--stdio'], 31 | }) 32 | ).toBe(true); 33 | }); 34 | 35 | it('should not match other servers', () => { 36 | expect( 37 | adapter.matches({ 38 | extensions: ['ts'], 39 | command: ['typescript-language-server', '--stdio'], 40 | }) 41 | ).toBe(false); 42 | }); 43 | 44 | it('should not match volar', () => { 45 | expect( 46 | adapter.matches({ 47 | extensions: ['vue'], 48 | command: ['volar', '--stdio'], 49 | }) 50 | ).toBe(false); 51 | }); 52 | }); 53 | 54 | describe('handleRequest', () => { 55 | it('should handle tsserver/request for projectInfo', async () => { 56 | const mockState = { 57 | config: { extensions: ['vue'], command: ['vue-language-server'] }, 58 | } as any; 59 | 60 | const result = await adapter.handleRequest( 61 | 'tsserver/request', 62 | [1, '_vue:projectInfo', { file: '/test.vue' }], 63 | mockState 64 | ); 65 | 66 | expect(result).toEqual([ 67 | 1, 68 | { 69 | configFiles: [], 70 | sourceFiles: [], 71 | }, 72 | ]); 73 | }); 74 | 75 | it('should handle tsserver/request with default response', async () => { 76 | const mockState = { 77 | config: { extensions: ['vue'], command: ['vue-language-server'] }, 78 | } as any; 79 | 80 | const result = await adapter.handleRequest( 81 | 'tsserver/request', 82 | [2, 'someOtherRequest', {}], 83 | mockState 84 | ); 85 | 86 | expect(result).toEqual([2, {}]); 87 | }); 88 | 89 | it('should reject unhandled methods', async () => { 90 | const mockState = {} as any; 91 | 92 | await expect(adapter.handleRequest('textDocument/definition', {}, mockState)).rejects.toThrow( 93 | 'Unhandled request: textDocument/definition' 94 | ); 95 | }); 96 | }); 97 | 98 | describe('getTimeout', () => { 99 | it('should provide extended timeout for documentSymbol', () => { 100 | expect(adapter.getTimeout('textDocument/documentSymbol')).toBe(60000); 101 | }); 102 | 103 | it('should provide extended timeout for definition', () => { 104 | expect(adapter.getTimeout('textDocument/definition')).toBe(45000); 105 | }); 106 | 107 | it('should provide extended timeout for references', () => { 108 | expect(adapter.getTimeout('textDocument/references')).toBe(45000); 109 | }); 110 | 111 | it('should provide extended timeout for rename', () => { 112 | expect(adapter.getTimeout('textDocument/rename')).toBe(45000); 113 | }); 114 | 115 | it('should return undefined for hover', () => { 116 | expect(adapter.getTimeout('textDocument/hover')).toBeUndefined(); 117 | }); 118 | 119 | it('should return undefined for completion', () => { 120 | expect(adapter.getTimeout('textDocument/completion')).toBeUndefined(); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/lsp/adapters/pyright.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'bun:test'; 2 | import { PyrightAdapter } from './pyright.js'; 3 | 4 | describe('PyrightAdapter', () => { 5 | const adapter = new PyrightAdapter(); 6 | 7 | describe('matches', () => { 8 | it('should match pyright command', () => { 9 | expect( 10 | adapter.matches({ 11 | extensions: ['py'], 12 | command: ['pyright-langserver', '--stdio'], 13 | }) 14 | ).toBe(true); 15 | }); 16 | 17 | it('should match basedpyright command', () => { 18 | expect( 19 | adapter.matches({ 20 | extensions: ['py'], 21 | command: ['basedpyright-langserver', '--stdio'], 22 | }) 23 | ).toBe(true); 24 | }); 25 | 26 | it('should match command with pyright in path', () => { 27 | expect( 28 | adapter.matches({ 29 | extensions: ['py'], 30 | command: ['/usr/local/bin/pyright-langserver', '--stdio'], 31 | }) 32 | ).toBe(true); 33 | }); 34 | 35 | it('should not match pylsp', () => { 36 | expect( 37 | adapter.matches({ 38 | extensions: ['py'], 39 | command: ['pylsp'], 40 | }) 41 | ).toBe(false); 42 | }); 43 | 44 | it('should not match other servers', () => { 45 | expect( 46 | adapter.matches({ 47 | extensions: ['ts'], 48 | command: ['typescript-language-server', '--stdio'], 49 | }) 50 | ).toBe(false); 51 | }); 52 | }); 53 | 54 | describe('customizeInitializeParams', () => { 55 | it('should preserve existing initializationOptions', () => { 56 | const params = { 57 | processId: 123, 58 | clientInfo: { name: 'test', version: '1.0' }, 59 | capabilities: {}, 60 | rootUri: 'file:///test', 61 | workspaceFolders: [], 62 | initializationOptions: { 63 | existingOption: 'value', 64 | }, 65 | }; 66 | 67 | const result = adapter.customizeInitializeParams(params); 68 | 69 | expect(result.initializationOptions).toEqual({ 70 | existingOption: 'value', 71 | }); 72 | }); 73 | 74 | it('should handle undefined initializationOptions', () => { 75 | const params = { 76 | processId: 123, 77 | clientInfo: { name: 'test', version: '1.0' }, 78 | capabilities: {}, 79 | rootUri: 'file:///test', 80 | workspaceFolders: [], 81 | }; 82 | 83 | const result = adapter.customizeInitializeParams(params); 84 | 85 | expect(result.initializationOptions).toEqual({}); 86 | }); 87 | 88 | it('should handle null initializationOptions', () => { 89 | const params = { 90 | processId: 123, 91 | clientInfo: { name: 'test', version: '1.0' }, 92 | capabilities: {}, 93 | rootUri: 'file:///test', 94 | workspaceFolders: [], 95 | initializationOptions: null, 96 | }; 97 | 98 | const result = adapter.customizeInitializeParams(params); 99 | 100 | expect(result.initializationOptions).toEqual({}); 101 | }); 102 | }); 103 | 104 | describe('getTimeout', () => { 105 | it('should provide extended timeout for definition', () => { 106 | expect(adapter.getTimeout('textDocument/definition')).toBe(45000); 107 | }); 108 | 109 | it('should provide extended timeout for references', () => { 110 | expect(adapter.getTimeout('textDocument/references')).toBe(60000); 111 | }); 112 | 113 | it('should provide extended timeout for rename', () => { 114 | expect(adapter.getTimeout('textDocument/rename')).toBe(60000); 115 | }); 116 | 117 | it('should provide extended timeout for documentSymbol', () => { 118 | expect(adapter.getTimeout('textDocument/documentSymbol')).toBe(45000); 119 | }); 120 | 121 | it('should return undefined for hover', () => { 122 | expect(adapter.getTimeout('textDocument/hover')).toBeUndefined(); 123 | }); 124 | 125 | it('should return undefined for completion', () => { 126 | expect(adapter.getTimeout('textDocument/completion')).toBeUndefined(); 127 | }); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/file-scanner.ts: -------------------------------------------------------------------------------- 1 | import { readFile, readdir, stat } from 'node:fs/promises'; 2 | import { constants, access } from 'node:fs/promises'; 3 | import { extname, join } from 'node:path'; 4 | import ignore from 'ignore'; 5 | import type { LanguageServerConfig } from './language-servers.js'; 6 | 7 | // Default ignore patterns 8 | const DEFAULT_IGNORE_PATTERNS = [ 9 | 'node_modules', 10 | '.git', 11 | '.svn', 12 | '.hg', 13 | 'dist', 14 | 'build', 15 | 'out', 16 | 'target', 17 | 'bin', 18 | 'obj', 19 | '.next', 20 | '.nuxt', 21 | 'coverage', 22 | '.nyc_output', 23 | 'temp', 24 | 'cache', 25 | '.cache', 26 | '.vscode', 27 | '.idea', 28 | '*.log', 29 | '.DS_Store', 30 | 'Thumbs.db', 31 | ]; 32 | 33 | export interface FileScanResult { 34 | extensions: Set; 35 | recommendedServers: string[]; 36 | } 37 | 38 | /** 39 | * Load gitignore patterns and create an ignore filter 40 | */ 41 | export async function loadGitignore(projectPath: string): Promise> { 42 | const ig = ignore(); 43 | 44 | // Add default patterns 45 | ig.add(DEFAULT_IGNORE_PATTERNS); 46 | 47 | // Add .gitignore patterns if file exists 48 | const gitignorePath = join(projectPath, '.gitignore'); 49 | try { 50 | await access(gitignorePath, constants.F_OK); 51 | const gitignoreContent = await readFile(gitignorePath, 'utf-8'); 52 | ig.add(gitignoreContent); 53 | } catch (error) { 54 | // File doesn't exist or can't be read - that's ok 55 | } 56 | 57 | return ig; 58 | } 59 | 60 | /** 61 | * Recursively scan directory for file extensions 62 | */ 63 | export async function scanDirectoryForExtensions( 64 | dirPath: string, 65 | maxDepth = 3, 66 | ignoreFilter?: ReturnType, 67 | debug = false 68 | ): Promise> { 69 | const extensions = new Set(); 70 | 71 | async function scanDirectory( 72 | currentPath: string, 73 | currentDepth: number, 74 | relativePath = '' 75 | ): Promise { 76 | if (currentDepth > maxDepth) return; 77 | 78 | try { 79 | const entries = await readdir(currentPath); 80 | if (debug) { 81 | process.stderr.write( 82 | `Scanning directory ${currentPath} (depth: ${currentDepth}), found ${entries.length} entries: ${entries.join(', ')}\n` 83 | ); 84 | } 85 | 86 | for (const entry of entries) { 87 | const fullPath = join(currentPath, entry); 88 | const entryRelativePath = relativePath ? join(relativePath, entry) : entry; 89 | 90 | // Skip if ignored - normalize path separators for cross-platform compatibility 91 | const normalizedPath = entryRelativePath.replace(/\\/g, '/'); 92 | if (ignoreFilter?.ignores(normalizedPath)) { 93 | if (debug) { 94 | process.stderr.write(`Skipping ignored entry: ${entryRelativePath}\n`); 95 | } 96 | continue; 97 | } 98 | 99 | try { 100 | const fileStat = await stat(fullPath); 101 | 102 | if (fileStat.isDirectory()) { 103 | if (debug) { 104 | process.stderr.write(`Recursing into directory: ${entryRelativePath}\n`); 105 | } 106 | await scanDirectory(fullPath, currentDepth + 1, entryRelativePath); 107 | } else if (fileStat.isFile()) { 108 | const ext = extname(entry).toLowerCase().slice(1); // Remove the dot 109 | if (debug) { 110 | process.stderr.write(`Found file: ${entry}, extension: "${ext}"\n`); 111 | } 112 | if (ext) { 113 | extensions.add(ext); 114 | if (debug) { 115 | process.stderr.write(`Added extension: ${ext}\n`); 116 | } 117 | } 118 | } 119 | } catch (error) { 120 | process.stderr.write(`Error processing ${fullPath}: ${error}\n`); 121 | } 122 | } 123 | } catch (error) { 124 | process.stderr.write(`Error reading directory ${currentPath}: ${error}\n`); 125 | return; 126 | } 127 | } 128 | 129 | await scanDirectory(dirPath, 0); 130 | return extensions; 131 | } 132 | 133 | /** 134 | * Get recommended language servers based on found extensions 135 | */ 136 | export function getRecommendedLanguageServers( 137 | extensions: Set, 138 | languageServers: LanguageServerConfig[] 139 | ): string[] { 140 | const recommended: string[] = []; 141 | 142 | for (const server of languageServers) { 143 | const hasMatchingExtension = server.extensions.some((ext) => extensions.has(ext)); 144 | if (hasMatchingExtension) { 145 | recommended.push(server.name); 146 | } 147 | } 148 | 149 | return recommended; 150 | } 151 | 152 | /** 153 | * Scan project files and get recommendations 154 | */ 155 | export async function scanProjectFiles( 156 | projectPath: string, 157 | languageServers: LanguageServerConfig[], 158 | maxDepth = 3, 159 | debug = false 160 | ): Promise { 161 | const ignoreFilter = await loadGitignore(projectPath); 162 | const extensions = await scanDirectoryForExtensions(projectPath, maxDepth, ignoreFilter, debug); 163 | const recommendedServers = getRecommendedLanguageServers(extensions, languageServers); 164 | 165 | return { 166 | extensions, 167 | recommendedServers, 168 | }; 169 | } 170 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to cclsp 2 | 3 | First off, thank you for considering contributing to cclsp! It's people like you that make cclsp such a great tool. 4 | 5 | ## Code of Conduct 6 | 7 | By participating in this project, you are expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). 8 | 9 | ## How Can I Contribute? 10 | 11 | ### Reporting Bugs 12 | 13 | Before creating bug reports, please check existing issues as you might find out that you don't need to create one. When you are creating a bug report, please use our [bug report template](.github/ISSUE_TEMPLATE/bug_report.md) and include as many details as possible. 14 | 15 | ### Suggesting Enhancements 16 | 17 | Enhancement suggestions are tracked as GitHub issues. Use our [feature request template](.github/ISSUE_TEMPLATE/feature_request.md) to describe your idea. 18 | 19 | ### Adding Language Support 20 | 21 | One of the most valuable contributions is adding support for new language servers. Use our [language support template](.github/ISSUE_TEMPLATE/language_support.md) to propose new language integrations. 22 | 23 | ### Your First Code Contribution 24 | 25 | Unsure where to begin contributing? You can start by looking through these issues: 26 | 27 | - Issues labeled with `good first issue` - these should be relatively simple to implement 28 | - Issues labeled with `help wanted` - these are often more involved but are areas where we need help 29 | 30 | ## Development Process 31 | 32 | ### Prerequisites 33 | 34 | - Node.js 18+ or Bun runtime 35 | - Git 36 | - Your favorite code editor 37 | 38 | ### Setting Up Your Development Environment 39 | 40 | 1. Fork the repository 41 | 2. Clone your fork: 42 | ```bash 43 | git clone https://github.com/your-username/cclsp.git 44 | cd cclsp 45 | ``` 46 | 47 | 3. Install dependencies: 48 | ```bash 49 | bun install 50 | ``` 51 | 52 | 4. Create a branch for your feature or fix: 53 | ```bash 54 | git checkout -b feature/your-feature-name 55 | ``` 56 | 57 | ### Development Workflow 58 | 59 | 1. Make your changes 60 | 2. Add or update tests as needed 61 | 3. Run the test suite: 62 | ```bash 63 | bun test 64 | ``` 65 | 66 | 4. Run linting and formatting: 67 | ```bash 68 | bun run lint 69 | bun run format 70 | bun run typecheck 71 | ``` 72 | 73 | 5. Test your changes manually: 74 | ```bash 75 | bun run dev 76 | ``` 77 | 78 | ### Testing New Language Servers 79 | 80 | When adding support for a new language server: 81 | 82 | 1. Install the language server locally 83 | 2. Add configuration to `cclsp.json` 84 | 3. Create test files in the target language 85 | 4. Test all three main features: 86 | - Go to definition 87 | - Find references 88 | - Rename symbol 89 | 90 | ### Commit Messages 91 | 92 | We use conventional commits with gitmoji for better readability: 93 | 94 | - ✨ `:sparkles:` feat: New feature 95 | - 🐛 `:bug:` fix: Bug fix 96 | - 📚 `:books:` docs: Documentation changes 97 | - ♻️ `:recycle:` refactor: Code refactoring 98 | - ✅ `:white_check_mark:` test: Adding tests 99 | - 🎨 `:art:` style: Code style changes 100 | - ⚡ `:zap:` perf: Performance improvements 101 | 102 | Example: 103 | ``` 104 | ✨ feat: add support for Ruby language server 105 | 106 | - Add configuration for solargraph 107 | - Test go to definition and find references 108 | - Update README with Ruby examples 109 | ``` 110 | 111 | ### Pull Request Process 112 | 113 | 1. Ensure all tests pass and there are no linting errors 114 | 2. Update the README.md with details of changes if applicable 115 | 3. Add yourself to the contributors list if this is your first contribution 116 | 4. Create a Pull Request with a clear title and description 117 | 5. Link any related issues 118 | 119 | ### Code Review Process 120 | 121 | - All submissions require review from at least one maintainer 122 | - We may suggest changes or improvements 123 | - Please be patient as reviews may take time 124 | - Once approved, a maintainer will merge your PR 125 | 126 | ## Project Structure 127 | 128 | ``` 129 | cclsp/ 130 | ├── src/ 131 | │ ├── index.ts # MCP server entry point 132 | │ ├── lsp-client.ts # LSP client implementation 133 | │ └── *.test.ts # Test files 134 | ├── dist/ # Compiled output (gitignored) 135 | ├── .github/ # GitHub specific files 136 | ├── package.json # Package configuration 137 | └── tsconfig.json # TypeScript configuration 138 | ``` 139 | 140 | ## Style Guide 141 | 142 | ### TypeScript Style 143 | 144 | - Use TypeScript strict mode 145 | - Prefer `const` over `let` 146 | - Use functional programming patterns where appropriate 147 | - Add types to all function parameters and return values 148 | - Use meaningful variable and function names 149 | 150 | ### Code Organization 151 | 152 | - Keep files focused on a single responsibility 153 | - Export types and interfaces separately 154 | - Group related functionality together 155 | - Add JSDoc comments for public APIs 156 | 157 | ## Recognition 158 | 159 | Contributors will be recognized in the following ways: 160 | 161 | - Added to the contributors section in README 162 | - Mentioned in release notes for significant contributions 163 | - Given credit in commit messages when their ideas are implemented 164 | 165 | ## Questions? 166 | 167 | Feel free to open an issue with the `question` label or start a discussion in [GitHub Discussions](https://github.com/ktnyt/cclsp/discussions). 168 | 169 | Thank you for contributing! 🎉 -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT EMAIL]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. -------------------------------------------------------------------------------- /debug-tsgo.cjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { spawn } = require('node:child_process'); 4 | const path = require('node:path'); 5 | 6 | // デバッグ用のヘルパー関数 7 | function formatMessage(data) { 8 | const str = data.toString(); 9 | const lines = str.split('\n'); 10 | return lines 11 | .map((line) => line.trim()) 12 | .filter((line) => line) 13 | .join('\n'); 14 | } 15 | 16 | // Content-Lengthヘッダーを含むLSPメッセージを作成 17 | function createMessage(content) { 18 | const contentStr = JSON.stringify(content); 19 | const contentLength = Buffer.byteLength(contentStr, 'utf8'); 20 | return `Content-Length: ${contentLength}\r\n\r\n${contentStr}`; 21 | } 22 | 23 | // tsgoを起動 24 | console.log('Starting tsgo...'); 25 | const tsgo = spawn('tsgo', ['--lsp', '-stdio'], { 26 | stdio: ['pipe', 'pipe', 'pipe'], 27 | cwd: process.cwd(), 28 | }); 29 | 30 | let buffer = ''; 31 | let contentLength = null; 32 | 33 | // stdout(LSPレスポンス)を処理 34 | tsgo.stdout.on('data', (data) => { 35 | console.log('\n=== STDOUT DATA ==='); 36 | console.log('Raw:', data.toString().replace(/\r/g, '\\r').replace(/\n/g, '\\n')); 37 | 38 | buffer += data.toString(); 39 | 40 | while (true) { 41 | if (contentLength === null) { 42 | // Content-Lengthヘッダーを探す 43 | const headerMatch = buffer.match(/Content-Length: (\d+)\r\n\r\n/); 44 | if (headerMatch) { 45 | contentLength = Number.parseInt(headerMatch[1]); 46 | buffer = buffer.substring(headerMatch.index + headerMatch[0].length); 47 | } else { 48 | break; 49 | } 50 | } 51 | 52 | if (contentLength !== null) { 53 | // 完全なメッセージを受信したかチェック 54 | if (buffer.length >= contentLength) { 55 | const message = buffer.substring(0, contentLength); 56 | buffer = buffer.substring(contentLength); 57 | contentLength = null; 58 | 59 | try { 60 | const json = JSON.parse(message); 61 | console.log('\n=== LSP RESPONSE ==='); 62 | console.log(JSON.stringify(json, null, 2)); 63 | } catch (e) { 64 | console.error('Failed to parse JSON:', e); 65 | console.error('Message:', message); 66 | } 67 | } else { 68 | break; 69 | } 70 | } 71 | } 72 | }); 73 | 74 | // stderr(エラー出力)を処理 75 | tsgo.stderr.on('data', (data) => { 76 | console.error('\n=== STDERR ==='); 77 | console.error(formatMessage(data)); 78 | }); 79 | 80 | // プロセス終了時 81 | tsgo.on('close', (code) => { 82 | console.log(`\ntsgo exited with code ${code}`); 83 | process.exit(code); 84 | }); 85 | 86 | tsgo.on('error', (err) => { 87 | console.error('\nFailed to start tsgo:', err); 88 | process.exit(1); 89 | }); 90 | 91 | // 初期化リクエストを送信 92 | setTimeout(() => { 93 | console.log('\n=== SENDING INITIALIZE REQUEST ==='); 94 | const initRequest = { 95 | jsonrpc: '2.0', 96 | id: 1, 97 | method: 'initialize', 98 | params: { 99 | processId: process.pid, 100 | clientInfo: { 101 | name: 'debug-client', 102 | version: '1.0.0', 103 | }, 104 | rootUri: `file://${process.cwd()}`, 105 | capabilities: { 106 | textDocument: { 107 | hover: { 108 | contentFormat: ['plaintext', 'markdown'], 109 | }, 110 | completion: { 111 | completionItem: { 112 | snippetSupport: false, 113 | }, 114 | }, 115 | definition: { 116 | linkSupport: false, 117 | }, 118 | references: {}, 119 | }, 120 | }, 121 | }, 122 | }; 123 | 124 | const message = createMessage(initRequest); 125 | console.log('Sending:', message.replace(/\r/g, '\\r').replace(/\n/g, '\\n')); 126 | tsgo.stdin.write(message); 127 | }, 1000); 128 | 129 | // 初期化完了通知を送信 130 | setTimeout(() => { 131 | console.log('\n=== SENDING INITIALIZED NOTIFICATION ==='); 132 | const initializedNotif = { 133 | jsonrpc: '2.0', 134 | method: 'initialized', 135 | params: {}, 136 | }; 137 | 138 | const message = createMessage(initializedNotif); 139 | console.log('Sending:', message.replace(/\r/g, '\\r').replace(/\n/g, '\\n')); 140 | tsgo.stdin.write(message); 141 | }, 2000); 142 | 143 | // テスト用のtextDocument/definitionリクエストを送信 144 | setTimeout(() => { 145 | console.log('\n=== SENDING DEFINITION REQUEST ==='); 146 | const testFile = path.join(process.cwd(), 'src/lsp-client.ts'); 147 | const defRequest = { 148 | jsonrpc: '2.0', 149 | id: 2, 150 | method: 'textDocument/definition', 151 | params: { 152 | textDocument: { 153 | uri: `file://${testFile}`, 154 | }, 155 | position: { 156 | line: 10, 157 | character: 10, 158 | }, 159 | }, 160 | }; 161 | 162 | const message = createMessage(defRequest); 163 | console.log('Sending:', message.replace(/\r/g, '\\r').replace(/\n/g, '\\n')); 164 | tsgo.stdin.write(message); 165 | }, 3000); 166 | 167 | // textDocument/referencesリクエストも試す 168 | setTimeout(() => { 169 | console.log('\n=== SENDING REFERENCES REQUEST ==='); 170 | const testFile = path.join(process.cwd(), 'src/lsp-client.ts'); 171 | const refRequest = { 172 | jsonrpc: '2.0', 173 | id: 3, 174 | method: 'textDocument/references', 175 | params: { 176 | textDocument: { 177 | uri: `file://${testFile}`, 178 | }, 179 | position: { 180 | line: 10, 181 | character: 10, 182 | }, 183 | context: { 184 | includeDeclaration: true, 185 | }, 186 | }, 187 | }; 188 | 189 | const message = createMessage(refRequest); 190 | console.log('Sending:', message.replace(/\r/g, '\\r').replace(/\n/g, '\\n')); 191 | tsgo.stdin.write(message); 192 | }, 4000); 193 | 194 | // 5秒後に終了 195 | setTimeout(() => { 196 | console.log('\n=== SENDING SHUTDOWN REQUEST ==='); 197 | const shutdownRequest = { 198 | jsonrpc: '2.0', 199 | id: 4, 200 | method: 'shutdown', 201 | }; 202 | 203 | const message = createMessage(shutdownRequest); 204 | tsgo.stdin.write(message); 205 | 206 | setTimeout(() => { 207 | console.log('\n=== SENDING EXIT NOTIFICATION ==='); 208 | const exitNotif = { 209 | jsonrpc: '2.0', 210 | method: 'exit', 211 | }; 212 | 213 | const exitMessage = createMessage(exitNotif); 214 | tsgo.stdin.write(exitMessage); 215 | }, 500); 216 | }, 5000); 217 | 218 | // Ctrl+Cでの終了処理 219 | process.on('SIGINT', () => { 220 | console.log('\nReceived SIGINT, shutting down...'); 221 | tsgo.kill(); 222 | process.exit(0); 223 | }); 224 | -------------------------------------------------------------------------------- /src/server-selection.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, spyOn } from 'bun:test'; 2 | import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; 3 | import { join } from 'node:path'; 4 | import { LSPClient } from './lsp-client.js'; 5 | 6 | const TEST_DIR = process.env.RUNNER_TEMP 7 | ? `${process.env.RUNNER_TEMP}/cclsp-server-selection-test` 8 | : '/tmp/cclsp-server-selection-test'; 9 | 10 | const TEST_CONFIG_PATH = join(TEST_DIR, 'test-config.json'); 11 | 12 | describe('LSPClient server selection', () => { 13 | beforeEach(() => { 14 | // Clean up test directory 15 | if (existsSync(TEST_DIR)) { 16 | rmSync(TEST_DIR, { recursive: true, force: true }); 17 | } 18 | mkdirSync(TEST_DIR, { recursive: true }); 19 | }); 20 | 21 | it('should select single matching server', () => { 22 | const testConfig = { 23 | servers: [ 24 | { 25 | extensions: ['ts'], 26 | command: ['typescript-language-server', '--stdio'], 27 | rootDir: '.', 28 | }, 29 | ], 30 | }; 31 | 32 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 33 | const client = new LSPClient(TEST_CONFIG_PATH); 34 | 35 | // Access private method for testing 36 | const getServerForFile = (client as any).getServerForFile.bind(client); 37 | const server = getServerForFile('/some/path/test.ts'); 38 | 39 | expect(server).toBeTruthy(); 40 | expect(server.extensions).toContain('ts'); 41 | }); 42 | 43 | it('should select most specific rootDir when multiple servers match extension', () => { 44 | const testConfig = { 45 | servers: [ 46 | { 47 | extensions: ['ts'], 48 | command: ['server-root', '--stdio'], 49 | rootDir: '.', 50 | }, 51 | { 52 | extensions: ['ts'], 53 | command: ['server-specific', '--stdio'], 54 | rootDir: 'repos/applicationserver', 55 | }, 56 | ], 57 | }; 58 | 59 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 60 | const client = new LSPClient(TEST_CONFIG_PATH); 61 | const getServerForFile = (client as any).getServerForFile.bind(client); 62 | 63 | // File inside repos/applicationserver should use the more specific server 64 | const cwd = process.cwd(); 65 | const server = getServerForFile(join(cwd, 'repos/applicationserver/src/test.ts')); 66 | 67 | expect(server).toBeTruthy(); 68 | expect(server.command[0]).toBe('server-specific'); 69 | }); 70 | 71 | it('should fall back to less specific rootDir when file is outside specific rootDir', () => { 72 | const testConfig = { 73 | servers: [ 74 | { 75 | extensions: ['ts'], 76 | command: ['server-root', '--stdio'], 77 | rootDir: '.', 78 | }, 79 | { 80 | extensions: ['ts'], 81 | command: ['server-specific', '--stdio'], 82 | rootDir: 'repos/applicationserver', 83 | }, 84 | ], 85 | }; 86 | 87 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 88 | const client = new LSPClient(TEST_CONFIG_PATH); 89 | const getServerForFile = (client as any).getServerForFile.bind(client); 90 | 91 | // File outside repos/applicationserver should use root server 92 | const cwd = process.cwd(); 93 | const server = getServerForFile(join(cwd, 'other-dir/test.ts')); 94 | 95 | expect(server).toBeTruthy(); 96 | expect(server.command[0]).toBe('server-root'); 97 | }); 98 | 99 | it('should handle absolute paths in rootDir', () => { 100 | const testConfig = { 101 | servers: [ 102 | { 103 | extensions: ['ts'], 104 | command: ['server-absolute', '--stdio'], 105 | rootDir: '/absolute/path/to/project', 106 | }, 107 | ], 108 | }; 109 | 110 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 111 | const client = new LSPClient(TEST_CONFIG_PATH); 112 | const getServerForFile = (client as any).getServerForFile.bind(client); 113 | 114 | const server = getServerForFile('/absolute/path/to/project/src/test.ts'); 115 | 116 | expect(server).toBeTruthy(); 117 | expect(server.command[0]).toBe('server-absolute'); 118 | }); 119 | 120 | it('should return first match when no rootDir contains the file', () => { 121 | const testConfig = { 122 | servers: [ 123 | { 124 | extensions: ['ts'], 125 | command: ['server-one', '--stdio'], 126 | rootDir: 'project-a', 127 | }, 128 | { 129 | extensions: ['ts'], 130 | command: ['server-two', '--stdio'], 131 | rootDir: 'project-b', 132 | }, 133 | ], 134 | }; 135 | 136 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 137 | const client = new LSPClient(TEST_CONFIG_PATH); 138 | const getServerForFile = (client as any).getServerForFile.bind(client); 139 | 140 | // File outside both rootDirs should fall back to first match 141 | const server = getServerForFile('/completely/different/path/test.ts'); 142 | 143 | expect(server).toBeTruthy(); 144 | expect(server.command[0]).toBe('server-one'); 145 | }); 146 | 147 | it('should return null when no server matches extension', () => { 148 | const testConfig = { 149 | servers: [ 150 | { 151 | extensions: ['ts'], 152 | command: ['typescript-language-server', '--stdio'], 153 | rootDir: '.', 154 | }, 155 | ], 156 | }; 157 | 158 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 159 | const client = new LSPClient(TEST_CONFIG_PATH); 160 | const getServerForFile = (client as any).getServerForFile.bind(client); 161 | 162 | const server = getServerForFile('/some/path/test.py'); 163 | 164 | expect(server).toBeNull(); 165 | }); 166 | 167 | it('should prefer longer matching rootDir over shorter one', () => { 168 | const testConfig = { 169 | servers: [ 170 | { 171 | extensions: ['ts'], 172 | command: ['server-short', '--stdio'], 173 | rootDir: 'repos', 174 | }, 175 | { 176 | extensions: ['ts'], 177 | command: ['server-long', '--stdio'], 178 | rootDir: 'repos/applicationserver', 179 | }, 180 | { 181 | extensions: ['ts'], 182 | command: ['server-longest', '--stdio'], 183 | rootDir: 'repos/applicationserver/apps', 184 | }, 185 | ], 186 | }; 187 | 188 | writeFileSync(TEST_CONFIG_PATH, JSON.stringify(testConfig)); 189 | const client = new LSPClient(TEST_CONFIG_PATH); 190 | const getServerForFile = (client as any).getServerForFile.bind(client); 191 | 192 | const cwd = process.cwd(); 193 | const server = getServerForFile(join(cwd, 'repos/applicationserver/apps/infinity/src/test.ts')); 194 | 195 | expect(server).toBeTruthy(); 196 | expect(server.command[0]).toBe('server-longest'); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /src/file-editor-rollback.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; 2 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; 3 | import { join } from 'node:path'; 4 | import { applyWorkspaceEdit } from './file-editor.js'; 5 | import { pathToUri } from './utils.js'; 6 | 7 | const TEST_DIR = process.env.CI 8 | ? `${process.cwd()}/test-tmp/file-editor-rollback-test` 9 | : '/tmp/file-editor-rollback-test'; 10 | 11 | describe.skipIf(!!process.env.CI)('file-editor rollback without backups', () => { 12 | beforeEach(() => { 13 | // Clean up and create test directory 14 | if (existsSync(TEST_DIR)) { 15 | rmSync(TEST_DIR, { recursive: true, force: true }); 16 | } 17 | mkdirSync(TEST_DIR, { recursive: true }); 18 | }); 19 | 20 | afterEach(() => { 21 | // Clean up test directory 22 | if (existsSync(TEST_DIR)) { 23 | rmSync(TEST_DIR, { recursive: true, force: true }); 24 | } 25 | }); 26 | 27 | it('should rollback changes when createBackups=false and an error occurs', async () => { 28 | console.log(`[TEST DEBUG] Test starting, TEST_DIR: ${TEST_DIR}`); 29 | console.log(`[TEST DEBUG] Directory exists at test start: ${existsSync(TEST_DIR)}`); 30 | 31 | const file1 = join(TEST_DIR, 'file1.ts'); 32 | const file2 = join(TEST_DIR, 'file2.ts'); 33 | 34 | console.log(`[TEST DEBUG] Creating files: ${file1}, ${file2}`); 35 | 36 | const originalContent1 = 'const x = 1;'; 37 | const originalContent2 = 'const y = 2;'; 38 | 39 | try { 40 | writeFileSync(file1, originalContent1); 41 | console.log('[TEST DEBUG] file1 written successfully'); 42 | console.log(`[TEST DEBUG] file1 realpath: ${require('node:fs').realpathSync(file1)}`); 43 | } catch (error) { 44 | console.log(`[TEST DEBUG] file1 write/realpath failed: ${error}`); 45 | throw error; 46 | } 47 | 48 | try { 49 | writeFileSync(file2, originalContent2); 50 | console.log('[TEST DEBUG] file2 written successfully'); 51 | console.log(`[TEST DEBUG] file2 realpath: ${require('node:fs').realpathSync(file2)}`); 52 | } catch (error) { 53 | console.log(`[TEST DEBUG] file2 write/realpath failed: ${error}`); 54 | throw error; 55 | } 56 | 57 | console.log( 58 | `[TEST DEBUG] Files created - file1 exists: ${existsSync(file1)}, file2 exists: ${existsSync(file2)}` 59 | ); 60 | 61 | // Add small delay to see if timing issue 62 | await new Promise((resolve) => setTimeout(resolve, 10)); 63 | console.log( 64 | `[TEST DEBUG] After 10ms delay - file1 exists: ${existsSync(file1)}, file2 exists: ${existsSync(file2)}` 65 | ); 66 | 67 | // Create an edit that will succeed on file1 but fail on file2 68 | const result = await applyWorkspaceEdit( 69 | { 70 | changes: { 71 | [pathToUri(file1)]: [ 72 | { 73 | range: { 74 | start: { line: 0, character: 6 }, 75 | end: { line: 0, character: 7 }, 76 | }, 77 | newText: 'a', 78 | }, 79 | ], 80 | [pathToUri(file2)]: [ 81 | { 82 | range: { 83 | start: { line: 10, character: 0 }, // Invalid line - will cause failure 84 | end: { line: 10, character: 5 }, 85 | }, 86 | newText: 'invalid', 87 | }, 88 | ], 89 | }, 90 | }, 91 | { 92 | createBackups: false, // Critical: no backup files created 93 | validateBeforeApply: true, 94 | } 95 | ); 96 | 97 | // Should have failed 98 | expect(result.success).toBe(false); 99 | expect(result.error).toContain('Invalid start line'); 100 | 101 | // Check that file1 was rolled back to original content even without backup file 102 | const content1 = readFileSync(file1, 'utf-8'); 103 | expect(content1).toBe(originalContent1); 104 | 105 | // Verify no backup files were created 106 | expect(existsSync(`${file1}.bak`)).toBe(false); 107 | expect(existsSync(`${file2}.bak`)).toBe(false); 108 | }); 109 | 110 | it('should handle multi-line edit with invalid character positions', async () => { 111 | const filePath = join(TEST_DIR, 'test.ts'); 112 | const content = 'line1\nline2\nline3'; 113 | writeFileSync(filePath, content); 114 | 115 | // Multi-line edit where end character exceeds line length 116 | const result = await applyWorkspaceEdit( 117 | { 118 | changes: { 119 | [pathToUri(filePath)]: [ 120 | { 121 | range: { 122 | start: { line: 0, character: 3 }, 123 | end: { line: 2, character: 100 }, // line3 only has 5 characters 124 | }, 125 | newText: 'replaced', 126 | }, 127 | ], 128 | }, 129 | }, 130 | { validateBeforeApply: true } 131 | ); 132 | 133 | expect(result.success).toBe(false); 134 | expect(result.error).toContain('Invalid end character'); 135 | expect(result.error).toContain('line has 5 characters'); 136 | 137 | // File should be unchanged 138 | const unchangedContent = readFileSync(filePath, 'utf-8'); 139 | expect(unchangedContent).toBe(content); 140 | }); 141 | 142 | it('should detect inverted ranges (start > end)', async () => { 143 | const filePath = join(TEST_DIR, 'test.ts'); 144 | writeFileSync(filePath, 'const x = 1;\nconst y = 2;'); 145 | 146 | const result = await applyWorkspaceEdit( 147 | { 148 | changes: { 149 | [pathToUri(filePath)]: [ 150 | { 151 | range: { 152 | start: { line: 1, character: 5 }, 153 | end: { line: 0, character: 2 }, // End before start 154 | }, 155 | newText: 'invalid', 156 | }, 157 | ], 158 | }, 159 | }, 160 | { validateBeforeApply: true } 161 | ); 162 | 163 | expect(result.success).toBe(false); 164 | expect(result.error).toContain('Invalid range'); 165 | expect(result.error).toContain('start (1:5) is after end (0:2)'); 166 | }); 167 | 168 | it('should detect same-line inverted character positions', async () => { 169 | const filePath = join(TEST_DIR, 'test.ts'); 170 | writeFileSync(filePath, 'const x = 1;'); 171 | 172 | const result = await applyWorkspaceEdit( 173 | { 174 | changes: { 175 | [pathToUri(filePath)]: [ 176 | { 177 | range: { 178 | start: { line: 0, character: 10 }, 179 | end: { line: 0, character: 5 }, // End character before start 180 | }, 181 | newText: 'invalid', 182 | }, 183 | ], 184 | }, 185 | }, 186 | { validateBeforeApply: true } 187 | ); 188 | 189 | expect(result.success).toBe(false); 190 | expect(result.error).toContain('Invalid range'); 191 | expect(result.error).toContain('start (0:10) is after end (0:5)'); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | cclsp is an MCP (Model Context Protocol) server that bridges Language Server Protocol (LSP) functionality to MCP tools. It allows MCP clients to access LSP features like "go to definition" and "find references" through a standardized interface. 8 | 9 | ## Development Commands 10 | 11 | ```bash 12 | # Install dependencies 13 | bun install 14 | 15 | # Development with hot reload 16 | bun run dev 17 | 18 | # Build for production 19 | bun run build 20 | 21 | # Run the built server 22 | bun run start 23 | # or directly 24 | node dist/index.js 25 | 26 | # Run setup wizard to configure LSP servers 27 | cclsp setup 28 | 29 | # Quality assurance 30 | bun run lint # Check code style and issues 31 | bun run lint:fix # Auto-fix safe issues 32 | bun run format # Format code with Biome 33 | bun run typecheck # Run TypeScript type checking 34 | bun run test # Run unit tests 35 | bun run test:manual # Run manual MCP client test 36 | 37 | # Full pre-publish check 38 | npm run prepublishOnly # build + test + typecheck 39 | ``` 40 | 41 | ## Architecture 42 | 43 | ### Core Components 44 | 45 | **MCP Server Layer** (`index.ts`) 46 | 47 | - Entry point that implements MCP protocol 48 | - Exposes `find_definition`, `find_references`, and `rename_symbol` tools 49 | - Handles MCP client requests and delegates to LSP layer 50 | - Includes subcommand handling for `cclsp setup` 51 | 52 | **LSP Client Layer** (`src/lsp-client.ts`) 53 | 54 | - Manages multiple LSP server processes concurrently 55 | - Handles LSP protocol communication (JSON-RPC over stdio) 56 | - Maps file extensions to appropriate language servers 57 | - Maintains process lifecycle and request/response correlation 58 | - Auto-detects and applies server-specific adapters 59 | 60 | **Server Adapter System** (`src/lsp/adapters/`) 61 | 62 | - Built-in adapters for LSP servers with non-standard behavior 63 | - Vue Language Server adapter handles custom `tsserver/request` protocol 64 | - Pyright adapter provides extended timeouts for large projects 65 | - Automatically detected based on server command (no configuration needed) 66 | - Internal use only - not user-extensible 67 | 68 | **Configuration System** (`cclsp.json` or via `CCLSP_CONFIG_PATH`) 69 | 70 | - Defines which LSP servers to use for different file extensions 71 | - Supports environment-based config via `CCLSP_CONFIG_PATH` env var 72 | - Interactive setup wizard via `cclsp setup` command 73 | - File scanning with gitignore support for project structure detection 74 | 75 | ### Data Flow 76 | 77 | 1. MCP client sends tool request (e.g., `find_definition`) 78 | 2. Main server resolves file path and extracts position 79 | 3. LSP client determines appropriate language server for file extension 80 | 4. If server not running, spawns new LSP server process 81 | 5. Sends LSP request to server and correlates response 82 | 6. Transforms LSP response back to MCP format 83 | 84 | ### LSP Server Management 85 | 86 | The system spawns separate LSP server processes per configuration. Each server: 87 | 88 | - Runs as child process with stdio communication 89 | - Maintains its own initialization state 90 | - Handles multiple concurrent requests 91 | - Gets terminated on process exit 92 | 93 | Supported language servers (configurable): 94 | 95 | - TypeScript: `typescript-language-server` 96 | - Python: `pylsp` 97 | - Go: `gopls` 98 | 99 | ## Configuration 100 | 101 | The server loads configuration in this order: 102 | 103 | 1. `CCLSP_CONFIG_PATH` environment variable pointing to config file 104 | 2. `cclsp.json` file in working directory 105 | 3. Fails if neither is found (no default fallback) 106 | 107 | ### Interactive Setup 108 | 109 | Use `cclsp setup` to configure LSP servers interactively: 110 | 111 | - Scans project for file extensions (respects .gitignore) 112 | - Presents pre-configured language server options 113 | - Generates `cclsp.json` configuration file 114 | - Validates server availability before configuration 115 | 116 | Each server config requires: 117 | 118 | - `extensions`: File extensions to handle (array) 119 | - `command`: Command array to spawn LSP server 120 | - `rootDir`: Working directory for LSP server (optional) 121 | - `restartInterval`: Auto-restart interval in minutes (optional, helps with long-running server stability, minimum 1 minute) 122 | 123 | ### Example Configuration 124 | 125 | ```json 126 | { 127 | "servers": [ 128 | { 129 | "extensions": ["py"], 130 | "command": ["pylsp"], 131 | "restartInterval": 5 132 | }, 133 | { 134 | "extensions": ["ts", "tsx", "js", "jsx"], 135 | "command": ["typescript-language-server", "--stdio"], 136 | "restartInterval": 10 137 | } 138 | ] 139 | } 140 | ``` 141 | 142 | ## Code Quality & Testing 143 | 144 | The project uses Biome for linting and formatting: 145 | 146 | - **Linting**: Enabled with recommended rules + custom strictness 147 | - **Formatting**: 2-space indents, single quotes, semicolons always, LF endings 148 | - **TypeScript**: Strict type checking with `--noEmit` 149 | - **Testing**: Bun test framework with unit tests in `src/*.test.ts` 150 | 151 | Run quality checks before committing: 152 | 153 | ```bash 154 | bun run lint:fix && bun run format && bun run typecheck && bun run test 155 | ``` 156 | 157 | ## LSP Protocol Details 158 | 159 | The implementation handles LSP protocol specifics: 160 | 161 | - Content-Length headers for message framing 162 | - JSON-RPC 2.0 message format 163 | - Request/response correlation via ID tracking 164 | - Server initialization handshake 165 | - Proper process cleanup on shutdown 166 | - Preloading of servers for detected file types 167 | - Automatic server restart based on configured intervals 168 | - Manual server restart via MCP tool 169 | - Server-specific adapters for non-standard protocol extensions 170 | 171 | ### Server Adapters 172 | 173 | Some LSP servers deviate from the standard protocol or have special requirements. cclsp includes built-in adapters to handle these cases automatically: 174 | 175 | #### Vue Language Server Adapter 176 | 177 | The Vue Language Server uses a non-standard `tsserver/request` protocol for TypeScript integration. The adapter: 178 | 179 | - Handles `tsserver/request` notifications from the server 180 | - Responds with minimal project information to unblock the server 181 | - Extends timeouts for operations requiring TypeScript analysis (60s for documentSymbol, 45s for definition/references/rename) 182 | - Automatically detected when command contains `vue-language-server` or `@vue/language-server` 183 | 184 | #### Pyright Adapter 185 | 186 | Pyright can be slow on large Python projects. The adapter: 187 | 188 | - Extends timeouts for operations that may analyze many files (45-60s) 189 | - Automatically detected when command contains `pyright` or `basedpyright` 190 | 191 | **Note**: Adapters are internal to cclsp and not user-extensible. They are automatically selected based on the server command - no configuration needed. 192 | -------------------------------------------------------------------------------- /src/file-scanner.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; 2 | import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; 3 | import { mkdir, rm, writeFile } from 'node:fs/promises'; 4 | import { join } from 'node:path'; 5 | import { 6 | getRecommendedLanguageServers, 7 | loadGitignore, 8 | scanDirectoryForExtensions, 9 | scanProjectFiles, 10 | } from './file-scanner.js'; 11 | import { LANGUAGE_SERVERS } from './language-servers.js'; 12 | 13 | const TEST_DIR = process.env.RUNNER_TEMP 14 | ? `${process.env.RUNNER_TEMP}/file-scanner-test` 15 | : '/tmp/file-scanner-test'; 16 | 17 | describe('file-scanner', () => { 18 | beforeEach(() => { 19 | // Clean up and create fresh test directory 20 | try { 21 | rmSync(TEST_DIR, { recursive: true, force: true }); 22 | } catch { 23 | // Directory might not exist 24 | } 25 | mkdirSync(TEST_DIR, { recursive: true }); 26 | }); 27 | 28 | afterEach(() => { 29 | // Clean up after each test 30 | try { 31 | rmSync(TEST_DIR, { recursive: true, force: true }); 32 | } catch { 33 | // Directory might not exist 34 | } 35 | }); 36 | 37 | describe('loadGitignore', () => { 38 | it('should load default ignore patterns', async () => { 39 | const ig = await loadGitignore(TEST_DIR); 40 | 41 | // Test that default patterns are loaded 42 | expect(ig.ignores('node_modules')).toBe(true); 43 | expect(ig.ignores('dist')).toBe(true); 44 | expect(ig.ignores('.git')).toBe(true); 45 | expect(ig.ignores('src.ts')).toBe(false); 46 | }); 47 | 48 | it('should load custom gitignore patterns', async () => { 49 | await writeFile(join(TEST_DIR, '.gitignore'), 'custom_dir\n*.log\n'); 50 | 51 | const ig = await loadGitignore(TEST_DIR); 52 | 53 | expect(ig.ignores('custom_dir')).toBe(true); 54 | expect(ig.ignores('test.log')).toBe(true); 55 | expect(ig.ignores('test.txt')).toBe(false); 56 | }); 57 | 58 | it('should handle missing .gitignore gracefully', async () => { 59 | const ig = await loadGitignore(TEST_DIR); 60 | 61 | // Should still have default patterns 62 | expect(ig.ignores('node_modules')).toBe(true); 63 | expect(ig.ignores('regular_file.ts')).toBe(false); 64 | }); 65 | }); 66 | 67 | describe('scanDirectoryForExtensions', () => { 68 | it('should find file extensions', async () => { 69 | await writeFile(join(TEST_DIR, 'test.ts'), 'console.log("test");'); 70 | await writeFile(join(TEST_DIR, 'app.js'), 'console.log("app");'); 71 | await writeFile(join(TEST_DIR, 'main.py'), 'print("hello")'); 72 | 73 | const extensions = await scanDirectoryForExtensions(TEST_DIR); 74 | 75 | expect(extensions.has('ts')).toBe(true); 76 | expect(extensions.has('js')).toBe(true); 77 | expect(extensions.has('py')).toBe(true); 78 | }); 79 | 80 | it('should respect gitignore patterns', async () => { 81 | await writeFile(join(TEST_DIR, '.gitignore'), 'ignored.ts\nignored_dir/\n'); 82 | 83 | // Create files - some should be ignored 84 | await writeFile(join(TEST_DIR, 'normal.ts'), 'console.log("normal");'); 85 | await writeFile(join(TEST_DIR, 'ignored.ts'), 'console.log("ignored");'); 86 | 87 | await mkdir(join(TEST_DIR, 'ignored_dir'), { recursive: true }); 88 | await writeFile(join(TEST_DIR, 'ignored_dir', 'file.js'), 'console.log("ignored");'); 89 | 90 | const ig = await loadGitignore(TEST_DIR); 91 | const extensions = await scanDirectoryForExtensions(TEST_DIR, 3, ig); 92 | 93 | // Should find TypeScript extension from normal.ts but not from ignored files 94 | expect(extensions.has('ts')).toBe(true); 95 | expect(extensions.has('js')).toBe(false); // js file was in ignored directory 96 | }); 97 | 98 | it('should skip common ignore patterns by default', async () => { 99 | // Create files in directories that should be ignored 100 | await mkdir(join(TEST_DIR, 'node_modules', 'pkg'), { recursive: true }); 101 | await writeFile(join(TEST_DIR, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};'); 102 | 103 | await mkdir(join(TEST_DIR, 'dist'), { recursive: true }); 104 | await writeFile(join(TEST_DIR, 'dist', 'build.js'), 'console.log("build");'); 105 | 106 | // Create a file that should be included 107 | await writeFile(join(TEST_DIR, 'src.ts'), 'console.log("source");'); 108 | 109 | const ig = await loadGitignore(TEST_DIR); 110 | const extensions = await scanDirectoryForExtensions(TEST_DIR, 3, ig); 111 | 112 | // Should only find TypeScript, not JavaScript from ignored directories 113 | expect(extensions.has('ts')).toBe(true); 114 | expect(extensions.has('js')).toBe(false); 115 | }); 116 | 117 | it('should respect maxDepth parameter', async () => { 118 | // Create nested directories 119 | await mkdir(join(TEST_DIR, 'level1', 'level2', 'level3', 'level4'), { recursive: true }); 120 | await writeFile( 121 | join(TEST_DIR, 'level1', 'level2', 'level3', 'level4', 'deep.rs'), 122 | 'fn main() {}' 123 | ); 124 | await writeFile(join(TEST_DIR, 'level1', 'shallow.go'), 'package main'); 125 | 126 | const extensions = await scanDirectoryForExtensions(TEST_DIR, 2); 127 | 128 | // Should find go at level 2 but not rust at level 4 129 | expect(extensions.has('go')).toBe(true); 130 | expect(extensions.has('rs')).toBe(false); 131 | }); 132 | }); 133 | 134 | describe('getRecommendedLanguageServers', () => { 135 | it('should recommend servers based on extensions', () => { 136 | const extensions = new Set(['ts', 'js', 'py', 'go']); 137 | const recommended = getRecommendedLanguageServers(extensions, LANGUAGE_SERVERS); 138 | 139 | expect(recommended).toContain('typescript'); 140 | expect(recommended).toContain('python'); 141 | expect(recommended).toContain('go'); 142 | expect(recommended).not.toContain('rust'); // rs extension not in set 143 | }); 144 | 145 | it('should return empty array for unknown extensions', () => { 146 | const extensions = new Set(['unknown', 'fake']); 147 | const recommended = getRecommendedLanguageServers(extensions, LANGUAGE_SERVERS); 148 | 149 | expect(recommended).toHaveLength(0); 150 | }); 151 | 152 | it('should handle empty extensions set', () => { 153 | const extensions = new Set(); 154 | const recommended = getRecommendedLanguageServers(extensions, LANGUAGE_SERVERS); 155 | 156 | expect(recommended).toHaveLength(0); 157 | }); 158 | }); 159 | 160 | describe('scanProjectFiles', () => { 161 | it('should return complete scan result', async () => { 162 | await writeFile(join(TEST_DIR, 'app.ts'), 'console.log("app");'); 163 | await writeFile(join(TEST_DIR, 'main.py'), 'print("hello")'); 164 | 165 | const result = await scanProjectFiles(TEST_DIR, LANGUAGE_SERVERS); 166 | 167 | expect(result.extensions.has('ts')).toBe(true); 168 | expect(result.extensions.has('py')).toBe(true); 169 | expect(result.recommendedServers).toContain('typescript'); 170 | expect(result.recommendedServers).toContain('python'); 171 | }); 172 | 173 | it('should respect gitignore in full scan', async () => { 174 | await writeFile(join(TEST_DIR, '.gitignore'), '*.temp\n'); 175 | await writeFile(join(TEST_DIR, 'app.ts'), 'console.log("app");'); 176 | await writeFile(join(TEST_DIR, 'ignore.temp'), 'temp file'); 177 | 178 | const result = await scanProjectFiles(TEST_DIR, LANGUAGE_SERVERS); 179 | 180 | expect(result.extensions.has('ts')).toBe(true); 181 | expect(result.extensions.has('temp')).toBe(false); 182 | expect(result.recommendedServers).toContain('typescript'); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/language-servers.ts: -------------------------------------------------------------------------------- 1 | export interface LanguageServerConfig { 2 | name: string; 3 | displayName: string; 4 | extensions: string[]; 5 | command: string[]; 6 | installInstructions: string; 7 | rootDir?: string; 8 | description?: string; 9 | installRequired?: boolean; 10 | restartInterval?: number; // Default restart interval in minutes 11 | initializationOptions?: unknown; // Default LSP initialization options 12 | } 13 | 14 | export const LANGUAGE_SERVERS: LanguageServerConfig[] = [ 15 | { 16 | name: 'typescript', 17 | displayName: 'TypeScript/JavaScript', 18 | extensions: ['js', 'ts', 'jsx', 'tsx'], 19 | command: ['npx', '--', 'typescript-language-server', '--stdio'], 20 | installInstructions: 'npm install -g typescript-language-server', 21 | description: 'TypeScript and JavaScript language server', 22 | installRequired: false, 23 | }, 24 | { 25 | name: 'python', 26 | displayName: 'Python', 27 | extensions: ['py', 'pyi'], 28 | command: ['uvx', '--from', 'python-lsp-server', 'pylsp'], 29 | installInstructions: 'pip install python-lsp-server', 30 | description: 'Python Language Server Protocol implementation', 31 | installRequired: false, 32 | restartInterval: 5, // Auto-restart every 5 minutes to prevent performance degradation 33 | initializationOptions: { 34 | settings: { 35 | pylsp: { 36 | plugins: { 37 | jedi_completion: { enabled: true }, 38 | jedi_definition: { enabled: true }, 39 | jedi_hover: { enabled: true }, 40 | jedi_references: { enabled: true }, 41 | jedi_signature_help: { enabled: true }, 42 | jedi_symbols: { enabled: true }, 43 | pylint: { enabled: false }, 44 | pycodestyle: { enabled: false }, 45 | pyflakes: { enabled: false }, 46 | yapf: { enabled: false }, 47 | rope_completion: { enabled: false }, 48 | }, 49 | }, 50 | }, 51 | }, 52 | }, 53 | { 54 | name: 'go', 55 | displayName: 'Go', 56 | extensions: ['go'], 57 | command: ['gopls'], 58 | installInstructions: 'go install golang.org/x/tools/gopls@latest', 59 | description: 'Official language server for the Go language', 60 | installRequired: true, 61 | }, 62 | { 63 | name: 'rust', 64 | displayName: 'Rust', 65 | extensions: ['rs'], 66 | command: ['rust-analyzer'], 67 | installInstructions: 'rustup component add rust-analyzer', 68 | description: 'Rust language server providing IDE-like features', 69 | installRequired: true, 70 | }, 71 | { 72 | name: 'c-cpp', 73 | displayName: 'C/C++', 74 | extensions: ['c', 'cpp', 'cc', 'h', 'hpp'], 75 | command: ['clangd'], 76 | installInstructions: 'Install clangd via your system package manager', 77 | description: 'LLVM-based language server for C and C++', 78 | installRequired: true, 79 | }, 80 | { 81 | name: 'java', 82 | displayName: 'Java', 83 | extensions: ['java'], 84 | command: ['jdtls'], 85 | installInstructions: 'Download Eclipse JDT Language Server', 86 | description: 'Eclipse JDT Language Server for Java', 87 | installRequired: true, 88 | }, 89 | { 90 | name: 'ruby', 91 | displayName: 'Ruby', 92 | extensions: ['rb'], 93 | command: ['solargraph', 'stdio'], 94 | installInstructions: 'gem install solargraph', 95 | description: 'Ruby language server providing IntelliSense', 96 | installRequired: true, 97 | }, 98 | { 99 | name: 'php', 100 | displayName: 'PHP', 101 | extensions: ['php'], 102 | command: ['intelephense', '--stdio'], 103 | installInstructions: 'npm install -g intelephense', 104 | description: 'PHP language server with advanced features', 105 | installRequired: true, 106 | }, 107 | { 108 | name: 'csharp', 109 | displayName: 'C#', 110 | extensions: ['cs'], 111 | command: ['omnisharp', '-lsp'], 112 | installInstructions: 'Install OmniSharp language server', 113 | description: 'Language server for C# and .NET', 114 | installRequired: true, 115 | }, 116 | { 117 | name: 'swift', 118 | displayName: 'Swift', 119 | extensions: ['swift'], 120 | command: ['sourcekit-lsp'], 121 | installInstructions: 'Comes with Swift toolchain', 122 | description: 'Language server for Swift programming language', 123 | installRequired: true, 124 | }, 125 | { 126 | name: 'kotlin', 127 | displayName: 'Kotlin', 128 | extensions: ['kt', 'kts'], 129 | command: ['kotlin-language-server'], 130 | installInstructions: 'Download from kotlin-language-server releases', 131 | description: 'Language server for Kotlin programming language', 132 | installRequired: true, 133 | }, 134 | { 135 | name: 'dart', 136 | displayName: 'Dart/Flutter', 137 | extensions: ['dart'], 138 | command: ['dart', 'language-server'], 139 | installInstructions: 'Install with Dart SDK', 140 | description: 'Dart language server for Dart and Flutter development', 141 | installRequired: true, 142 | }, 143 | { 144 | name: 'elixir', 145 | displayName: 'Elixir', 146 | extensions: ['ex', 'exs'], 147 | command: ['elixir-ls'], 148 | installInstructions: 'Install ElixirLS language server', 149 | description: 'Language server for Elixir programming language', 150 | installRequired: true, 151 | }, 152 | { 153 | name: 'haskell', 154 | displayName: 'Haskell', 155 | extensions: ['hs', 'lhs'], 156 | command: ['haskell-language-server-wrapper', '--lsp'], 157 | installInstructions: 'Install via ghcup or stack', 158 | description: 'Haskell Language Server for Haskell development', 159 | installRequired: true, 160 | }, 161 | { 162 | name: 'lua', 163 | displayName: 'Lua', 164 | extensions: ['lua'], 165 | command: ['lua-language-server'], 166 | installInstructions: 'Install lua-language-server', 167 | description: 'Language server for Lua programming language', 168 | installRequired: true, 169 | }, 170 | { 171 | name: 'vue', 172 | displayName: 'Vue.js', 173 | extensions: ['vue'], 174 | command: ['npx', '--', 'vue-language-server', '--stdio'], 175 | installInstructions: 'npm install -g @vue/language-server', 176 | description: 'Official Vue.js language server (Volar)', 177 | installRequired: false, 178 | }, 179 | { 180 | name: 'svelte', 181 | displayName: 'Svelte', 182 | extensions: ['svelte'], 183 | command: ['npx', '--', 'svelteserver', '--stdio'], 184 | installInstructions: 'npm install -g svelte-language-server', 185 | description: 'Language server for Svelte framework', 186 | installRequired: false, 187 | }, 188 | ]; 189 | 190 | export function generateConfig(selectedLanguages: string[]): object { 191 | const selectedServers = LANGUAGE_SERVERS.filter((server) => 192 | selectedLanguages.includes(server.name) 193 | ); 194 | 195 | return { 196 | servers: selectedServers.map((server) => { 197 | const config: { 198 | extensions: string[]; 199 | command: string[]; 200 | rootDir: string; 201 | restartInterval?: number; 202 | initializationOptions?: unknown; 203 | } = { 204 | extensions: server.extensions, 205 | command: server.command, 206 | rootDir: server.rootDir || '.', 207 | }; 208 | 209 | // Add restartInterval if specified for the server 210 | if (server.restartInterval) { 211 | config.restartInterval = server.restartInterval; 212 | } 213 | 214 | // Add initializationOptions if specified for the server 215 | if (server.initializationOptions) { 216 | config.initializationOptions = server.initializationOptions; 217 | } 218 | 219 | return config; 220 | }), 221 | }; 222 | } 223 | -------------------------------------------------------------------------------- /test/manual-rename-test.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bun 2 | /** 3 | * Manual test script for rename operations 4 | * Run with: bun test/manual-rename-test.ts 5 | */ 6 | 7 | import { spawn } from 'node:child_process'; 8 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; 9 | import { dirname, join } from 'node:path'; 10 | import { fileURLToPath } from 'node:url'; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = dirname(__filename); 14 | 15 | const TEST_DIR = '/tmp/cclsp-rename-test'; 16 | const FIXTURES_DIR = join(__dirname, 'fixtures'); 17 | 18 | interface TestCase { 19 | name: string; 20 | fixture: string; 21 | testFile: string; 22 | oldName: string; 23 | newName: string; 24 | expectedChanges: string[]; 25 | } 26 | 27 | const testCases: TestCase[] = [ 28 | { 29 | name: 'TypeScript class rename', 30 | fixture: 'typescript-example.ts', 31 | testFile: 'test.ts', 32 | oldName: 'UserService', 33 | newName: 'AccountService', 34 | expectedChanges: ['export class AccountService {'], 35 | }, 36 | { 37 | name: 'Python class rename', 38 | fixture: 'python-example.py', 39 | testFile: 'test.py', 40 | oldName: 'Calculator', 41 | newName: 'MathProcessor', 42 | expectedChanges: ['class MathProcessor:', 'calc = MathProcessor()'], 43 | }, 44 | { 45 | name: 'Go struct rename', 46 | fixture: 'go-example.go', 47 | testFile: 'test.go', 48 | oldName: 'DataStore', 49 | newName: 'Storage', 50 | expectedChanges: [ 51 | 'type Storage struct {', 52 | 'func NewStorage() *Storage {', 53 | 'func (ds *Storage)', 54 | ], 55 | }, 56 | ]; 57 | 58 | interface RenameArgs { 59 | file_path: string; 60 | symbol_name: string; 61 | new_name: string; 62 | dry_run: boolean; 63 | } 64 | 65 | interface MCPResult { 66 | content: Array<{ text: string }>; 67 | } 68 | 69 | async function runMCPCommand(args: RenameArgs): Promise { 70 | return new Promise((resolve, reject) => { 71 | const serverPath = join(__dirname, '..', 'dist', 'index.js'); 72 | const mcp = spawn('node', [serverPath], { 73 | stdio: ['pipe', 'pipe', 'pipe'], 74 | }); 75 | 76 | let output = ''; 77 | let error = ''; 78 | 79 | mcp.stdout.on('data', (data) => { 80 | output += data.toString(); 81 | }); 82 | 83 | mcp.stderr.on('data', (data) => { 84 | error += data.toString(); 85 | }); 86 | 87 | mcp.on('close', (code) => { 88 | if (code !== 0) { 89 | console.error('Server error output:', error); 90 | reject(new Error(`MCP server exited with code ${code}`)); 91 | } else { 92 | try { 93 | // Parse JSON-RPC response 94 | const lines = output.split('\n'); 95 | for (const line of lines) { 96 | if (line.includes('"result"')) { 97 | const response = JSON.parse(line); 98 | resolve(response.result); 99 | return; 100 | } 101 | } 102 | reject(new Error('No valid response from MCP server')); 103 | } catch (e) { 104 | reject(e); 105 | } 106 | } 107 | }); 108 | 109 | // Send JSON-RPC request 110 | const request = { 111 | jsonrpc: '2.0', 112 | id: 1, 113 | method: 'tools/call', 114 | params: { 115 | name: 'rename_symbol', 116 | arguments: args, 117 | }, 118 | }; 119 | 120 | mcp.stdin.write(`${JSON.stringify(request)}\n`); 121 | mcp.stdin.end(); 122 | }); 123 | } 124 | 125 | async function setupTestEnvironment() { 126 | console.log('Setting up test environment...'); 127 | 128 | // Clean and create test directory 129 | if (existsSync(TEST_DIR)) { 130 | rmSync(TEST_DIR, { recursive: true, force: true }); 131 | } 132 | mkdirSync(TEST_DIR, { recursive: true }); 133 | 134 | // Copy fixtures to test directory 135 | for (const testCase of testCases) { 136 | const src = join(FIXTURES_DIR, testCase.fixture); 137 | const dest = join(TEST_DIR, testCase.testFile); 138 | 139 | if (existsSync(src)) { 140 | copyFileSync(src, dest); 141 | console.log(`Copied ${testCase.fixture} to ${testCase.testFile}`); 142 | } else { 143 | console.warn(`Warning: Fixture ${testCase.fixture} not found`); 144 | } 145 | } 146 | 147 | // Create a config file for LSP servers 148 | const config = { 149 | servers: [ 150 | { 151 | extensions: ['ts', 'tsx', 'js', 'jsx'], 152 | command: ['npx', '--', 'typescript-language-server', '--stdio'], 153 | }, 154 | { 155 | extensions: ['py'], 156 | command: ['pylsp'], 157 | }, 158 | { 159 | extensions: ['go'], 160 | command: ['gopls'], 161 | }, 162 | ], 163 | }; 164 | 165 | const configPath = join(TEST_DIR, 'cclsp.json'); 166 | require('node:fs').writeFileSync(configPath, JSON.stringify(config, null, 2)); 167 | console.log('Created LSP config file'); 168 | } 169 | 170 | async function runTest(testCase: TestCase, dryRun: boolean) { 171 | console.log(`\n${'='.repeat(60)}`); 172 | console.log(`Test: ${testCase.name} (dry_run: ${dryRun})`); 173 | console.log('='.repeat(60)); 174 | 175 | const testFile = join(TEST_DIR, testCase.testFile); 176 | 177 | // Read original content 178 | const originalContent = readFileSync(testFile, 'utf-8'); 179 | console.log('\nOriginal content (first 10 lines):'); 180 | console.log(originalContent.split('\n').slice(0, 10).join('\n')); 181 | 182 | try { 183 | // Run rename operation 184 | const result = await runMCPCommand({ 185 | file_path: testFile, 186 | symbol_name: testCase.oldName, 187 | new_name: testCase.newName, 188 | dry_run: dryRun, 189 | }); 190 | 191 | console.log('\nMCP Response:'); 192 | console.log(result.content[0]?.text || 'No response text'); 193 | 194 | if (!dryRun) { 195 | // Read modified content 196 | const modifiedContent = readFileSync(testFile, 'utf-8'); 197 | console.log('\nModified content (first 10 lines):'); 198 | console.log(modifiedContent.split('\n').slice(0, 10).join('\n')); 199 | 200 | // Verify expected changes 201 | console.log('\nVerifying expected changes:'); 202 | for (const expected of testCase.expectedChanges) { 203 | if (modifiedContent.includes(expected)) { 204 | console.log(`✅ Found: "${expected}"`); 205 | } else { 206 | console.log(`❌ Missing: "${expected}"`); 207 | } 208 | } 209 | 210 | // Check that old name is gone 211 | if (!modifiedContent.includes(testCase.oldName)) { 212 | console.log(`✅ Old name "${testCase.oldName}" successfully replaced`); 213 | } else { 214 | console.log(`⚠️ Old name "${testCase.oldName}" still present`); 215 | } 216 | } 217 | } catch (error) { 218 | console.error('Test failed:', error); 219 | } 220 | } 221 | 222 | async function main() { 223 | console.log('Manual Rename Test Script'); 224 | console.log('=========================\n'); 225 | 226 | // Build the project first 227 | console.log('Building project...'); 228 | const { execSync } = require('node:child_process'); 229 | try { 230 | execSync('bun run build', { cwd: join(__dirname, '..') }); 231 | console.log('Build successful\n'); 232 | } catch (error) { 233 | console.error('Build failed:', error); 234 | process.exit(1); 235 | } 236 | 237 | // Setup test environment 238 | await setupTestEnvironment(); 239 | 240 | // Change to test directory for relative paths 241 | process.chdir(TEST_DIR); 242 | 243 | // Run tests 244 | for (const testCase of testCases) { 245 | // First run with dry_run to preview changes 246 | await runTest(testCase, true); 247 | 248 | // Then run actual rename 249 | await runTest(testCase, false); 250 | 251 | // Restore original file for next test 252 | const src = join(FIXTURES_DIR, testCase.fixture); 253 | const dest = join(TEST_DIR, testCase.testFile); 254 | copyFileSync(src, dest); 255 | } 256 | 257 | console.log(`\n${'='.repeat(60)}`); 258 | console.log('All tests completed!'); 259 | console.log('Test files are in:', TEST_DIR); 260 | } 261 | 262 | // Run the tests 263 | main().catch(console.error); 264 | -------------------------------------------------------------------------------- /src/get-diagnostics.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, spyOn } from 'bun:test'; 2 | import { resolve } from 'node:path'; 3 | import type { LSPClient } from './lsp-client.js'; 4 | import type { Diagnostic } from './types.js'; 5 | 6 | // Create a function that executes the handler logic 7 | async function createHandler(args: { file_path: string }, lspClient: { getDiagnostics: any }) { 8 | const { file_path } = args; 9 | const absolutePath = resolve(file_path); 10 | 11 | try { 12 | const diagnostics = await lspClient.getDiagnostics(absolutePath); 13 | 14 | if (diagnostics.length === 0) { 15 | return { 16 | content: [ 17 | { 18 | type: 'text', 19 | text: `No diagnostics found for ${file_path}. The file has no errors, warnings, or hints.`, 20 | }, 21 | ], 22 | }; 23 | } 24 | 25 | const severityMap: Record = { 26 | 1: 'Error', 27 | 2: 'Warning', 28 | 3: 'Information', 29 | 4: 'Hint', 30 | }; 31 | 32 | const diagnosticMessages = diagnostics.map((diag: Diagnostic) => { 33 | const severity = diag.severity ? severityMap[diag.severity] || 'Unknown' : 'Unknown'; 34 | const code = diag.code ? ` [${diag.code}]` : ''; 35 | const source = diag.source ? ` (${diag.source})` : ''; 36 | const { start, end } = diag.range; 37 | 38 | return `• ${severity}${code}${source}: ${diag.message}\n Location: Line ${start.line + 1}, Column ${start.character + 1} to Line ${end.line + 1}, Column ${end.character + 1}`; 39 | }); 40 | 41 | return { 42 | content: [ 43 | { 44 | type: 'text', 45 | text: `Found ${diagnostics.length} diagnostic${diagnostics.length === 1 ? '' : 's'} in ${file_path}:\n\n${diagnosticMessages.join('\n\n')}`, 46 | }, 47 | ], 48 | }; 49 | } catch (error) { 50 | return { 51 | content: [ 52 | { 53 | type: 'text', 54 | text: `Error getting diagnostics: ${error instanceof Error ? error.message : String(error)}`, 55 | }, 56 | ], 57 | }; 58 | } 59 | } 60 | 61 | describe('get_diagnostics MCP tool', () => { 62 | let mockLspClient: { 63 | getDiagnostics: ReturnType; 64 | }; 65 | 66 | beforeEach(() => { 67 | mockLspClient = { 68 | getDiagnostics: spyOn({} as LSPClient, 'getDiagnostics'), 69 | }; 70 | }); 71 | 72 | it('should return message when no diagnostics found', async () => { 73 | mockLspClient.getDiagnostics.mockResolvedValue([]); 74 | 75 | const result = await createHandler({ file_path: 'test.ts' }, mockLspClient); 76 | 77 | expect(result?.content[0]?.text).toBe( 78 | 'No diagnostics found for test.ts. The file has no errors, warnings, or hints.' 79 | ); 80 | expect(mockLspClient.getDiagnostics).toHaveBeenCalledWith(resolve('test.ts')); 81 | }); 82 | 83 | it('should format single diagnostic correctly', async () => { 84 | const mockDiagnostics: Diagnostic[] = [ 85 | { 86 | range: { 87 | start: { line: 0, character: 5 }, 88 | end: { line: 0, character: 10 }, 89 | }, 90 | severity: 1, // Error 91 | message: 'Undefined variable', 92 | code: 'TS2304', 93 | source: 'typescript', 94 | }, 95 | ]; 96 | 97 | mockLspClient.getDiagnostics.mockResolvedValue(mockDiagnostics); 98 | 99 | const result = await createHandler({ file_path: 'test.ts' }, mockLspClient); 100 | 101 | expect(result?.content[0]?.text).toContain('Found 1 diagnostic in test.ts:'); 102 | expect(result?.content[0]?.text).toContain('• Error [TS2304] (typescript): Undefined variable'); 103 | expect(result?.content[0]?.text).toContain('Location: Line 1, Column 6 to Line 1, Column 11'); 104 | }); 105 | 106 | it('should format multiple diagnostics correctly', async () => { 107 | const mockDiagnostics: Diagnostic[] = [ 108 | { 109 | range: { 110 | start: { line: 0, character: 0 }, 111 | end: { line: 0, character: 5 }, 112 | }, 113 | severity: 1, // Error 114 | message: 'Missing semicolon', 115 | code: '1003', 116 | source: 'typescript', 117 | }, 118 | { 119 | range: { 120 | start: { line: 2, character: 10 }, 121 | end: { line: 2, character: 15 }, 122 | }, 123 | severity: 2, // Warning 124 | message: 'Unused variable', 125 | source: 'eslint', 126 | }, 127 | { 128 | range: { 129 | start: { line: 5, character: 0 }, 130 | end: { line: 5, character: 20 }, 131 | }, 132 | severity: 3, // Information 133 | message: 'Consider using const', 134 | }, 135 | { 136 | range: { 137 | start: { line: 10, character: 4 }, 138 | end: { line: 10, character: 8 }, 139 | }, 140 | severity: 4, // Hint 141 | message: 'Add type annotation', 142 | code: 'no-implicit-any', 143 | }, 144 | ]; 145 | 146 | mockLspClient.getDiagnostics.mockResolvedValue(mockDiagnostics); 147 | 148 | const result = await createHandler({ file_path: 'src/main.ts' }, mockLspClient); 149 | 150 | expect(result?.content[0]?.text).toContain('Found 4 diagnostics in src/main.ts:'); 151 | expect(result?.content[0]?.text).toContain('• Error [1003] (typescript): Missing semicolon'); 152 | expect(result?.content[0]?.text).toContain('• Warning (eslint): Unused variable'); 153 | expect(result?.content[0]?.text).toContain('• Information: Consider using const'); 154 | expect(result?.content[0]?.text).toContain('• Hint [no-implicit-any]: Add type annotation'); 155 | }); 156 | 157 | it('should handle diagnostics without optional fields', async () => { 158 | const mockDiagnostics: Diagnostic[] = [ 159 | { 160 | range: { 161 | start: { line: 0, character: 0 }, 162 | end: { line: 0, character: 10 }, 163 | }, 164 | message: 'Basic error message', 165 | // No severity, code, or source 166 | }, 167 | ]; 168 | 169 | mockLspClient.getDiagnostics.mockResolvedValue(mockDiagnostics); 170 | 171 | const result = await createHandler({ file_path: 'test.ts' }, mockLspClient); 172 | 173 | expect(result?.content[0]?.text).toContain('• Unknown: Basic error message'); 174 | expect(result?.content[0]?.text).not.toContain('['); 175 | expect(result?.content[0]?.text).not.toContain('('); 176 | }); 177 | 178 | it('should handle absolute file paths', async () => { 179 | mockLspClient.getDiagnostics.mockResolvedValue([]); 180 | 181 | await createHandler({ file_path: '/absolute/path/to/file.ts' }, mockLspClient); 182 | 183 | expect(mockLspClient.getDiagnostics).toHaveBeenCalledWith(resolve('/absolute/path/to/file.ts')); 184 | }); 185 | 186 | it('should handle error from getDiagnostics', async () => { 187 | mockLspClient.getDiagnostics.mockRejectedValue(new Error('LSP server not available')); 188 | 189 | const result = await createHandler({ file_path: 'test.ts' }, mockLspClient); 190 | 191 | expect(result?.content[0]?.text).toBe('Error getting diagnostics: LSP server not available'); 192 | }); 193 | 194 | it('should handle non-Error exceptions', async () => { 195 | mockLspClient.getDiagnostics.mockRejectedValue('Unknown error'); 196 | 197 | const result = await createHandler({ file_path: 'test.ts' }, mockLspClient); 198 | 199 | expect(result?.content[0]?.text).toBe('Error getting diagnostics: Unknown error'); 200 | }); 201 | 202 | it('should convert 0-indexed line and character to 1-indexed for display', async () => { 203 | const mockDiagnostics: Diagnostic[] = [ 204 | { 205 | range: { 206 | start: { line: 0, character: 0 }, 207 | end: { line: 0, character: 0 }, 208 | }, 209 | severity: 1, 210 | message: 'Error at start of file', 211 | }, 212 | ]; 213 | 214 | mockLspClient.getDiagnostics.mockResolvedValue(mockDiagnostics); 215 | 216 | const result = await createHandler({ file_path: 'test.ts' }, mockLspClient); 217 | 218 | // 0-indexed (0, 0) should be displayed as 1-indexed (1, 1) 219 | expect(result?.content[0]?.text).toContain('Location: Line 1, Column 1 to Line 1, Column 1'); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /src/file-editor-symlink.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; 2 | import { 3 | existsSync, 4 | lstatSync, 5 | mkdirSync, 6 | readFileSync, 7 | readlinkSync, 8 | rmSync, 9 | symlinkSync, 10 | writeFileSync, 11 | } from 'node:fs'; 12 | import { lstat, readFile, readlink, symlink } from 'node:fs/promises'; 13 | import { tmpdir } from 'node:os'; 14 | import { join } from 'node:path'; 15 | import { applyWorkspaceEdit } from './file-editor.js'; 16 | import { pathToUri } from './utils.js'; 17 | 18 | // Check if symlinks are supported in this environment 19 | function canCreateSymlinks(): boolean { 20 | try { 21 | const testFile = join(tmpdir(), `cclsp-test-target-${Date.now()}.txt`); 22 | const testLink = join(tmpdir(), `cclsp-test-link-${Date.now()}.txt`); 23 | 24 | writeFileSync(testFile, 'test'); 25 | symlinkSync(testFile, testLink); 26 | const isLink = lstatSync(testLink).isSymbolicLink(); 27 | 28 | rmSync(testFile, { force: true }); 29 | rmSync(testLink, { force: true }); 30 | return isLink; 31 | } catch (error) { 32 | return false; 33 | } 34 | } 35 | 36 | // Timeout wrapper for potentially hanging operations in CI 37 | function withTimeout(promise: Promise, ms: number, operation: string): Promise { 38 | return Promise.race([ 39 | promise, 40 | new Promise((_, reject) => 41 | setTimeout(() => reject(new Error(`${operation} timed out after ${ms}ms`)), ms) 42 | ), 43 | ]); 44 | } 45 | 46 | // Robust symlink operations with timeout for CI environments 47 | async function robustCreateSymlink(target: string, link: string): Promise { 48 | if (process.env.CI) { 49 | // Use async with timeout in CI to prevent hangs 50 | await withTimeout(symlink(target, link), 1000, 'symlink creation'); 51 | } else { 52 | // Use sync version locally for simplicity 53 | symlinkSync(target, link); 54 | } 55 | } 56 | 57 | async function robustVerifySymlink(link: string): Promise { 58 | if (process.env.CI) { 59 | const stats = await withTimeout(lstat(link), 500, 'symlink verification'); 60 | return stats.isSymbolicLink(); 61 | } 62 | return lstatSync(link).isSymbolicLink(); 63 | } 64 | 65 | async function robustReadSymlink(link: string): Promise { 66 | if (process.env.CI) { 67 | return await withTimeout(readlink(link), 500, 'symlink readlink'); 68 | } 69 | return readlinkSync(link); 70 | } 71 | 72 | async function robustReadThroughSymlink(link: string): Promise { 73 | if (process.env.CI) { 74 | return await withTimeout(readFile(link, 'utf-8'), 500, 'symlink file read'); 75 | } 76 | return readFileSync(link, 'utf-8'); 77 | } 78 | 79 | const TEST_DIR = process.env.CI 80 | ? `${process.cwd()}/test-tmp/file-editor-symlink-test` 81 | : '/tmp/file-editor-symlink-test'; 82 | 83 | describe.skipIf(!canCreateSymlinks() || !!process.env.CI)('file-editor symlink handling', () => { 84 | beforeEach(() => { 85 | // Clean up and create test directory 86 | if (existsSync(TEST_DIR)) { 87 | rmSync(TEST_DIR, { recursive: true, force: true }); 88 | } 89 | mkdirSync(TEST_DIR, { recursive: true }); 90 | }); 91 | 92 | afterEach(() => { 93 | // Clean up test directory 94 | if (existsSync(TEST_DIR)) { 95 | rmSync(TEST_DIR, { recursive: true, force: true }); 96 | } 97 | }); 98 | 99 | it('should edit the target file without replacing the symlink', async () => { 100 | // Create a target file 101 | const targetPath = join(TEST_DIR, 'target.ts'); 102 | const originalContent = 'const oldName = 42;'; 103 | writeFileSync(targetPath, originalContent); 104 | 105 | // Create a symlink pointing to the target using robust operations 106 | const symlinkPath = join(TEST_DIR, 'link.ts'); 107 | await robustCreateSymlink(targetPath, symlinkPath); 108 | 109 | // Verify symlink was created correctly using robust operations 110 | expect(await robustVerifySymlink(symlinkPath)).toBe(true); 111 | expect(await robustReadSymlink(symlinkPath)).toBe(targetPath); 112 | expect(await robustReadThroughSymlink(symlinkPath)).toBe(originalContent); 113 | 114 | // Apply an edit to the symlink path 115 | const result = await applyWorkspaceEdit({ 116 | changes: { 117 | [pathToUri(symlinkPath)]: [ 118 | { 119 | range: { 120 | start: { line: 0, character: 6 }, 121 | end: { line: 0, character: 13 }, 122 | }, 123 | newText: 'newName', 124 | }, 125 | ], 126 | }, 127 | }); 128 | 129 | expect(result.success).toBe(true); 130 | 131 | // CRITICAL: The symlink should STILL be a symlink, not replaced with a regular file 132 | const symlinkStatsAfter = await robustVerifySymlink(symlinkPath); 133 | expect(symlinkStatsAfter).toBe(true); 134 | expect(lstatSync(symlinkPath).isFile()).toBe(false); 135 | 136 | // The symlink should still point to the same target 137 | expect(await robustReadSymlink(symlinkPath)).toBe(targetPath); 138 | 139 | // The content should be updated when read through either path 140 | const expectedContent = 'const newName = 42;'; 141 | expect(await robustReadThroughSymlink(symlinkPath)).toBe(expectedContent); 142 | expect(readFileSync(targetPath, 'utf-8')).toBe(expectedContent); 143 | }); 144 | 145 | it('should handle edits to multiple symlinks and regular files', async () => { 146 | // Create target files 147 | const target1 = join(TEST_DIR, 'target1.ts'); 148 | const target2 = join(TEST_DIR, 'target2.ts'); 149 | const regularFile = join(TEST_DIR, 'regular.ts'); 150 | 151 | writeFileSync(target1, 'class OldClass1 {}'); 152 | writeFileSync(target2, 'class OldClass2 {}'); 153 | writeFileSync(regularFile, 'class OldClass3 {}'); 154 | 155 | // Create symlinks using robust operations 156 | const link1 = join(TEST_DIR, 'link1.ts'); 157 | const link2 = join(TEST_DIR, 'link2.ts'); 158 | await robustCreateSymlink(target1, link1); 159 | await robustCreateSymlink(target2, link2); 160 | 161 | // Apply edits to all files (mix of symlinks and regular) 162 | const result = await applyWorkspaceEdit({ 163 | changes: { 164 | [pathToUri(link1)]: [ 165 | { 166 | range: { 167 | start: { line: 0, character: 6 }, 168 | end: { line: 0, character: 15 }, 169 | }, 170 | newText: 'NewClass1', 171 | }, 172 | ], 173 | [pathToUri(link2)]: [ 174 | { 175 | range: { 176 | start: { line: 0, character: 6 }, 177 | end: { line: 0, character: 15 }, 178 | }, 179 | newText: 'NewClass2', 180 | }, 181 | ], 182 | [pathToUri(regularFile)]: [ 183 | { 184 | range: { 185 | start: { line: 0, character: 6 }, 186 | end: { line: 0, character: 15 }, 187 | }, 188 | newText: 'NewClass3', 189 | }, 190 | ], 191 | }, 192 | }); 193 | 194 | expect(result.success).toBe(true); 195 | 196 | // Verify symlinks are still symlinks 197 | expect(await robustVerifySymlink(link1)).toBe(true); 198 | expect(await robustVerifySymlink(link2)).toBe(true); 199 | expect(lstatSync(regularFile).isFile()).toBe(true); 200 | expect(lstatSync(regularFile).isSymbolicLink()).toBe(false); 201 | 202 | // Verify content updates 203 | expect(readFileSync(target1, 'utf-8')).toBe('class NewClass1 {}'); 204 | expect(readFileSync(target2, 'utf-8')).toBe('class NewClass2 {}'); 205 | expect(readFileSync(regularFile, 'utf-8')).toBe('class NewClass3 {}'); 206 | }); 207 | 208 | it('should create backups of the target file, not the symlink', async () => { 209 | const targetPath = join(TEST_DIR, 'target.ts'); 210 | const symlinkPath = join(TEST_DIR, 'link.ts'); 211 | 212 | writeFileSync(targetPath, 'const x = 1;'); 213 | await robustCreateSymlink(targetPath, symlinkPath); 214 | 215 | const result = await applyWorkspaceEdit( 216 | { 217 | changes: { 218 | [pathToUri(symlinkPath)]: [ 219 | { 220 | range: { 221 | start: { line: 0, character: 10 }, 222 | end: { line: 0, character: 11 }, 223 | }, 224 | newText: '2', 225 | }, 226 | ], 227 | }, 228 | }, 229 | { createBackups: true } 230 | ); 231 | 232 | expect(result.success).toBe(true); 233 | 234 | // The backup should be of the target file (which may be the resolved path) 235 | expect(result.backupFiles.length).toBe(1); 236 | const backupPath = result.backupFiles[0]; 237 | expect(backupPath).toBeDefined(); 238 | expect(backupPath?.endsWith('.bak')).toBe(true); 239 | 240 | if (backupPath) { 241 | expect(existsSync(backupPath)).toBe(true); 242 | expect(readFileSync(backupPath, 'utf-8')).toBe('const x = 1;'); 243 | 244 | // Clean up backup 245 | rmSync(backupPath); 246 | } 247 | }); 248 | 249 | it('should handle rollback correctly when editing through symlink fails', async () => { 250 | const targetPath = join(TEST_DIR, 'target.ts'); 251 | const symlinkPath = join(TEST_DIR, 'link.ts'); 252 | 253 | const originalContent = 'const x = 1;\nconst y = 2;'; 254 | writeFileSync(targetPath, originalContent); 255 | await robustCreateSymlink(targetPath, symlinkPath); 256 | 257 | // Apply an edit that will fail validation 258 | const result = await applyWorkspaceEdit({ 259 | changes: { 260 | [pathToUri(symlinkPath)]: [ 261 | { 262 | range: { 263 | start: { line: 10, character: 0 }, // Invalid line 264 | end: { line: 10, character: 5 }, 265 | }, 266 | newText: 'invalid', 267 | }, 268 | ], 269 | }, 270 | }); 271 | 272 | expect(result.success).toBe(false); 273 | 274 | // Symlink should still be a symlink 275 | expect(await robustVerifySymlink(symlinkPath)).toBe(true); 276 | 277 | // Target content should be unchanged 278 | expect(readFileSync(targetPath, 'utf-8')).toBe(originalContent); 279 | expect(await robustReadThroughSymlink(symlinkPath)).toBe(originalContent); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /src/setup-execution.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'bun:test'; 2 | import { spawn } from 'node:child_process'; 3 | import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; 4 | import { tmpdir } from 'node:os'; 5 | import { join } from 'node:path'; 6 | import { buildMCPArgs, generateMCPCommand } from './setup.js'; 7 | 8 | // Helper function to execute command 9 | async function executeCommand( 10 | command: string, 11 | args: string[] 12 | ): Promise<{ success: boolean; stdout: string; stderr: string }> { 13 | return new Promise((resolve) => { 14 | const child = spawn(command, args, { 15 | shell: false, 16 | stdio: ['pipe', 'pipe', 'pipe'], 17 | }); 18 | 19 | let stdout = ''; 20 | let stderr = ''; 21 | 22 | child.stdout?.on('data', (data) => { 23 | stdout += data.toString(); 24 | }); 25 | 26 | child.stderr?.on('data', (data) => { 27 | stderr += data.toString(); 28 | }); 29 | 30 | child.on('error', () => { 31 | resolve({ success: false, stdout, stderr }); 32 | }); 33 | 34 | child.on('close', (code) => { 35 | resolve({ success: code === 0, stdout, stderr }); 36 | }); 37 | }); 38 | } 39 | 40 | describe('Setup command execution tests', () => { 41 | test('should generate valid claude mcp add command', async () => { 42 | const configPath = '/test/path/config.json'; 43 | const command = generateMCPCommand(configPath, false); 44 | const args = buildMCPArgs(configPath, false); 45 | 46 | // Verify command structure 47 | expect(command).toContain('claude mcp add cclsp'); 48 | expect(command).toContain('--env CCLSP_CONFIG_PATH='); 49 | expect(command).toContain('npx cclsp@latest'); 50 | 51 | // Verify args structure 52 | expect(args[0]).toBe('mcp'); 53 | expect(args[1]).toBe('add'); 54 | expect(args[2]).toBe('cclsp'); 55 | 56 | // Find --env argument 57 | const envIndex = args.indexOf('--env'); 58 | expect(envIndex).toBeGreaterThan(2); 59 | expect(args[envIndex + 1]).toContain('CCLSP_CONFIG_PATH='); 60 | }); 61 | 62 | test('should execute claude mcp add command with dry-run', async () => { 63 | const testDir = join(tmpdir(), `cclsp-mcp-test-${Date.now()}`); 64 | const configPath = join(testDir, 'cclsp.json'); 65 | 66 | try { 67 | // Create test directory and config 68 | mkdirSync(testDir, { recursive: true }); 69 | writeFileSync( 70 | configPath, 71 | JSON.stringify({ 72 | servers: [ 73 | { 74 | extensions: ['ts'], 75 | command: ['npx', '--', 'typescript-language-server', '--stdio'], 76 | rootDir: '.', 77 | }, 78 | ], 79 | }) 80 | ); 81 | 82 | // Build the command with --dry-run flag 83 | const args = buildMCPArgs(configPath, false); 84 | 85 | // Replace 'mcp' with 'echo' to simulate the command 86 | const testArgs = ['claude', ...args]; 87 | 88 | // Execute with echo to verify command structure 89 | const result = await executeCommand('echo', testArgs); 90 | 91 | expect(result.success).toBe(true); 92 | expect(result.stdout).toContain('claude'); 93 | expect(result.stdout).toContain('mcp'); 94 | expect(result.stdout).toContain('add'); 95 | expect(result.stdout).toContain('cclsp'); 96 | expect(result.stdout).toContain('CCLSP_CONFIG_PATH='); 97 | } finally { 98 | // Cleanup 99 | rmSync(testDir, { recursive: true, force: true }); 100 | } 101 | }); 102 | 103 | test.skipIf(!process.env.TEST_WITH_CLAUDE_CLI)( 104 | 'should execute actual claude mcp add command', 105 | async () => { 106 | const testDir = join(tmpdir(), `cclsp-real-test-${Date.now()}`); 107 | const configPath = join(testDir, 'cclsp.json'); 108 | 109 | try { 110 | // Create test directory and config 111 | mkdirSync(testDir, { recursive: true }); 112 | writeFileSync( 113 | configPath, 114 | JSON.stringify({ 115 | servers: [ 116 | { 117 | extensions: ['ts'], 118 | command: ['npx', '--', 'typescript-language-server', '--stdio'], 119 | rootDir: '.', 120 | }, 121 | ], 122 | }) 123 | ); 124 | 125 | // Check if claude command exists 126 | const checkResult = await executeCommand('which', ['claude']); 127 | const hasClaudeCLI = checkResult.success && checkResult.stdout.trim().length > 0; 128 | 129 | if (hasClaudeCLI) { 130 | // Build the actual command 131 | const args = buildMCPArgs(configPath, false); 132 | 133 | // First, try to remove if exists (ignore errors) 134 | await executeCommand('claude', ['mcp', 'remove', 'cclsp']); 135 | 136 | // Execute the actual add command 137 | const result = await executeCommand('claude', args); 138 | 139 | if (result.success) { 140 | console.log('✅ Successfully added cclsp to MCP configuration'); 141 | 142 | // Try to remove it to clean up 143 | const removeResult = await executeCommand('claude', ['mcp', 'remove', 'cclsp']); 144 | expect(removeResult.success).toBe(true); 145 | } else { 146 | console.log('⚠️ Claude CLI command failed:', result.stderr); 147 | // Log the command that would have been executed 148 | const command = generateMCPCommand(configPath, false); 149 | console.log(`Command would be: ${command}`); 150 | } 151 | } else { 152 | console.log('⚠️ Claude CLI not found, verifying command format only'); 153 | 154 | // Just verify the command would be correctly formatted 155 | const command = generateMCPCommand(configPath, false); 156 | console.log(`Command would be: ${command}`); 157 | expect(command).toContain('claude mcp add cclsp'); 158 | expect(command).toContain('--env CCLSP_CONFIG_PATH='); 159 | expect(command).toContain('npx cclsp@latest'); 160 | } 161 | } finally { 162 | // Cleanup 163 | rmSync(testDir, { recursive: true, force: true }); 164 | } 165 | } 166 | ); 167 | test.skipIf(!process.env.RUN_EXECUTION_TESTS)( 168 | 'should execute MCP command with spaces in path', 169 | async () => { 170 | const testDir = join(tmpdir(), `cclsp-exec-test-${Date.now()} with spaces`); 171 | const configPath = join(testDir, 'cclsp.json'); 172 | 173 | try { 174 | // Create test directory and config 175 | mkdirSync(testDir, { recursive: true }); 176 | writeFileSync( 177 | configPath, 178 | JSON.stringify({ 179 | servers: [ 180 | { 181 | extensions: ['ts'], 182 | command: ['npx', '--', 'typescript-language-server', '--stdio'], 183 | rootDir: '.', 184 | }, 185 | ], 186 | }) 187 | ); 188 | 189 | // Generate command parts 190 | const args = buildMCPArgs(configPath, false); 191 | 192 | // Test with echo to verify command structure 193 | const echoResult = await executeCommand('echo', args.slice(1)); 194 | expect(echoResult.success).toBe(true); 195 | expect(echoResult.stdout).toContain('add'); 196 | expect(echoResult.stdout).toContain('cclsp'); 197 | expect(echoResult.stdout).toContain('CCLSP_CONFIG_PATH='); 198 | 199 | // Verify path handling based on platform 200 | const isWindows = process.platform === 'win32'; 201 | if (isWindows) { 202 | // Windows: Path with spaces should be quoted 203 | expect(echoResult.stdout).toContain('"'); 204 | } else { 205 | // Non-Windows: Path with spaces should be escaped 206 | expect(echoResult.stdout).toContain('\\ '); 207 | expect(echoResult.stdout).not.toContain('"'); 208 | } 209 | } finally { 210 | // Cleanup 211 | rmSync(testDir, { recursive: true, force: true }); 212 | } 213 | } 214 | ); 215 | 216 | test('should handle command execution simulation', async () => { 217 | const testPath = '/path with spaces/config.json'; 218 | const isWindows = process.platform === 'win32'; 219 | const args = buildMCPArgs(testPath, false); 220 | 221 | // Simulate execution with echo (always available) 222 | const testArgs = ['MCP_SIMULATION:', ...args.slice(1)]; 223 | const result = await executeCommand('echo', testArgs); 224 | 225 | expect(result.success).toBe(true); 226 | expect(result.stdout).toContain('MCP_SIMULATION:'); 227 | expect(result.stdout).toContain('add'); 228 | expect(result.stdout).toContain('cclsp'); 229 | 230 | // Verify path handling based on platform 231 | const envArg = args.find((arg) => arg.startsWith('CCLSP_CONFIG_PATH=')); 232 | if (isWindows) { 233 | // Windows: Path with spaces should be quoted 234 | expect(envArg).toContain('"'); 235 | } else { 236 | // Non-Windows: Path with spaces should be escaped 237 | expect(envArg).not.toContain('"'); 238 | expect(envArg).toContain('\\ '); 239 | } 240 | }); 241 | 242 | test.skipIf(!process.env.TEST_WITH_CLAUDE_CLI)('should work with actual claude CLI', async () => { 243 | // This test requires Claude CLI to be installed 244 | const testDir = join(tmpdir(), `cclsp-claude-test-${Date.now()}`); 245 | const configPath = join(testDir, 'cclsp.json'); 246 | 247 | try { 248 | mkdirSync(testDir, { recursive: true }); 249 | writeFileSync( 250 | configPath, 251 | JSON.stringify({ 252 | servers: [ 253 | { 254 | extensions: ['ts'], 255 | command: ['npx', '--', 'typescript-language-server', '--stdio'], 256 | rootDir: '.', 257 | }, 258 | ], 259 | }) 260 | ); 261 | 262 | // Try to check if command would work (dry run) 263 | const args = buildMCPArgs(configPath, false); 264 | 265 | // Check if claude command exists 266 | const checkResult = await executeCommand('which', ['claude']); 267 | if (checkResult.success) { 268 | // Claude is installed, we could test the actual command 269 | // But we'll just verify the structure is correct 270 | console.log('Claude CLI found, command would be:', ['claude', ...args].join(' ')); 271 | } 272 | 273 | expect(args).toContain('mcp'); 274 | expect(args).toContain('add'); 275 | expect(args).toContain('cclsp'); 276 | } finally { 277 | rmSync(testDir, { recursive: true, force: true }); 278 | } 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /src/file-editor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | copyFileSync, 3 | existsSync, 4 | lstatSync, 5 | readFileSync, 6 | realpathSync, 7 | renameSync, 8 | statSync, 9 | unlinkSync, 10 | writeFileSync, 11 | } from 'node:fs'; 12 | import type { LSPClient } from './lsp-client.js'; 13 | import { uriToPath } from './utils.js'; 14 | 15 | export interface TextEdit { 16 | range: { 17 | start: { line: number; character: number }; 18 | end: { line: number; character: number }; 19 | }; 20 | newText: string; 21 | } 22 | 23 | export interface WorkspaceEdit { 24 | changes?: Record; 25 | } 26 | 27 | export interface ApplyEditResult { 28 | success: boolean; 29 | filesModified: string[]; 30 | backupFiles: string[]; 31 | error?: string; 32 | } 33 | 34 | interface FileBackup { 35 | originalPath: string; // The path that was requested (could be symlink) 36 | targetPath: string; // The actual file path (resolved symlink target or same as originalPath) 37 | backupPath?: string; 38 | originalContent: string; 39 | } 40 | 41 | /** 42 | * Apply a workspace edit to files on disk 43 | * @param workspaceEdit The edit to apply (from LSP rename operation) 44 | * @param options Configuration options 45 | * @returns Result indicating success and modified files 46 | */ 47 | export async function applyWorkspaceEdit( 48 | workspaceEdit: WorkspaceEdit, 49 | options: { 50 | createBackups?: boolean; 51 | validateBeforeApply?: boolean; 52 | backupSuffix?: string; 53 | lspClient?: LSPClient; 54 | } = {} 55 | ): Promise { 56 | const { 57 | createBackups = true, 58 | validateBeforeApply = true, 59 | backupSuffix = '.bak', 60 | lspClient, 61 | } = options; 62 | 63 | const backups: FileBackup[] = []; 64 | const filesModified: string[] = []; 65 | 66 | if (!workspaceEdit.changes || Object.keys(workspaceEdit.changes).length === 0) { 67 | return { 68 | success: true, 69 | filesModified: [], 70 | backupFiles: [], 71 | }; 72 | } 73 | 74 | try { 75 | // Pre-flight checks 76 | for (const [uri, edits] of Object.entries(workspaceEdit.changes)) { 77 | const filePath = uriToPath(uri); 78 | 79 | // Check file exists 80 | if (!existsSync(filePath)) { 81 | throw new Error(`File does not exist: ${filePath}`); 82 | } 83 | 84 | // Check if it's a symlink and validate the target 85 | const stats = lstatSync(filePath); 86 | if (stats.isSymbolicLink()) { 87 | // For symlinks, validate that the target exists and is a file 88 | try { 89 | const realPath = realpathSync(filePath); 90 | const targetStats = statSync(realPath); 91 | if (!targetStats.isFile()) { 92 | throw new Error(`Symlink target is not a file: ${realPath}`); 93 | } 94 | } catch (error) { 95 | throw new Error(`Cannot resolve symlink ${filePath}: ${error}`); 96 | } 97 | } else if (!stats.isFile()) { 98 | // For non-symlinks, check it's a regular file 99 | throw new Error(`Not a file: ${filePath}`); 100 | } 101 | 102 | // Try to read the file to ensure we have permissions 103 | try { 104 | readFileSync(filePath, 'utf-8'); 105 | } catch (error) { 106 | throw new Error(`Cannot read file: ${filePath} - ${error}`); 107 | } 108 | } 109 | 110 | // Process each file 111 | for (const [uri, edits] of Object.entries(workspaceEdit.changes)) { 112 | const originalPath = uriToPath(uri); 113 | 114 | // Resolve symlinks to their actual target 115 | let targetPath = originalPath; 116 | const originalStats = lstatSync(originalPath); 117 | if (originalStats.isSymbolicLink()) { 118 | targetPath = realpathSync(originalPath); 119 | process.stderr.write( 120 | `[DEBUG] Editing symlink target: ${targetPath} (via ${originalPath})\n` 121 | ); 122 | } 123 | 124 | // Read content from the actual file (symlink target or regular file) 125 | const originalContent = readFileSync(targetPath, 'utf-8'); 126 | 127 | // Always track original content for rollback 128 | const backup: FileBackup = { 129 | originalPath: originalPath, // The requested path (could be symlink) 130 | targetPath: targetPath, // The actual file to restore 131 | originalContent, 132 | }; 133 | 134 | // Create physical backup file if requested (backup the target, not the symlink) 135 | if (createBackups) { 136 | const backupPath = targetPath + backupSuffix; 137 | copyFileSync(targetPath, backupPath); 138 | backup.backupPath = backupPath; 139 | } 140 | 141 | backups.push(backup); 142 | 143 | // Apply edits to the file content 144 | const modifiedContent = applyEditsToContent(originalContent, edits, validateBeforeApply); 145 | 146 | // Write the modified content atomically to the target location 147 | const tempPath = `${targetPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; 148 | writeFileSync(tempPath, modifiedContent, 'utf-8'); 149 | 150 | // Atomic rename to replace the target file (not the symlink) 151 | try { 152 | renameSync(tempPath, targetPath); 153 | } catch (error) { 154 | // Clean up temp file if rename failed 155 | try { 156 | if (existsSync(tempPath)) { 157 | unlinkSync(tempPath); 158 | } 159 | } catch {} 160 | throw error; 161 | } 162 | 163 | // Report the original path as modified (what the user requested) 164 | filesModified.push(originalPath); 165 | 166 | // Sync the file with LSP server if client is provided 167 | // Use the original path (not target) for LSP sync since LSP tracks by requested path 168 | if (lspClient) { 169 | await lspClient.syncFileContent(originalPath); 170 | } 171 | } 172 | 173 | return { 174 | success: true, 175 | filesModified, 176 | backupFiles: backups 177 | .filter((b): b is FileBackup & { backupPath: string } => !!b.backupPath) 178 | .map((b) => b.backupPath), 179 | }; 180 | } catch (error) { 181 | // Rollback: restore original files from backups 182 | for (const backup of backups) { 183 | try { 184 | // Restore to the target path (the actual file, not the symlink) 185 | writeFileSync(backup.targetPath, backup.originalContent, 'utf-8'); 186 | } catch (rollbackError) { 187 | console.error(`Failed to rollback ${backup.targetPath}:`, rollbackError); 188 | } 189 | } 190 | 191 | // Clean up backup files after successful rollback 192 | for (const backup of backups) { 193 | if (backup.backupPath) { 194 | try { 195 | if (existsSync(backup.backupPath)) { 196 | unlinkSync(backup.backupPath); 197 | } 198 | } catch (cleanupError) { 199 | console.error(`Failed to clean up backup ${backup.backupPath}:`, cleanupError); 200 | } 201 | } 202 | } 203 | 204 | return { 205 | success: false, 206 | filesModified: [], 207 | backupFiles: [], 208 | error: error instanceof Error ? error.message : String(error), 209 | }; 210 | } 211 | } 212 | 213 | /** 214 | * Apply text edits to file content 215 | * @param content Original file content 216 | * @param edits List of edits to apply 217 | * @param validate Whether to validate edit positions 218 | * @returns Modified content 219 | */ 220 | function applyEditsToContent(content: string, edits: TextEdit[], validate: boolean): string { 221 | // Detect and preserve line ending style 222 | const lineEnding = content.includes('\r\n') ? '\r\n' : '\n'; 223 | 224 | // Split content into lines for easier manipulation 225 | // Handle both LF and CRLF 226 | const lines = content.split(/\r?\n/); 227 | 228 | // Sort edits in reverse order (bottom to top, right to left) 229 | // This ensures that earlier edits don't affect the positions of later edits 230 | const sortedEdits = [...edits].sort((a, b) => { 231 | if (a.range.start.line !== b.range.start.line) { 232 | return b.range.start.line - a.range.start.line; 233 | } 234 | return b.range.start.character - a.range.start.character; 235 | }); 236 | 237 | for (const edit of sortedEdits) { 238 | const { start, end } = edit.range; 239 | 240 | // Validate edit positions if requested 241 | if (validate) { 242 | if (start.line < 0 || start.line >= lines.length) { 243 | throw new Error(`Invalid start line ${start.line} (file has ${lines.length} lines)`); 244 | } 245 | if (end.line < 0 || end.line >= lines.length) { 246 | throw new Error(`Invalid end line ${end.line} (file has ${lines.length} lines)`); 247 | } 248 | 249 | // Validate start position is before end position 250 | if (start.line > end.line || (start.line === end.line && start.character > end.character)) { 251 | throw new Error( 252 | `Invalid range: start (${start.line}:${start.character}) is after end (${end.line}:${end.character})` 253 | ); 254 | } 255 | 256 | // Validate character bounds for start line 257 | const startLine = lines[start.line]; 258 | if (startLine !== undefined) { 259 | if (start.character < 0 || start.character > startLine.length) { 260 | throw new Error( 261 | `Invalid start character ${start.character} on line ${start.line} (line has ${startLine.length} characters)` 262 | ); 263 | } 264 | } 265 | 266 | // Validate character bounds for end line 267 | const endLine = lines[end.line]; 268 | if (endLine !== undefined) { 269 | if (end.character < 0 || end.character > endLine.length) { 270 | throw new Error( 271 | `Invalid end character ${end.character} on line ${end.line} (line has ${endLine.length} characters)` 272 | ); 273 | } 274 | } 275 | } 276 | 277 | // Apply the edit 278 | if (start.line === end.line) { 279 | // Single line edit 280 | const line = lines[start.line]; 281 | if (line !== undefined) { 282 | lines[start.line] = 283 | line.substring(0, start.character) + edit.newText + line.substring(end.character); 284 | } 285 | } else { 286 | // Multi-line edit 287 | const startLine = lines[start.line]; 288 | const endLine = lines[end.line]; 289 | 290 | if (startLine !== undefined && endLine !== undefined) { 291 | // Combine the parts with the new text 292 | const newLine = 293 | startLine.substring(0, start.character) + edit.newText + endLine.substring(end.character); 294 | 295 | // Replace the affected lines 296 | lines.splice(start.line, end.line - start.line + 1, newLine); 297 | } 298 | } 299 | } 300 | 301 | return lines.join(lineEnding); 302 | } 303 | 304 | /** 305 | * Clean up backup files created during editing 306 | * @param backupFiles List of backup file paths 307 | */ 308 | export function cleanupBackups(backupFiles: string[]): void { 309 | for (const backupPath of backupFiles) { 310 | try { 311 | if (existsSync(backupPath)) { 312 | unlinkSync(backupPath); 313 | } 314 | } catch (error) { 315 | console.error(`Failed to remove backup file ${backupPath}:`, error); 316 | } 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/mcp-tools.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, spyOn } from 'bun:test'; 2 | 3 | // Type definitions for test 4 | interface ToolArgs { 5 | file_path: string; 6 | line: number; 7 | character: number; 8 | use_zero_index?: boolean; 9 | include_declaration?: boolean; 10 | new_name?: string; 11 | } 12 | 13 | // Mock implementation for LSPClient 14 | class MockLSPClient { 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | findDefinition: any = spyOn({} as any, 'findDefinition').mockResolvedValue([]); 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | findReferences: any = spyOn({} as any, 'findReferences').mockResolvedValue([]); 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | renameSymbol: any = spyOn({} as any, 'renameSymbol').mockResolvedValue({ changes: {} }); 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | dispose: any = spyOn({} as any, 'dispose').mockImplementation(() => {}); 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | preloadServers: any = spyOn({} as any, 'preloadServers').mockResolvedValue(undefined); 25 | } 26 | 27 | // Tool handler function similar to main index.ts 28 | async function handleToolCall(name: string, args: ToolArgs, mockLspClient: MockLSPClient) { 29 | if (name === 'find_definition') { 30 | const { 31 | file_path, 32 | line, 33 | character, 34 | use_zero_index = false, 35 | } = args as { 36 | file_path: string; 37 | line: number; 38 | character: number; 39 | use_zero_index?: boolean; 40 | }; 41 | 42 | const adjustedLine = use_zero_index ? line : line - 1; 43 | const adjustedCharacter = use_zero_index ? character : character - 1; 44 | await mockLspClient.findDefinition('test.ts', { 45 | line: adjustedLine, 46 | character: adjustedCharacter, 47 | }); 48 | 49 | return { 50 | content: [ 51 | { 52 | type: 'text', 53 | text: `find_definition called with line: ${adjustedLine}`, 54 | }, 55 | ], 56 | }; 57 | } 58 | 59 | if (name === 'find_references') { 60 | const { 61 | file_path, 62 | line, 63 | character, 64 | include_declaration = true, 65 | use_zero_index = false, 66 | } = args as { 67 | file_path: string; 68 | line: number; 69 | character: number; 70 | include_declaration?: boolean; 71 | use_zero_index?: boolean; 72 | }; 73 | 74 | const adjustedLine = use_zero_index ? line : line - 1; 75 | const adjustedCharacter = use_zero_index ? character : character - 1; 76 | await mockLspClient.findReferences( 77 | 'test.ts', 78 | { line: adjustedLine, character: adjustedCharacter }, 79 | include_declaration 80 | ); 81 | 82 | return { 83 | content: [ 84 | { 85 | type: 'text', 86 | text: `find_references called with line: ${adjustedLine}`, 87 | }, 88 | ], 89 | }; 90 | } 91 | 92 | if (name === 'rename_symbol') { 93 | const { 94 | file_path, 95 | line, 96 | character, 97 | new_name, 98 | use_zero_index = false, 99 | } = args as { 100 | file_path: string; 101 | line: number; 102 | character: number; 103 | new_name: string; 104 | use_zero_index?: boolean; 105 | }; 106 | 107 | const adjustedLine = use_zero_index ? line : line - 1; 108 | const adjustedCharacter = use_zero_index ? character : character - 1; 109 | await mockLspClient.renameSymbol( 110 | 'test.ts', 111 | { line: adjustedLine, character: adjustedCharacter }, 112 | new_name || '' 113 | ); 114 | 115 | return { 116 | content: [ 117 | { 118 | type: 'text', 119 | text: `rename_symbol called with line: ${adjustedLine}`, 120 | }, 121 | ], 122 | }; 123 | } 124 | 125 | throw new Error(`Unknown tool: ${name}`); 126 | } 127 | 128 | describe('MCP Tools with use_zero_index option', () => { 129 | let mockLspClient: MockLSPClient; 130 | 131 | beforeEach(() => { 132 | // Create fresh mock instance 133 | mockLspClient = new MockLSPClient(); 134 | }); 135 | 136 | describe('find_definition', () => { 137 | it('should subtract 1 from line number when use_zero_index is false (default)', async () => { 138 | const response = await handleToolCall( 139 | 'find_definition', 140 | { 141 | file_path: 'test.ts', 142 | line: 5, 143 | character: 10, // 1-indexed input (gets converted to 9) 144 | }, 145 | mockLspClient 146 | ); 147 | 148 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 149 | line: 4, // 5 - 1 (1-indexed to 0-indexed) 150 | character: 9, // 10 - 1 (1-indexed to 0-indexed) 151 | }); 152 | 153 | expect(response.content[0]).toEqual({ 154 | type: 'text', 155 | text: 'find_definition called with line: 4', 156 | }); 157 | }); 158 | 159 | it('should use original line number when use_zero_index is true', async () => { 160 | const response = await handleToolCall( 161 | 'find_definition', 162 | { 163 | file_path: 'test.ts', 164 | line: 5, 165 | character: 10, // 1-indexed input 166 | use_zero_index: true, 167 | }, 168 | mockLspClient 169 | ); 170 | 171 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 172 | line: 5, // Original line number (0-indexed) 173 | character: 10, // Original character position (0-indexed) 174 | }); 175 | 176 | expect(response.content[0]).toEqual({ 177 | type: 'text', 178 | text: 'find_definition called with line: 5', 179 | }); 180 | }); 181 | 182 | it('should subtract 1 from line number when use_zero_index is explicitly false', async () => { 183 | const response = await handleToolCall( 184 | 'find_definition', 185 | { 186 | file_path: 'test.ts', 187 | line: 5, 188 | character: 10, // 1-indexed input 189 | use_zero_index: false, 190 | }, 191 | mockLspClient 192 | ); 193 | 194 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 195 | line: 4, // 5 - 1 (1-indexed to 0-indexed) 196 | character: 9, // 10 - 1 (1-indexed to 0-indexed) 197 | }); 198 | 199 | expect(response.content[0]).toEqual({ 200 | type: 'text', 201 | text: 'find_definition called with line: 4', 202 | }); 203 | }); 204 | }); 205 | 206 | describe('find_references', () => { 207 | it('should subtract 1 from line number when use_zero_index is false (default)', async () => { 208 | const response = await handleToolCall( 209 | 'find_references', 210 | { 211 | file_path: 'test.ts', 212 | line: 5, 213 | character: 10, // 1-indexed input 214 | }, 215 | mockLspClient 216 | ); 217 | 218 | expect(mockLspClient.findReferences).toHaveBeenCalledWith( 219 | 'test.ts', 220 | { 221 | line: 4, // 5 - 1 (1-indexed to 0-indexed) 222 | character: 9, // 10 - 1 (1-indexed to 0-indexed) 223 | }, 224 | true 225 | ); 226 | 227 | expect(response.content[0]).toEqual({ 228 | type: 'text', 229 | text: 'find_references called with line: 4', 230 | }); 231 | }); 232 | 233 | it('should use original line number when use_zero_index is true', async () => { 234 | const response = await handleToolCall( 235 | 'find_references', 236 | { 237 | file_path: 'test.ts', 238 | line: 5, 239 | character: 10, // 1-indexed input 240 | use_zero_index: true, 241 | include_declaration: false, 242 | }, 243 | mockLspClient 244 | ); 245 | 246 | expect(mockLspClient.findReferences).toHaveBeenCalledWith( 247 | 'test.ts', 248 | { 249 | line: 5, // Original line number (0-indexed) 250 | character: 10, // Original character position (0-indexed) 251 | }, 252 | false 253 | ); 254 | 255 | expect(response.content[0]).toEqual({ 256 | type: 'text', 257 | text: 'find_references called with line: 5', 258 | }); 259 | }); 260 | }); 261 | 262 | describe('rename_symbol', () => { 263 | it('should subtract 1 from line number when use_zero_index is false (default)', async () => { 264 | const response = await handleToolCall( 265 | 'rename_symbol', 266 | { 267 | file_path: 'test.ts', 268 | line: 5, 269 | character: 10, // 1-indexed input 270 | new_name: 'newSymbolName', 271 | }, 272 | mockLspClient 273 | ); 274 | 275 | expect(mockLspClient.renameSymbol).toHaveBeenCalledWith( 276 | 'test.ts', 277 | { 278 | line: 4, // 5 - 1 (1-indexed to 0-indexed) 279 | character: 9, // 10 - 1 (1-indexed to 0-indexed) 280 | }, 281 | 'newSymbolName' 282 | ); 283 | 284 | expect(response.content[0]).toEqual({ 285 | type: 'text', 286 | text: 'rename_symbol called with line: 4', 287 | }); 288 | }); 289 | 290 | it('should use original line number when use_zero_index is true', async () => { 291 | const response = await handleToolCall( 292 | 'rename_symbol', 293 | { 294 | file_path: 'test.ts', 295 | line: 5, 296 | character: 10, 297 | new_name: 'newSymbolName', 298 | use_zero_index: true, 299 | }, 300 | mockLspClient 301 | ); 302 | 303 | expect(mockLspClient.renameSymbol).toHaveBeenCalledWith( 304 | 'test.ts', 305 | { 306 | line: 5, // Original line number (0-indexed) 307 | character: 10, // Original character position (0-indexed) 308 | }, 309 | 'newSymbolName' 310 | ); 311 | 312 | expect(response.content[0]).toEqual({ 313 | type: 'text', 314 | text: 'rename_symbol called with line: 5', 315 | }); 316 | }); 317 | }); 318 | 319 | describe('edge cases', () => { 320 | it('should handle line 0 with use_zero_index correctly', async () => { 321 | const response = await handleToolCall( 322 | 'find_definition', 323 | { 324 | file_path: 'test.ts', 325 | line: 0, 326 | character: 0, 327 | use_zero_index: true, 328 | }, 329 | mockLspClient 330 | ); 331 | 332 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 333 | line: 0, // Original 0-indexed input 334 | character: 0, // Original 0-indexed input 335 | }); 336 | 337 | expect(response.content[0]).toEqual({ 338 | type: 'text', 339 | text: 'find_definition called with line: 0', 340 | }); 341 | }); 342 | 343 | it('should handle line 1 with default behavior correctly (converts to 0)', async () => { 344 | const response = await handleToolCall( 345 | 'find_definition', 346 | { 347 | file_path: 'test.ts', 348 | line: 1, 349 | character: 0, 350 | }, 351 | mockLspClient 352 | ); 353 | 354 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 355 | line: 0, // 1 - 1 (1-indexed to 0-indexed) 356 | character: -1, // 0 - 1 (1-indexed to 0-indexed) 357 | }); 358 | 359 | expect(response.content[0]).toEqual({ 360 | type: 'text', 361 | text: 'find_definition called with line: 0', 362 | }); 363 | }); 364 | 365 | it('should handle character indexing with default behavior (converts to 0-indexed)', async () => { 366 | await handleToolCall( 367 | 'find_definition', 368 | { 369 | file_path: 'test.ts', 370 | line: 1, 371 | character: 5, // 1-indexed input 372 | }, 373 | mockLspClient 374 | ); 375 | 376 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 377 | line: 0, // 1 - 1 (1-indexed to 0-indexed) 378 | character: 4, // 5 - 1 (1-indexed to 0-indexed) 379 | }); 380 | }); 381 | 382 | it('should handle character indexing with use_zero_index=true (no conversion)', async () => { 383 | await handleToolCall( 384 | 'find_definition', 385 | { 386 | file_path: 'test.ts', 387 | line: 1, 388 | character: 5, // 0-indexed input 389 | use_zero_index: true, 390 | }, 391 | mockLspClient 392 | ); 393 | 394 | expect(mockLspClient.findDefinition).toHaveBeenCalledWith('test.ts', { 395 | line: 1, // Original 0-indexed input 396 | character: 5, // Original 0-indexed input 397 | }); 398 | }); 399 | 400 | it('should handle character 1 with default behavior (converts to 0)', async () => { 401 | await handleToolCall( 402 | 'find_references', 403 | { 404 | file_path: 'test.ts', 405 | line: 2, 406 | character: 1, // 1-indexed input 407 | }, 408 | mockLspClient 409 | ); 410 | 411 | expect(mockLspClient.findReferences).toHaveBeenCalledWith( 412 | 'test.ts', 413 | { 414 | line: 1, // 2 - 1 (1-indexed to 0-indexed) 415 | character: 0, // 1 - 1 (1-indexed to 0-indexed) 416 | }, 417 | true 418 | ); 419 | }); 420 | 421 | it('should handle character 0 with use_zero_index=true', async () => { 422 | await handleToolCall( 423 | 'rename_symbol', 424 | { 425 | file_path: 'test.ts', 426 | line: 0, 427 | character: 0, // 0-indexed input 428 | new_name: 'test', 429 | use_zero_index: true, 430 | }, 431 | mockLspClient 432 | ); 433 | 434 | expect(mockLspClient.renameSymbol).toHaveBeenCalledWith( 435 | 'test.ts', 436 | { 437 | line: 0, // Original 0-indexed input 438 | character: 0, // Original 0-indexed input 439 | }, 440 | 'test' 441 | ); 442 | }); 443 | }); 444 | }); 445 | -------------------------------------------------------------------------------- /src/file-editor.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; 2 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; 3 | import { join } from 'node:path'; 4 | import { applyWorkspaceEdit, cleanupBackups } from './file-editor.js'; 5 | import { pathToUri } from './utils.js'; 6 | 7 | const TEST_DIR = process.env.RUNNER_TEMP 8 | ? `${process.env.RUNNER_TEMP}/file-editor-test` 9 | : '/tmp/file-editor-test'; 10 | 11 | describe('file-editor', () => { 12 | beforeEach(() => { 13 | // Clean up and create test directory 14 | if (existsSync(TEST_DIR)) { 15 | rmSync(TEST_DIR, { recursive: true, force: true }); 16 | } 17 | mkdirSync(TEST_DIR, { recursive: true }); 18 | }); 19 | 20 | afterEach(() => { 21 | // Clean up test directory 22 | if (existsSync(TEST_DIR)) { 23 | rmSync(TEST_DIR, { recursive: true, force: true }); 24 | } 25 | }); 26 | 27 | describe('applyWorkspaceEdit', () => { 28 | it('should apply a single edit to a file', async () => { 29 | const filePath = join(TEST_DIR, 'test.ts'); 30 | const originalContent = 'const oldName = 42;\nconsole.log(oldName);'; 31 | writeFileSync(filePath, originalContent); 32 | 33 | const result = await applyWorkspaceEdit({ 34 | changes: { 35 | [pathToUri(filePath)]: [ 36 | { 37 | range: { 38 | start: { line: 0, character: 6 }, 39 | end: { line: 0, character: 13 }, 40 | }, 41 | newText: 'newName', 42 | }, 43 | ], 44 | }, 45 | }); 46 | 47 | expect(result.success).toBe(true); 48 | expect(result.filesModified).toEqual([filePath]); 49 | 50 | const modifiedContent = readFileSync(filePath, 'utf-8'); 51 | expect(modifiedContent).toBe('const newName = 42;\nconsole.log(oldName);'); 52 | }); 53 | 54 | it('should apply multiple edits to the same file', async () => { 55 | const filePath = join(TEST_DIR, 'test.ts'); 56 | const originalContent = 'const foo = 1;\nconst bar = foo + foo;\nconsole.log(foo);'; 57 | writeFileSync(filePath, originalContent); 58 | 59 | const result = await applyWorkspaceEdit({ 60 | changes: { 61 | [pathToUri(filePath)]: [ 62 | { 63 | range: { 64 | start: { line: 0, character: 6 }, 65 | end: { line: 0, character: 9 }, 66 | }, 67 | newText: 'baz', 68 | }, 69 | { 70 | range: { 71 | start: { line: 1, character: 12 }, 72 | end: { line: 1, character: 15 }, 73 | }, 74 | newText: 'baz', 75 | }, 76 | { 77 | range: { 78 | start: { line: 1, character: 18 }, 79 | end: { line: 1, character: 21 }, 80 | }, 81 | newText: 'baz', 82 | }, 83 | { 84 | range: { 85 | start: { line: 2, character: 12 }, 86 | end: { line: 2, character: 15 }, 87 | }, 88 | newText: 'baz', 89 | }, 90 | ], 91 | }, 92 | }); 93 | 94 | expect(result.success).toBe(true); 95 | 96 | const modifiedContent = readFileSync(filePath, 'utf-8'); 97 | expect(modifiedContent).toBe('const baz = 1;\nconst bar = baz + baz;\nconsole.log(baz);'); 98 | }); 99 | 100 | it('should handle multi-line edits', async () => { 101 | const filePath = join(TEST_DIR, 'test.ts'); 102 | const originalContent = 'function oldFunc() {\n return 42;\n}\n\noldFunc();'; 103 | writeFileSync(filePath, originalContent); 104 | 105 | const result = await applyWorkspaceEdit({ 106 | changes: { 107 | [pathToUri(filePath)]: [ 108 | { 109 | range: { 110 | start: { line: 0, character: 9 }, 111 | end: { line: 0, character: 16 }, 112 | }, 113 | newText: 'newFunc', 114 | }, 115 | { 116 | range: { 117 | start: { line: 4, character: 0 }, 118 | end: { line: 4, character: 7 }, 119 | }, 120 | newText: 'newFunc', 121 | }, 122 | ], 123 | }, 124 | }); 125 | 126 | expect(result.success).toBe(true); 127 | 128 | const modifiedContent = readFileSync(filePath, 'utf-8'); 129 | expect(modifiedContent).toBe('function newFunc() {\n return 42;\n}\n\nnewFunc();'); 130 | }); 131 | 132 | it('should handle edits across multiple files', async () => { 133 | const file1 = join(TEST_DIR, 'file1.ts'); 134 | const file2 = join(TEST_DIR, 'file2.ts'); 135 | 136 | writeFileSync(file1, 'export const oldName = 42;'); 137 | writeFileSync(file2, 'import { oldName } from "./file1";\nconsole.log(oldName);'); 138 | 139 | const result = await applyWorkspaceEdit({ 140 | changes: { 141 | [pathToUri(file1)]: [ 142 | { 143 | range: { 144 | start: { line: 0, character: 13 }, 145 | end: { line: 0, character: 20 }, 146 | }, 147 | newText: 'newName', 148 | }, 149 | ], 150 | [pathToUri(file2)]: [ 151 | { 152 | range: { 153 | start: { line: 0, character: 9 }, 154 | end: { line: 0, character: 16 }, 155 | }, 156 | newText: 'newName', 157 | }, 158 | { 159 | range: { 160 | start: { line: 1, character: 12 }, 161 | end: { line: 1, character: 19 }, 162 | }, 163 | newText: 'newName', 164 | }, 165 | ], 166 | }, 167 | }); 168 | 169 | expect(result.success).toBe(true); 170 | expect(result.filesModified.length).toBe(2); 171 | 172 | const content1 = readFileSync(file1, 'utf-8'); 173 | const content2 = readFileSync(file2, 'utf-8'); 174 | expect(content1).toBe('export const newName = 42;'); 175 | expect(content2).toBe('import { newName } from "./file1";\nconsole.log(newName);'); 176 | }); 177 | 178 | it('should create backup files when requested', async () => { 179 | const filePath = join(TEST_DIR, 'test.ts'); 180 | const originalContent = 'const oldName = 42;'; 181 | writeFileSync(filePath, originalContent); 182 | 183 | const result = await applyWorkspaceEdit( 184 | { 185 | changes: { 186 | [pathToUri(filePath)]: [ 187 | { 188 | range: { 189 | start: { line: 0, character: 6 }, 190 | end: { line: 0, character: 13 }, 191 | }, 192 | newText: 'newName', 193 | }, 194 | ], 195 | }, 196 | }, 197 | { createBackups: true } 198 | ); 199 | 200 | expect(result.success).toBe(true); 201 | expect(result.backupFiles.length).toBe(1); 202 | const backupFile = result.backupFiles[0]; 203 | expect(backupFile).toBeDefined(); 204 | if (backupFile) { 205 | expect(existsSync(backupFile)).toBe(true); 206 | const backupContent = readFileSync(backupFile, 'utf-8'); 207 | expect(backupContent).toBe(originalContent); 208 | } 209 | }); 210 | 211 | it('should skip backup creation when disabled', async () => { 212 | const filePath = join(TEST_DIR, 'test.ts'); 213 | writeFileSync(filePath, 'const oldName = 42;'); 214 | 215 | const result = await applyWorkspaceEdit( 216 | { 217 | changes: { 218 | [pathToUri(filePath)]: [ 219 | { 220 | range: { 221 | start: { line: 0, character: 6 }, 222 | end: { line: 0, character: 13 }, 223 | }, 224 | newText: 'newName', 225 | }, 226 | ], 227 | }, 228 | }, 229 | { createBackups: false } 230 | ); 231 | 232 | expect(result.success).toBe(true); 233 | expect(result.backupFiles.length).toBe(0); 234 | expect(existsSync(`${filePath}.bak`)).toBe(false); 235 | }); 236 | 237 | it('should validate edit positions when requested', async () => { 238 | const filePath = join(TEST_DIR, 'test.ts'); 239 | writeFileSync(filePath, 'const x = 1;'); 240 | 241 | const result = await applyWorkspaceEdit( 242 | { 243 | changes: { 244 | [pathToUri(filePath)]: [ 245 | { 246 | range: { 247 | start: { line: 5, character: 0 }, // Invalid line 248 | end: { line: 5, character: 5 }, 249 | }, 250 | newText: 'invalid', 251 | }, 252 | ], 253 | }, 254 | }, 255 | { validateBeforeApply: true } 256 | ); 257 | 258 | expect(result.success).toBe(false); 259 | expect(result.error).toContain('Invalid start line'); 260 | }); 261 | 262 | it('should rollback changes on failure', async () => { 263 | const file1 = join(TEST_DIR, 'file1.ts'); 264 | const file2 = join(TEST_DIR, 'file2.ts'); 265 | 266 | const originalContent1 = 'const x = 1;'; 267 | const originalContent2 = 'const y = 2;'; 268 | 269 | writeFileSync(file1, originalContent1); 270 | writeFileSync(file2, originalContent2); 271 | 272 | // Make file2 invalid to cause failure 273 | const result = await applyWorkspaceEdit( 274 | { 275 | changes: { 276 | [pathToUri(file1)]: [ 277 | { 278 | range: { 279 | start: { line: 0, character: 6 }, 280 | end: { line: 0, character: 7 }, 281 | }, 282 | newText: 'a', 283 | }, 284 | ], 285 | [pathToUri(file2)]: [ 286 | { 287 | range: { 288 | start: { line: 10, character: 0 }, // Invalid line 289 | end: { line: 10, character: 5 }, 290 | }, 291 | newText: 'invalid', 292 | }, 293 | ], 294 | }, 295 | }, 296 | { validateBeforeApply: true } 297 | ); 298 | 299 | expect(result.success).toBe(false); 300 | 301 | // Check that file1 was rolled back to original content 302 | const content1 = readFileSync(file1, 'utf-8'); 303 | expect(content1).toBe(originalContent1); 304 | }); 305 | 306 | it('should handle empty files', async () => { 307 | const filePath = join(TEST_DIR, 'empty.ts'); 308 | writeFileSync(filePath, ''); 309 | 310 | const result = await applyWorkspaceEdit({ 311 | changes: { 312 | [pathToUri(filePath)]: [ 313 | { 314 | range: { 315 | start: { line: 0, character: 0 }, 316 | end: { line: 0, character: 0 }, 317 | }, 318 | newText: 'const x = 1;', 319 | }, 320 | ], 321 | }, 322 | }); 323 | 324 | expect(result.success).toBe(true); 325 | 326 | const content = readFileSync(filePath, 'utf-8'); 327 | expect(content).toBe('const x = 1;'); 328 | }); 329 | 330 | it('should handle files with different line endings', async () => { 331 | const filePath = join(TEST_DIR, 'crlf.ts'); 332 | // File with CRLF line endings (without trailing newline) 333 | writeFileSync(filePath, 'const x = 1;\r\nconst y = 2;'); 334 | 335 | const result = await applyWorkspaceEdit({ 336 | changes: { 337 | [pathToUri(filePath)]: [ 338 | { 339 | range: { 340 | start: { line: 0, character: 6 }, 341 | end: { line: 0, character: 7 }, 342 | }, 343 | newText: 'a', 344 | }, 345 | ], 346 | }, 347 | }); 348 | 349 | expect(result.success).toBe(true); 350 | 351 | const content = readFileSync(filePath, 'utf-8'); 352 | // Our implementation now preserves line endings 353 | expect(content).toBe('const a = 1;\r\nconst y = 2;'); 354 | }); 355 | 356 | it('should handle unicode content', async () => { 357 | const filePath = join(TEST_DIR, 'unicode.ts'); 358 | const originalContent = 'const 你好 = "世界";\nconsole.log(你好);'; 359 | writeFileSync(filePath, originalContent); 360 | 361 | const result = await applyWorkspaceEdit({ 362 | changes: { 363 | [pathToUri(filePath)]: [ 364 | { 365 | range: { 366 | start: { line: 0, character: 6 }, 367 | end: { line: 0, character: 8 }, 368 | }, 369 | newText: '世界', 370 | }, 371 | { 372 | range: { 373 | start: { line: 1, character: 12 }, 374 | end: { line: 1, character: 14 }, 375 | }, 376 | newText: '世界', 377 | }, 378 | ], 379 | }, 380 | }); 381 | 382 | expect(result.success).toBe(true); 383 | 384 | const content = readFileSync(filePath, 'utf-8'); 385 | expect(content).toBe('const 世界 = "世界";\nconsole.log(世界);'); 386 | }); 387 | 388 | it('should fail gracefully for non-existent files', async () => { 389 | const filePath = join(TEST_DIR, 'non-existent.ts'); 390 | 391 | const result = await applyWorkspaceEdit({ 392 | changes: { 393 | [pathToUri(filePath)]: [ 394 | { 395 | range: { 396 | start: { line: 0, character: 0 }, 397 | end: { line: 0, character: 0 }, 398 | }, 399 | newText: 'test', 400 | }, 401 | ], 402 | }, 403 | }); 404 | 405 | expect(result.success).toBe(false); 406 | expect(result.error).toContain('File does not exist'); 407 | }); 408 | 409 | it('should handle no changes gracefully', async () => { 410 | const result = await applyWorkspaceEdit({ 411 | changes: {}, 412 | }); 413 | 414 | expect(result.success).toBe(true); 415 | expect(result.filesModified).toEqual([]); 416 | expect(result.backupFiles).toEqual([]); 417 | }); 418 | }); 419 | 420 | describe('cleanupBackups', () => { 421 | it('should remove backup files', () => { 422 | const backup1 = join(TEST_DIR, 'file1.ts.bak'); 423 | const backup2 = join(TEST_DIR, 'file2.ts.bak'); 424 | 425 | writeFileSync(backup1, 'backup content 1'); 426 | writeFileSync(backup2, 'backup content 2'); 427 | 428 | expect(existsSync(backup1)).toBe(true); 429 | expect(existsSync(backup2)).toBe(true); 430 | 431 | cleanupBackups([backup1, backup2]); 432 | 433 | expect(existsSync(backup1)).toBe(false); 434 | expect(existsSync(backup2)).toBe(false); 435 | }); 436 | 437 | it('should handle non-existent backup files gracefully', () => { 438 | const nonExistent = join(TEST_DIR, 'non-existent.bak'); 439 | 440 | // Should not throw 441 | cleanupBackups([nonExistent]); 442 | }); 443 | }); 444 | }); 445 | -------------------------------------------------------------------------------- /src/multi-position.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, spyOn } from 'bun:test'; 2 | 3 | // Type definitions for test 4 | interface ToolArgs { 5 | file_path: string; 6 | line: number; 7 | character: number; 8 | include_declaration?: boolean; 9 | new_name?: string; 10 | } 11 | 12 | // Mock implementation for LSPClient with position-based results 13 | class MockLSPClient { 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | findDefinition: any = spyOn({} as any, 'findDefinition'); 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | findReferences: any = spyOn({} as any, 'findReferences'); 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | renameSymbol: any = spyOn({} as any, 'renameSymbol'); 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | dispose: any = spyOn({} as any, 'dispose').mockImplementation(() => {}); 22 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 23 | preloadServers: any = spyOn({} as any, 'preloadServers').mockResolvedValue(undefined); 24 | 25 | // Helper to simulate different results for different positions 26 | setPositionBasedResults(results: Record) { 27 | this.findDefinition.mockImplementation( 28 | (filePath: string, position: { line: number; character: number }) => { 29 | const key = `${position.line}:${position.character}`; 30 | return Promise.resolve(results[key] || []); 31 | } 32 | ); 33 | 34 | this.findReferences.mockImplementation( 35 | ( 36 | filePath: string, 37 | position: { line: number; character: number }, 38 | includeDeclaration: boolean 39 | ) => { 40 | const key = `${position.line}:${position.character}`; 41 | return Promise.resolve(results[key] || []); 42 | } 43 | ); 44 | 45 | this.renameSymbol.mockImplementation( 46 | (filePath: string, position: { line: number; character: number }, newName: string) => { 47 | const key = `${position.line}:${position.character}`; 48 | const locations = results[key] || []; 49 | if (locations.length > 0) { 50 | return Promise.resolve({ 51 | changes: { 52 | 'file:///test.ts': locations.map((loc) => ({ 53 | range: loc.range, 54 | newText: newName, 55 | })), 56 | }, 57 | }); 58 | } 59 | return Promise.resolve({}); 60 | } 61 | ); 62 | } 63 | } 64 | 65 | // Tool handler function similar to main index.ts with multi-position logic 66 | async function handleMultiPositionToolCall( 67 | name: string, 68 | args: ToolArgs, 69 | mockLspClient: MockLSPClient 70 | ) { 71 | const { file_path, line, character } = args; 72 | 73 | if (name === 'find_definition') { 74 | // Try multiple position combinations 75 | const positionCandidates = [ 76 | { 77 | line: line - 1, 78 | character: character - 1, 79 | description: `line-1/character-1 (${line - 1}:${character - 1})`, 80 | }, 81 | { 82 | line: line, 83 | character: character - 1, 84 | description: `line/character-1 (${line}:${character - 1})`, 85 | }, 86 | { 87 | line: line - 1, 88 | character: character, 89 | description: `line-1/character (${line - 1}:${character})`, 90 | }, 91 | { line: line, character: character, description: `line/character (${line}:${character})` }, 92 | ]; 93 | 94 | const results = []; 95 | for (const candidate of positionCandidates) { 96 | try { 97 | const locations = await mockLspClient.findDefinition('test.ts', { 98 | line: candidate.line, 99 | character: candidate.character, 100 | }); 101 | 102 | if (locations.length > 0) { 103 | const locationResults = locations 104 | .map((loc: any) => { 105 | const filePath = loc.uri.replace('file://', ''); 106 | const { start } = loc.range; 107 | return `${filePath}:${start.line + 1}:${start.character + 1}`; 108 | }) 109 | .join('\n'); 110 | 111 | results.push(`Results for ${candidate.description}:\n${locationResults}`); 112 | } 113 | } catch (error) { 114 | // Continue trying other positions if one fails 115 | } 116 | } 117 | 118 | if (results.length === 0) { 119 | return { 120 | content: [ 121 | { 122 | type: 'text', 123 | text: `No definition found at any position variation around line ${line}, character ${character}. Please verify the symbol location and ensure the language server is properly configured.`, 124 | }, 125 | ], 126 | }; 127 | } 128 | 129 | return { 130 | content: [ 131 | { 132 | type: 'text', 133 | text: results.join('\n\n'), 134 | }, 135 | ], 136 | }; 137 | } 138 | 139 | if (name === 'find_references') { 140 | const { include_declaration = true } = args; 141 | 142 | // Try multiple position combinations 143 | const positionCandidates = [ 144 | { 145 | line: line - 1, 146 | character: character - 1, 147 | description: `line-1/character-1 (${line - 1}:${character - 1})`, 148 | }, 149 | { 150 | line: line, 151 | character: character - 1, 152 | description: `line/character-1 (${line}:${character - 1})`, 153 | }, 154 | { 155 | line: line - 1, 156 | character: character, 157 | description: `line-1/character (${line - 1}:${character})`, 158 | }, 159 | { line: line, character: character, description: `line/character (${line}:${character})` }, 160 | ]; 161 | 162 | const results = []; 163 | for (const candidate of positionCandidates) { 164 | try { 165 | const locations = await mockLspClient.findReferences( 166 | 'test.ts', 167 | { line: candidate.line, character: candidate.character }, 168 | include_declaration 169 | ); 170 | 171 | if (locations.length > 0) { 172 | const locationResults = locations 173 | .map((loc: any) => { 174 | const filePath = loc.uri.replace('file://', ''); 175 | const { start } = loc.range; 176 | return `${filePath}:${start.line + 1}:${start.character + 1}`; 177 | }) 178 | .join('\n'); 179 | 180 | results.push(`Results for ${candidate.description}:\n${locationResults}`); 181 | } 182 | } catch (error) { 183 | // Continue trying other positions if one fails 184 | } 185 | } 186 | 187 | if (results.length === 0) { 188 | return { 189 | content: [ 190 | { 191 | type: 'text', 192 | text: `No references found at any position variation around line ${line}, character ${character}. Please verify the symbol location and ensure the language server is properly configured.`, 193 | }, 194 | ], 195 | }; 196 | } 197 | 198 | return { 199 | content: [ 200 | { 201 | type: 'text', 202 | text: results.join('\n\n'), 203 | }, 204 | ], 205 | }; 206 | } 207 | 208 | if (name === 'rename_symbol') { 209 | const { new_name } = args; 210 | if (!new_name) { 211 | throw new Error('new_name is required for rename_symbol'); 212 | } 213 | 214 | // Try multiple position combinations 215 | const positionCandidates = [ 216 | { 217 | line: line - 1, 218 | character: character - 1, 219 | description: `line-1/character-1 (${line - 1}:${character - 1})`, 220 | }, 221 | { 222 | line: line, 223 | character: character - 1, 224 | description: `line/character-1 (${line}:${character - 1})`, 225 | }, 226 | { 227 | line: line - 1, 228 | character: character, 229 | description: `line-1/character (${line - 1}:${character})`, 230 | }, 231 | { line: line, character: character, description: `line/character (${line}:${character})` }, 232 | ]; 233 | 234 | const results = []; 235 | for (const candidate of positionCandidates) { 236 | try { 237 | const workspaceEdit = await mockLspClient.renameSymbol( 238 | 'test.ts', 239 | { line: candidate.line, character: candidate.character }, 240 | new_name 241 | ); 242 | 243 | if (workspaceEdit?.changes && Object.keys(workspaceEdit.changes).length > 0) { 244 | const changes = []; 245 | for (const [uri, edits] of Object.entries(workspaceEdit.changes)) { 246 | const filePath = uri.replace('file://', ''); 247 | changes.push(`File: ${filePath}`); 248 | for (const edit of edits as any[]) { 249 | const { start, end } = edit.range; 250 | changes.push( 251 | ` - Line ${start.line + 1}, Column ${start.character + 1} to Line ${end.line + 1}, Column ${end.character + 1}: "${edit.newText}"` 252 | ); 253 | } 254 | } 255 | 256 | results.push(`Results for ${candidate.description}:\n${changes.join('\n')}`); 257 | } 258 | } catch (error) { 259 | // Continue trying other positions if one fails 260 | } 261 | } 262 | 263 | if (results.length === 0) { 264 | return { 265 | content: [ 266 | { 267 | type: 'text', 268 | text: `No rename edits available at any position variation around line ${line}, character ${character}. Please verify the symbol location and ensure the language server is properly configured.`, 269 | }, 270 | ], 271 | }; 272 | } 273 | 274 | return { 275 | content: [ 276 | { 277 | type: 'text', 278 | text: results.join('\n\n'), 279 | }, 280 | ], 281 | }; 282 | } 283 | 284 | throw new Error(`Unknown tool: ${name}`); 285 | } 286 | 287 | describe('Multi-Position Tool Calls', () => { 288 | let mockLspClient: MockLSPClient; 289 | 290 | beforeEach(() => { 291 | mockLspClient = new MockLSPClient(); 292 | }); 293 | 294 | describe('find_definition', () => { 295 | it('should find results in one position variation and return it', async () => { 296 | // Mock: only position (4, 9) has results, others return empty 297 | const mockResults = { 298 | '4:9': [ 299 | { 300 | uri: 'file:///test.ts', 301 | range: { 302 | start: { line: 10, character: 5 }, 303 | end: { line: 10, character: 15 }, 304 | }, 305 | }, 306 | ], 307 | }; 308 | mockLspClient.setPositionBasedResults(mockResults); 309 | 310 | const response = await handleMultiPositionToolCall( 311 | 'find_definition', 312 | { 313 | file_path: 'test.ts', 314 | line: 5, 315 | character: 10, 316 | }, 317 | mockLspClient 318 | ); 319 | 320 | expect(response.content[0]?.text).toContain('Results for line-1/character-1 (4:9)'); 321 | expect(response.content[0]?.text).toContain('/test.ts:11:6'); 322 | }); 323 | 324 | it('should find results in multiple position variations and return all', async () => { 325 | // Mock: two different positions have results 326 | const mockResults = { 327 | '4:9': [ 328 | { 329 | uri: 'file:///test.ts', 330 | range: { 331 | start: { line: 10, character: 5 }, 332 | end: { line: 10, character: 15 }, 333 | }, 334 | }, 335 | ], 336 | '5:9': [ 337 | { 338 | uri: 'file:///other.ts', 339 | range: { 340 | start: { line: 15, character: 8 }, 341 | end: { line: 15, character: 18 }, 342 | }, 343 | }, 344 | ], 345 | }; 346 | mockLspClient.setPositionBasedResults(mockResults); 347 | 348 | const response = await handleMultiPositionToolCall( 349 | 'find_definition', 350 | { 351 | file_path: 'test.ts', 352 | line: 5, 353 | character: 10, 354 | }, 355 | mockLspClient 356 | ); 357 | 358 | expect(response.content[0]?.text).toContain('Results for line-1/character-1 (4:9)'); 359 | expect(response.content[0]?.text).toContain('/test.ts:11:6'); 360 | expect(response.content[0]?.text).toContain('Results for line/character-1 (5:9)'); 361 | expect(response.content[0]?.text).toContain('/other.ts:16:9'); 362 | }); 363 | 364 | it('should return error message when no position variations have results', async () => { 365 | mockLspClient.setPositionBasedResults({}); // No results for any position 366 | 367 | const response = await handleMultiPositionToolCall( 368 | 'find_definition', 369 | { 370 | file_path: 'test.ts', 371 | line: 5, 372 | character: 10, 373 | }, 374 | mockLspClient 375 | ); 376 | 377 | expect(response.content[0]?.text).toBe( 378 | 'No definition found at any position variation around line 5, character 10. Please verify the symbol location and ensure the language server is properly configured.' 379 | ); 380 | }); 381 | }); 382 | 383 | describe('find_references', () => { 384 | it('should find references in position variations', async () => { 385 | const mockResults = { 386 | '4:10': [ 387 | { 388 | uri: 'file:///test.ts', 389 | range: { 390 | start: { line: 20, character: 3 }, 391 | end: { line: 20, character: 13 }, 392 | }, 393 | }, 394 | ], 395 | }; 396 | mockLspClient.setPositionBasedResults(mockResults); 397 | 398 | const response = await handleMultiPositionToolCall( 399 | 'find_references', 400 | { 401 | file_path: 'test.ts', 402 | line: 5, 403 | character: 11, 404 | include_declaration: false, 405 | }, 406 | mockLspClient 407 | ); 408 | 409 | expect(response.content[0]?.text).toContain('Results for line-1/character-1 (4:10)'); 410 | expect(response.content[0]?.text).toContain('/test.ts:21:4'); 411 | }); 412 | 413 | it('should return error message when no references found', async () => { 414 | mockLspClient.setPositionBasedResults({}); 415 | 416 | const response = await handleMultiPositionToolCall( 417 | 'find_references', 418 | { 419 | file_path: 'test.ts', 420 | line: 5, 421 | character: 10, 422 | }, 423 | mockLspClient 424 | ); 425 | 426 | expect(response.content[0]?.text).toBe( 427 | 'No references found at any position variation around line 5, character 10. Please verify the symbol location and ensure the language server is properly configured.' 428 | ); 429 | }); 430 | }); 431 | 432 | describe('rename_symbol', () => { 433 | it('should generate rename edits for position variations', async () => { 434 | const mockResults = { 435 | '5:10': [ 436 | { 437 | uri: 'file:///test.ts', 438 | range: { 439 | start: { line: 5, character: 10 }, 440 | end: { line: 5, character: 20 }, 441 | }, 442 | }, 443 | ], 444 | }; 445 | mockLspClient.setPositionBasedResults(mockResults); 446 | 447 | const response = await handleMultiPositionToolCall( 448 | 'rename_symbol', 449 | { 450 | file_path: 'test.ts', 451 | line: 6, 452 | character: 11, 453 | new_name: 'newSymbolName', 454 | }, 455 | mockLspClient 456 | ); 457 | 458 | expect(response.content[0]?.text).toContain('Results for line-1/character-1 (5:10)'); 459 | expect(response.content[0]?.text).toContain('File: /test.ts'); 460 | expect(response.content[0]?.text).toContain( 461 | 'Line 6, Column 11 to Line 6, Column 21: "newSymbolName"' 462 | ); 463 | }); 464 | 465 | it('should return error message when no rename edits available', async () => { 466 | mockLspClient.setPositionBasedResults({}); 467 | 468 | const response = await handleMultiPositionToolCall( 469 | 'rename_symbol', 470 | { 471 | file_path: 'test.ts', 472 | line: 5, 473 | character: 10, 474 | new_name: 'newName', 475 | }, 476 | mockLspClient 477 | ); 478 | 479 | expect(response.content[0]?.text).toBe( 480 | 'No rename edits available at any position variation around line 5, character 10. Please verify the symbol location and ensure the language server is properly configured.' 481 | ); 482 | }); 483 | }); 484 | 485 | describe('error handling', () => { 486 | it('should continue trying other positions when one throws an error', async () => { 487 | // Mock: first position throws error, second has results 488 | mockLspClient.findDefinition.mockImplementation( 489 | (filePath: string, position: { line: number; character: number }) => { 490 | const key = `${position.line}:${position.character}`; 491 | if (key === '4:9') { 492 | throw new Error('LSP Error'); 493 | } 494 | if (key === '5:9') { 495 | return Promise.resolve([ 496 | { 497 | uri: 'file:///test.ts', 498 | range: { 499 | start: { line: 10, character: 5 }, 500 | end: { line: 10, character: 15 }, 501 | }, 502 | }, 503 | ]); 504 | } 505 | return Promise.resolve([]); 506 | } 507 | ); 508 | 509 | const response = await handleMultiPositionToolCall( 510 | 'find_definition', 511 | { 512 | file_path: 'test.ts', 513 | line: 5, 514 | character: 10, 515 | }, 516 | mockLspClient 517 | ); 518 | 519 | expect(response.content[0]?.text).toContain('Results for line/character-1 (5:9)'); 520 | expect(response.content[0]?.text).toContain('/test.ts:11:6'); 521 | }); 522 | }); 523 | }); 524 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [0.6.1] - 2025-10-20 9 | 10 | ### Enhanced 11 | 12 | - **find_references Tool Documentation**: Clarified tool descriptions and parameters 13 | - Updated `find_references` description to emphasize workspace-wide search capability 14 | - Clarified `file_path` parameter to indicate it's where the symbol is defined 15 | - Improved accuracy of MCP tool documentation 16 | 17 | ## [0.6.0] - 2025-10-06 18 | 19 | ### Added 20 | 21 | - **Server Adapter System**: Internal adapter pattern for LSP servers with non-standard behavior 22 | - **Vue Language Server Adapter**: Handles custom `tsserver/request` protocol 23 | - Responds to `_vue:projectInfo` requests to unblock server operations 24 | - Extended timeouts: 60s for documentSymbol, 45s for definition/references/rename 25 | - Auto-detected when command contains `vue-language-server` or `@vue/language-server` 26 | - **Pyright Adapter**: Extended timeouts for large Python projects 27 | - 45-60s timeouts for operations that analyze many files 28 | - Auto-detected when command contains `pyright` or `basedpyright` 29 | - Zero configuration required - adapters automatically detected based on server command 30 | - Internal use only (not user-extensible) to maintain stability and security 31 | - Comprehensive test coverage with 34 new tests 32 | 33 | ### Fixed 34 | 35 | - **Vue Language Server Timeout**: Fixed timeout errors with vue-language-server (#18) 36 | - Custom protocol handler prevents 30-second timeout on `textDocument/documentSymbol` 37 | - Improved compatibility with Vue 3 projects using TypeScript 38 | 39 | ## [0.5.13] - 2025-08-30 40 | 41 | ### Added 42 | 43 | - **JAR File Language Support**: Added language ID mapping for JAR and Java class files 44 | - `.jar` files now properly mapped to Java language ID 45 | - `.class` files now properly mapped to Java language ID 46 | - Enables LSP features for JAR files when Java LSP server is configured 47 | 48 | ## [0.5.12] - 2025-08-25 49 | 50 | ### Added 51 | 52 | - **InitializationOptions Support**: Added support for passing LSP server initialization options (#15 by @colinmollenhour) 53 | - New `initializationOptions` field in server configuration for LSP-specific settings 54 | - Enables passing settings like `pylsp.plugins.pycodestyle.enabled` for Python Language Server 55 | - Improves LSP server compatibility with servers requiring specific initialization configuration 56 | 57 | ### Fixed 58 | 59 | - **MCP Command Execution**: Fixed argument order and escaping for Claude CLI integration 60 | - Corrected command argument ordering for proper MCP server registration 61 | - Fixed path escaping issues with spaces in configuration paths 62 | - Improved cross-platform compatibility for Windows, macOS, and Linux 63 | 64 | ## [0.5.10] - 2025-08-22 65 | 66 | ### Fixed 67 | 68 | - **MCP Command Argument Order**: Fixed `claude mcp add` command argument order 69 | 70 | - Corrected to: `claude mcp add cclsp [args...] --env ` 71 | - Server name and command are now properly positioned as positional arguments 72 | - Options are placed after the command as required by the CLI 73 | - Resolves "missing required argument 'commandOrUrl'" error 74 | 75 | - **Path Escaping on Non-Windows Platforms**: Fixed path handling for spaces 76 | - Windows: Continues to use quotes for paths with spaces 77 | - macOS/Linux: Now escapes spaces with backslashes instead of quotes 78 | - Ensures proper path handling across all platforms 79 | 80 | ## [0.5.7] - 2025-08-22 81 | 82 | ### Fixed 83 | 84 | - **Claude CLI Fallback**: Setup script now falls back to `npx @anthropic-ai/claude-code@latest` when Claude CLI is not installed 85 | 86 | - Automatically detects if `claude` command is available 87 | - Uses npx to run Claude commands without requiring global installation 88 | - Improves setup experience for users without Claude CLI installed 89 | 90 | - **MCP Command Syntax**: Fixed incorrect argument order in MCP add command 91 | 92 | - Options (`--env`, `--scope`) now correctly placed before server name 93 | - Resolves "unknown option '--env'" error 94 | - Commands now follow proper Claude MCP CLI syntax 95 | 96 | - **Platform-specific Path Quoting**: Fixed config path quoting based on platform (#14) 97 | - Windows: Paths with spaces are quoted in environment variables 98 | - macOS/Linux: Paths are not quoted to avoid literal quotes in values 99 | - Resolves "Config file specified in CCLSP_CONFIG_PATH does not exist" error on Unix systems 100 | 101 | ### Enhanced 102 | 103 | - **Setup Robustness**: Improved error handling and fallback mechanisms 104 | - Better detection of Claude CLI availability 105 | - Clear messaging when falling back to npx 106 | - Consistent behavior across all MCP operations (list, remove, add) 107 | 108 | ## [0.5.6] - 2025-08-20 109 | 110 | ### Enhanced 111 | 112 | - **Path Quoting**: Always quote configuration paths for improved safety 113 | - Paths are now always quoted regardless of spaces 114 | - Better handling of special characters in file paths 115 | - Improved cross-platform compatibility 116 | 117 | ### Added 118 | 119 | - **Execution Tests**: Added comprehensive command execution tests for CI 120 | - Real command execution simulation with `echo` 121 | - Verification that quoted paths work correctly in actual execution 122 | - Integration tests for MCP command structure 123 | - New test scripts: `test:execution` and `test:all` 124 | 125 | ### Fixed 126 | 127 | - **Path Resolution**: Fixed absolute path detection for Windows drive letters 128 | - Correctly handles paths like `C:\Program Files\...` 129 | - Prevents unnecessary path resolution for already absolute paths 130 | 131 | ## [0.5.5] - 2025-08-20 132 | 133 | ### Fixed 134 | 135 | - **Windows Support**: Fixed setup script to properly handle Windows environments 136 | - Added `cmd /c` prefix for npx commands on Windows platform 137 | - Ensures correct MCP configuration command generation across all platforms 138 | - Added comprehensive test coverage for Windows-specific behavior 139 | 140 | ## [0.5.4] - 2025-08-18 141 | 142 | ### Added 143 | 144 | - **File Editing Capability**: Complete transformation of rename operations from preview-only to actual file modification (PR #13 by @secondcircle) 145 | - Atomic file operations with automatic backup and rollback support 146 | - Symlink handling - correctly resolves and edits target files 147 | - Multi-file workspace edits for complex rename operations across multiple files 148 | - Comprehensive validation for file existence, permissions, and types 149 | - `dry_run` parameter for safe preview mode on both `rename_symbol` and `rename_symbol_strict` 150 | 151 | ### Enhanced 152 | 153 | - **LSP Server Synchronization**: Improved file synchronization after edits 154 | - All modified files are properly synced with LSP servers after edits 155 | - Version tracking for proper LSP protocol compliance 156 | - Auto-open files that weren't previously opened get opened and synced automatically 157 | 158 | ### Fixed 159 | 160 | - **Multi-file Rename Operations**: Now actually applies rename changes across all affected files instead of just returning preview 161 | - **LSP Document Synchronization**: Fixed sync issues with files modified by rename operations 162 | 163 | ### Testing 164 | 165 | - Added comprehensive test suite for file editing functionality (100+ test cases) 166 | - Implemented CI workarounds for environment-specific test issues 167 | 168 | ### Acknowledgements 169 | 170 | Special thanks to @secondcircle for the major enhancement that transforms cclsp from a read-only query tool into a functional refactoring tool with actual file editing capabilities (#13). This change significantly improves the user experience from preview-only to actually applying changes. 171 | 172 | ## [0.5.3] - 2025-08-16 173 | 174 | ### Fixed 175 | 176 | - **Rename Operations**: Fixed rename operations with modern LSP servers like gopls that use DocumentChanges format (PR #11 by @secondcircle) 177 | - Now properly handles both WorkspaceEdit and DocumentChanges response formats 178 | - Improved compatibility with language servers using the newer LSP specification 179 | 180 | ### Documentation 181 | 182 | - Updated MCP tools documentation to match current implementation 183 | - Added MseeP.ai badge to README (PR #4 by @lwsinclair) 184 | 185 | ### Acknowledgements 186 | 187 | Special thanks to the contributors of recent enhancements and fixes. 188 | 189 | - @secondcircle for fixing the critical rename operation issue with modern LSP servers (#11) 190 | - @lwsinclair for adding the MseeP.ai badge to improve project visibility (#4) 191 | - @maschwenk for the rootDir preloading fix in the previous release (#5) 192 | 193 | Your contributions help make cclsp better for everyone! 🙏 194 | 195 | ## [0.5.2] - 2025-08-04 196 | 197 | ### Added 198 | 199 | - **Manual Server Restart**: Added `restart_server` MCP tool for manually restarting LSP servers 200 | - Restart specific servers by file extension (e.g., `["ts", "tsx"]`) 201 | - Restart all running servers when no extensions specified 202 | - Detailed success/failure reporting for each server 203 | 204 | ### Enhanced 205 | 206 | - **Server Management**: Improved LSP server lifecycle management with proper cleanup of restart timers 207 | 208 | ### Fixed 209 | 210 | - **Server Preloading**: Fixed server preloading to respect `rootDir` configuration (PR #5 by @maschwenk) 211 | - Now correctly scans each server's configured directory instead of using project root 212 | 213 | ## [0.5.1] - 2025-07-14 214 | 215 | ### Enhanced 216 | 217 | - **Improved Diagnostic Idle Detection**: Added intelligent idle detection for publishDiagnostics notifications 218 | - Tracks diagnostic versions and update timestamps to determine when LSP servers are idle 219 | - Ensures all diagnostics are received before returning results 220 | - **Optimized MCP Timeouts**: Adjusted wait times for better reliability in MCP usage 221 | - Initial diagnostics: 5 seconds (previously 2 seconds) 222 | - After changes: 3 seconds (previously 1.5 seconds) 223 | - Idle detection: 300ms (previously 200ms) 224 | 225 | ### Fixed 226 | 227 | - Fixed Windows path handling in diagnostics tests by using `path.resolve()` consistently 228 | 229 | ## [0.5.0] - 2025-07-14 230 | 231 | ### Added 232 | 233 | - **PublishDiagnostics Support**: Added support for push-based diagnostics (textDocument/publishDiagnostics) in addition to pull-based diagnostics 234 | - **Diagnostic Caching**: Implemented caching for diagnostics received via publishDiagnostics notifications 235 | - **Fallback Mechanism**: Added automatic fallback to trigger diagnostics generation for servers that don't support pull-based diagnostics 236 | 237 | ### Enhanced 238 | 239 | - Improved compatibility with language servers like gopls that primarily use publishDiagnostics 240 | - Better diagnostic retrieval with multiple strategies: cached diagnostics, pull request, and triggered generation 241 | 242 | ## [0.4.4] - 2025-07-10 243 | 244 | ### Fixed 245 | 246 | - **LSP Server Initialization**: Improved initialization handling to properly wait for server's initialized notification 247 | - **Setup Script Improvements**: Fixed Claude command detection to use local installation when global command is not available 248 | - **Type Safety**: Replaced `any` types with proper type annotations (NodeJS.ErrnoException) 249 | 250 | ### Enhanced 251 | 252 | - Better error handling in setup script with more descriptive error messages 253 | - More robust process spawning with proper error event handling 254 | 255 | ## [0.4.3] - 2025-06-30 256 | 257 | ### Added 258 | 259 | - **Vue.js Language Server Support**: Added official Vue.js language server (Volar) configuration 260 | - **Svelte Language Server Support**: Added Svelte language server configuration 261 | - Support for `.vue` and `.svelte` file extensions in setup wizard 262 | - Installation guides and auto-install commands for Vue.js and Svelte language servers 263 | 264 | ### Maintenance 265 | 266 | - Cleaned up temporary test files (`test-example.ts`, `test-mcp.mjs`, `test-rename.ts`) 267 | 268 | ## [0.4.2] - 2025-06-29 269 | 270 | ### Added 271 | 272 | - **LSP Server Auto-Restart**: Added `restartInterval` option to server configuration for automatic LSP server restarts to prevent long-running server degradation 273 | - Configurable restart intervals in minutes with minimum 0.1 minute (6 seconds) for testing 274 | - Comprehensive test coverage for restart functionality including timer setup, configuration validation, and cleanup 275 | 276 | ### Enhanced 277 | 278 | - Improved LSP server stability for long-running sessions, particularly beneficial for Python Language Server (pylsp) 279 | - Updated documentation with configuration examples and restart interval guidelines 280 | - **Setup Wizard Improvements**: Enhanced file extension detection with comprehensive .gitignore support 281 | - Improved project structure scanning to exclude common build artifacts, dependencies, and temporary files 282 | - Better accuracy in detecting project's primary programming languages for LSP server configuration 283 | 284 | ## [0.4.1] - 2025-06-28 285 | 286 | ### Added 287 | 288 | - **Intelligent symbol kind fallback**: When a specific `symbol_kind` is specified but no matches are found, automatically search all symbol types and return results with descriptive warning messages 289 | - Enhanced user experience for LLM-based tools that may specify incorrect symbol kinds 290 | - Comprehensive test coverage for all fallback scenarios 291 | 292 | ### Fixed 293 | 294 | - Improved robustness of symbol searches when exact kind matches are not available 295 | 296 | ## [0.4.0] - 2025-06-28 297 | 298 | ### Changed 299 | 300 | - **BREAKING**: Complete redesign of MCP tool API from position-based to symbol name/kind-based lookup 301 | - `find_definition` now accepts `symbol_name` and `symbol_kind` instead of `line` and `character` 302 | - `find_references` now accepts `symbol_name` and `symbol_kind` instead of `line` and `character` 303 | - `rename_symbol` now accepts `symbol_name` and `symbol_kind` instead of `line` and `character` 304 | - Enhanced LSP stderr forwarding directly to MCP stderr for better debugging 305 | - Improved position accuracy for `SymbolInformation` with file content analysis 306 | 307 | ### Added 308 | 309 | - `textDocument/documentSymbol` LSP functionality for comprehensive symbol discovery 310 | - Automatic symbol matching by name and kind for improved LLM accuracy 311 | - `rename_symbol_strict` tool for precise position-based renaming when multiple matches exist 312 | - Symbol kind validation with helpful error messages listing valid options 313 | - Comprehensive debug logging throughout the symbol resolution pipeline 314 | - File content analysis for precise symbol position detection in `SymbolInformation` 315 | - Enhanced pylsp configuration with jedi plugin settings for Python support 316 | - Invalid symbol kind warnings embedded in response text instead of breaking execution 317 | 318 | ### Fixed 319 | 320 | - Position accuracy issues with Python Language Server (pylsp) symbol detection 321 | - Character position estimation for better symbol name targeting 322 | 323 | ## [0.3.5] - 2025-06-28 324 | 325 | ### Changed 326 | 327 | - **BREAKING**: Removed `use_zero_index` option from all MCP tools 328 | - Tools now automatically try multiple position combinations (line±1, character±1) to handle different indexing conventions 329 | - Enhanced error messages with better debugging information 330 | - Results show which position combination was successful 331 | 332 | ### Added 333 | 334 | - Multi-position symbol resolution for better compatibility with different editors and LSP implementations 335 | - Comprehensive test suite for multi-position functionality 336 | 337 | ## [0.3.4] - 2025-06-28 338 | 339 | ### Fixed 340 | 341 | - Fixed setup command to use `npx cclsp@latest` instead of `npx cclsp` for MCP configuration 342 | - Updated all documentation to consistently use `npx cclsp@latest` for better version control 343 | 344 | ## [0.3.3] - 2025-06-28 345 | 346 | ### Changed 347 | 348 | - MCP tools now use 1-based indexing by default for both line and character positions 349 | - Tool parameter `character` now defaults to 1-indexed (human-readable) instead of 0-indexed 350 | - Added `use_zero_index` parameter to all tools for backward compatibility with 0-based indexing 351 | - Updated tool descriptions to clearly indicate indexing behavior 352 | 353 | ### Added 354 | 355 | - Comprehensive test coverage for 1-based and 0-based indexing behavior 356 | - Character position conversion tests for all MCP tools 357 | - Edge case testing for character indexing boundaries 358 | 359 | ## [0.3.2] - 2025-06-27 360 | 361 | ### Fixed 362 | 363 | - Improved CI/CD version detection for npm publishing 364 | - Replaced git-based version change detection with npm registry comparison 365 | - Enhanced logging for version comparison process in CI workflow 366 | 367 | ## [0.3.1] - 2025-06-27 368 | 369 | ### Fixed 370 | 371 | - `npx cclsp@latest setup` command now executes properly without hanging 372 | - Setup subcommand execution flow and error handling 373 | - Eliminated duplicate execution when running setup via `node dist/index.js setup` 374 | - Streamlined build process by removing separate setup.js compilation 375 | 376 | ## [0.3.0] 377 | 378 | ### Added 379 | 380 | - Interactive configuration generator with `cclsp setup` command 381 | - Support for 15 language servers (TypeScript, Python, Go, Rust, C/C++, Java, Ruby, PHP, C#, Swift, Kotlin, Dart, Elixir, Haskell, Lua) 382 | - Emacs-style keyboard navigation (Ctrl+P/Ctrl+N) for setup interface 383 | - Automatic installation instructions display for selected language servers 384 | - Configuration file preview and validation 385 | - Comprehensive test suite for setup functionality 386 | - GitHub issue templates for bug reports, feature requests, language support, and questions 387 | - `CONTRIBUTING.md` with detailed contribution guidelines 388 | - `CODE_OF_CONDUCT.md` following Contributor Covenant 389 | - `SECURITY.md` with security policy and reporting guidelines 390 | - `ROADMAP.md` outlining project vision and planned features 391 | - GitHub Actions CI/CD pipeline for automated testing and npm publishing 392 | - Additional badges in README (CI status, npm downloads, PRs welcome) 393 | - Comprehensive troubleshooting section in README 394 | - Real-world usage examples in README 395 | 396 | ### Changed 397 | 398 | - Enhanced README with better structure and more detailed documentation 399 | - Improved project metadata for better npm discoverability 400 | 401 | ## [0.2.1] 402 | 403 | ### Added 404 | 405 | - `rename_symbol` MCP tool for refactoring symbols across codebases 406 | - Enhanced error handling for LSP server failures 407 | 408 | ### Changed 409 | 410 | - Improved documentation clarity for tool outputs 411 | - Better type safety in tool interfaces 412 | 413 | ## [0.2.0] 414 | 415 | ### Added 416 | 417 | - npm publishing configuration 418 | - Executable binary support (`cclsp` command) 419 | - Proper package.json metadata 420 | - Installation instructions in README 421 | 422 | ### Changed 423 | 424 | - Project renamed from `lsmcp` to `cclsp` for better clarity 425 | - Updated all references and documentation 426 | 427 | ## [0.1.0] 428 | 429 | ### Added 430 | 431 | - Initial implementation of MCP server for LSP functionality 432 | - `find_definition` tool for locating symbol definitions 433 | - `find_references` tool for finding all symbol references 434 | - Support for multiple language servers via configuration 435 | - TypeScript language server as default 436 | - Basic error handling and logging 437 | - Test suite with Bun 438 | - Documentation for setup and usage 439 | 440 | [0.6.1]: https://github.com/ktnyt/cclsp/compare/v0.6.0...v0.6.1 441 | [0.2.1]: https://github.com/ktnyt/cclsp/compare/v0.2.0...v0.2.1 442 | [0.2.0]: https://github.com/ktnyt/cclsp/compare/v0.1.0...v0.2.0 443 | [0.1.0]: https://github.com/ktnyt/cclsp/releases/tag/v0.1.0 444 | --------------------------------------------------------------------------------