mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat: add refactoring tools and cleanup search
- Add comprehensive refactor tool with rename, extract_function, extract_variable, inline, move, and organize_imports actions - Rename unified_search.py to search_tool.py for cleaner naming - Make vector search opt-in (not required by default) - Fix KeyError in ripgrep JSON output parsing - Update LSP tool to use pyright for Python (faster than pylsp) - Add 26 tests for refactor tool, all passing - Bump version to 0.9.8 The refactor tool provides LSP/AST-powered code transformations: - Rename symbols across entire codebase - Extract code blocks to functions - Extract expressions to variables - Inline variables at all usage sites - Move symbols between files - Organize import statements
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Demo of the unified search tool - THE primary search interface.
|
||||
|
||||
This demonstrates how the unified search tool intelligently combines multiple
|
||||
search modalities to find anything in your codebase.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from hanzo_mcp.tools.search import create_unified_search_tool
|
||||
|
||||
|
||||
async def demo_unified_search():
|
||||
"""Demonstrate unified search capabilities."""
|
||||
print("=== Hanzo Unified Search Demo ===\n")
|
||||
|
||||
# Create the search tool
|
||||
search = create_unified_search_tool()
|
||||
|
||||
# Demo 1: Simple text search
|
||||
print("1. Simple text search for 'UnifiedSearch':")
|
||||
result = await search.run(pattern="UnifiedSearch", max_results_per_type=3)
|
||||
|
||||
if result.data and "results" in result.data:
|
||||
print(f" Found {len(result.data['results'])} results")
|
||||
print(f" Search types used: {', '.join(result.data['statistics']['search_types_used'])}")
|
||||
for r in result.data["results"][:3]:
|
||||
print(f" - {r['file']}:{r['line']} ({r['type']})")
|
||||
print()
|
||||
|
||||
# Demo 2: Natural language query
|
||||
print("2. Natural language query:")
|
||||
result = await search.run(pattern="how does pagination work in search results", max_results_per_type=3)
|
||||
|
||||
if result.data:
|
||||
stats = result.data["statistics"]
|
||||
print(f" Search types: {', '.join(stats['search_types_used'])}")
|
||||
print(f" Total matches: {stats['total_matches']}")
|
||||
print(" Note: Vector search would be used if embedding model is available")
|
||||
print()
|
||||
|
||||
# Demo 3: Code pattern search
|
||||
print("3. Code pattern search for class definitions:")
|
||||
result = await search.run(
|
||||
pattern="class.*Tool", # Regex for Tool classes
|
||||
max_results_per_type=5,
|
||||
)
|
||||
|
||||
if result.data and "results" in result.data:
|
||||
print(f" Found {len(result.data['results'])} Tool classes")
|
||||
unique_files = set(r["file"] for r in result.data["results"])
|
||||
print(f" Across {len(unique_files)} files")
|
||||
print()
|
||||
|
||||
# Demo 4: File search
|
||||
print("4. Find Python test files:")
|
||||
result = await search.run(pattern="test_*.py", search_files=True, max_results_per_type=5)
|
||||
|
||||
if result.data:
|
||||
stats = result.data["statistics"]
|
||||
if "files" in stats["search_types_used"]:
|
||||
file_results = [r for r in result.data["results"] if r["type"] == "file"]
|
||||
print(f" Found {len(file_results)} test files")
|
||||
for r in file_results[:3]:
|
||||
print(f" - {r['file']} ({r['context']['semantic']})")
|
||||
print()
|
||||
|
||||
# Demo 5: Symbol search
|
||||
print("5. Symbol/identifier search:")
|
||||
result = await search.run(
|
||||
pattern="BaseTool", # Looking for the BaseTool class
|
||||
max_results_per_type=3,
|
||||
)
|
||||
|
||||
if result.data:
|
||||
# Should find symbol definitions
|
||||
symbol_results = [r for r in result.data["results"] if r.get("context", {}).get("node_type")]
|
||||
if symbol_results:
|
||||
print(f" Found {len(symbol_results)} symbol matches")
|
||||
else:
|
||||
print(f" Found {len(result.data['results'])} text matches")
|
||||
print()
|
||||
|
||||
# Demo 6: Combined search with pagination
|
||||
print("6. Paginated results demo:")
|
||||
result = await search.run(pattern="async def", page_size=10, page=1)
|
||||
|
||||
if result.data and "pagination" in result.data:
|
||||
pag = result.data["pagination"]
|
||||
print(f" Page {pag['page']} of {pag['total_pages']}")
|
||||
print(f" Showing {len(result.data['results'])} of {pag['total_results']} total results")
|
||||
print(f" Has next page: {pag['has_next']}")
|
||||
print()
|
||||
|
||||
# Demo 7: Search with filters
|
||||
print("7. Filtered search (Python files only):")
|
||||
result = await search.run(pattern="import", include="*.py", max_results_per_type=5)
|
||||
|
||||
if result.data:
|
||||
print(f" Found {len(result.data['results'])} import statements in Python files")
|
||||
stats = result.data["statistics"]
|
||||
print(f" Search completed in {stats['time_ms'].get('text', 0)}ms")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("\n=== Search Intelligence ===")
|
||||
print("The unified search tool automatically:")
|
||||
print("- Detects query intent (code vs natural language)")
|
||||
print("- Runs appropriate search types in parallel")
|
||||
print("- Combines and ranks results by relevance")
|
||||
print("- Deduplicates across search types")
|
||||
print("- Provides context and semantic information")
|
||||
print("- Handles pagination for large result sets")
|
||||
print("\nIt's THE primary search interface for finding anything!")
|
||||
|
||||
|
||||
async def demo_find_tool():
|
||||
"""Demonstrate the find tool for fast file discovery."""
|
||||
print("\n\n=== Find Tool Demo ===\n")
|
||||
|
||||
from hanzo_mcp.tools.search import create_find_tool
|
||||
|
||||
find = create_find_tool()
|
||||
|
||||
# Demo 1: Find by pattern
|
||||
print("1. Find all Python files:")
|
||||
result = await find.run(pattern="*.py", max_results=5)
|
||||
|
||||
if result.data and "results" in result.data:
|
||||
print(f" Found {result.data['statistics']['total_found']} Python files")
|
||||
print(f" Search time: {result.data['statistics']['search_time_ms']}ms")
|
||||
print()
|
||||
|
||||
# Demo 2: Find with size filter
|
||||
print("2. Find large files (>10KB):")
|
||||
result = await find.run(pattern="*", min_size="10KB", max_results=5)
|
||||
|
||||
if result.data and "results" in result.data:
|
||||
for r in result.data["results"][:3]:
|
||||
size_kb = r["size"] / 1024
|
||||
print(f" - {r['name']} ({size_kb:.1f} KB)")
|
||||
print()
|
||||
|
||||
# Demo 3: Find recently modified
|
||||
print("3. Find recently modified files:")
|
||||
result = await find.run(pattern="*", modified_after="1 hour ago", max_results=5)
|
||||
|
||||
if result.data and "results" in result.data:
|
||||
print(f" Found {len(result.data['results'])} recently modified files")
|
||||
|
||||
print("\nFind tool is optimized for fast file discovery by name and attributes!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run demos
|
||||
asyncio.run(demo_unified_search())
|
||||
asyncio.run(demo_find_tool())
|
||||
@@ -51,7 +51,6 @@
|
||||
"grep_ast": true,
|
||||
"git_search": true,
|
||||
"batch_search": true,
|
||||
"unified_search": true,
|
||||
"symbols": true,
|
||||
"mode": true,
|
||||
"vector": true,
|
||||
@@ -137,7 +136,7 @@
|
||||
"aider", "cline", "openhands", "oh", "hanzo_dev", "think", "critic"
|
||||
],
|
||||
"search": [
|
||||
"grep", "grep_ast", "ast", "search", "unified_search", "symbols", "mode",
|
||||
"grep", "grep_ast", "ast", "search", "symbols", "mode",
|
||||
"git_search", "batch_search", "vector", "node", "vector_search", "vector_index", "find_files"
|
||||
],
|
||||
"process": [
|
||||
|
||||
@@ -152,7 +152,7 @@ batch(
|
||||
|
||||
1. **Use batch for parallel operations**: When you need to run multiple independent operations, use batch to run them concurrently.
|
||||
|
||||
2. **Combine search tools**: Use unified_search for comprehensive results, grep for simple text matching, and grep_ast for code structure understanding.
|
||||
2. **Combine search tools**: Use search for comprehensive results, grep for simple text matching, and grep_ast for code structure understanding.
|
||||
|
||||
3. **Leverage agents for complex tasks**: Use dispatch_agent for tasks requiring deep analysis or multiple steps.
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ try: # pragma: no cover
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
from hanzo_mcp.tools.common.tool_enable import ToolEnableTool
|
||||
from hanzo_mcp.tools.common.tool_disable import ToolDisableTool
|
||||
from hanzo_mcp.tools.common.plugin_loader import load_user_plugins
|
||||
|
||||
# Try memory tools
|
||||
try:
|
||||
@@ -78,6 +79,9 @@ except Exception:
|
||||
def apply_environment_from_mode():
|
||||
pass
|
||||
|
||||
def load_user_plugins():
|
||||
return {}
|
||||
|
||||
|
||||
# Try to import LSP tool
|
||||
try:
|
||||
@@ -87,6 +91,14 @@ try:
|
||||
except ImportError:
|
||||
LSP_TOOL_AVAILABLE = False
|
||||
|
||||
# Try to import refactor tool
|
||||
try:
|
||||
from hanzo_mcp.tools.refactor import RefactorTool, create_refactor_tool
|
||||
|
||||
REFACTOR_TOOL_AVAILABLE = True
|
||||
except ImportError:
|
||||
REFACTOR_TOOL_AVAILABLE = False
|
||||
|
||||
|
||||
def register_all_tools(
|
||||
mcp_server: FastMCP,
|
||||
@@ -183,7 +195,7 @@ def register_all_tools(
|
||||
"vector_search": is_tool_enabled("vector_search", False),
|
||||
}
|
||||
|
||||
# Create project manager if vector tools, batch_search, or unified_search are enabled
|
||||
# Create project manager if vector tools, batch_search, or search are enabled
|
||||
if (
|
||||
any(vector_enabled.values())
|
||||
or filesystem_enabled.get("batch_search", False)
|
||||
@@ -475,6 +487,15 @@ def register_all_tools(
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register LSP tool: {e}")
|
||||
|
||||
# Register refactor tool if enabled
|
||||
if is_tool_enabled("refactor", True) and REFACTOR_TOOL_AVAILABLE:
|
||||
try:
|
||||
tool = create_refactor_tool()
|
||||
tool.register(mcp_server)
|
||||
all_tools[tool.name] = tool
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register refactor tool: {e}")
|
||||
|
||||
# Register user plugins last (so they can override built-in tools)
|
||||
for plugin_name, plugin in plugins.items():
|
||||
if is_tool_enabled(plugin_name, True):
|
||||
|
||||
@@ -28,14 +28,14 @@ from hanzo_mcp.tools.filesystem.content_replace import ContentReplaceTool
|
||||
try:
|
||||
from hanzo_mcp.tools.search import (
|
||||
FindTool,
|
||||
UnifiedSearch,
|
||||
SearchTool,
|
||||
create_find_tool,
|
||||
create_unified_search_tool,
|
||||
create_search_tool,
|
||||
)
|
||||
|
||||
UNIFIED_SEARCH_AVAILABLE = True
|
||||
SEARCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
UNIFIED_SEARCH_AVAILABLE = False
|
||||
SEARCH_AVAILABLE = False
|
||||
|
||||
# Export all tool classes
|
||||
__all__ = [
|
||||
@@ -87,8 +87,8 @@ def get_read_only_filesystem_tools(
|
||||
tools.append(SearchTool(permission_manager, project_manager))
|
||||
|
||||
# Add new search tools if available
|
||||
if UNIFIED_SEARCH_AVAILABLE:
|
||||
tools.extend([create_unified_search_tool(), create_find_tool()])
|
||||
if SEARCH_AVAILABLE:
|
||||
tools.extend([create_search_tool(), create_find_tool()])
|
||||
|
||||
return tools
|
||||
|
||||
@@ -124,8 +124,8 @@ def get_filesystem_tools(permission_manager: PermissionManager, project_manager=
|
||||
tools.append(SearchTool(permission_manager, project_manager))
|
||||
|
||||
# Add new search tools if available
|
||||
if UNIFIED_SEARCH_AVAILABLE:
|
||||
tools.extend([create_unified_search_tool(), create_find_tool()])
|
||||
if SEARCH_AVAILABLE:
|
||||
tools.extend([create_search_tool(), create_find_tool()])
|
||||
|
||||
return tools
|
||||
|
||||
@@ -171,8 +171,8 @@ def register_filesystem_tools(
|
||||
}
|
||||
|
||||
# Add new search tools if available
|
||||
if UNIFIED_SEARCH_AVAILABLE:
|
||||
tool_classes["unified_search"] = lambda pm: create_unified_search_tool()
|
||||
if SEARCH_AVAILABLE:
|
||||
tool_classes["search"] = lambda pm: create_search_tool()
|
||||
tool_classes["find"] = lambda pm: create_find_tool()
|
||||
|
||||
tools = []
|
||||
@@ -188,7 +188,7 @@ def register_filesystem_tools(
|
||||
elif tool_name == "watch":
|
||||
# Watch tool is a singleton
|
||||
tools.append(tool_class(permission_manager))
|
||||
elif tool_name in ["unified_search", "find"]:
|
||||
elif tool_name in ["search", "find"]:
|
||||
# New search tools are factory functions
|
||||
tools.append(tool_class(permission_manager))
|
||||
else:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
|
||||
"""Language Server Protocol (LSP) tool for code intelligence.
|
||||
|
||||
This tool provides on-demand LSP configuration and installation for various
|
||||
@@ -9,13 +7,16 @@ rename symbol, and diagnostics.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import shutil
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
import subprocess
|
||||
import atexit
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from hanzo_mcp.types import MCPResourceDocument
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
@@ -39,12 +40,12 @@ LSP_SERVERS = {
|
||||
],
|
||||
},
|
||||
"python": {
|
||||
"name": "pylsp",
|
||||
"install_cmd": ["pip", "install", "python-lsp-server[all]"],
|
||||
"check_cmd": ["pylsp", "--version"],
|
||||
"start_cmd": ["pylsp"],
|
||||
"root_markers": ["pyproject.toml", "setup.py", "requirements.txt"],
|
||||
"file_extensions": [".py"],
|
||||
"name": "pyright",
|
||||
"install_cmd": ["npm", "install", "-g", "pyright"],
|
||||
"check_cmd": ["pyright-langserver", "--version"],
|
||||
"start_cmd": ["pyright-langserver", "--stdio"],
|
||||
"root_markers": ["pyproject.toml", "setup.py", "requirements.txt", "pyrightconfig.json"],
|
||||
"file_extensions": [".py", ".pyi"],
|
||||
"capabilities": [
|
||||
"definition",
|
||||
"references",
|
||||
@@ -52,6 +53,7 @@ LSP_SERVERS = {
|
||||
"diagnostics",
|
||||
"hover",
|
||||
"completion",
|
||||
"typeDefinition",
|
||||
],
|
||||
},
|
||||
"typescript": {
|
||||
@@ -156,9 +158,43 @@ LSP_SERVERS = {
|
||||
"completion",
|
||||
],
|
||||
},
|
||||
"solidity": {
|
||||
"name": "solc",
|
||||
"install_cmd": ["npm", "install", "-g", "solc"],
|
||||
"check_cmd": ["solc", "--version"],
|
||||
"start_cmd": ["solc", "--lsp"],
|
||||
"root_markers": ["foundry.toml", "hardhat.config.js", "hardhat.config.ts", "truffle-config.js"],
|
||||
"file_extensions": [".sol"],
|
||||
"capabilities": [
|
||||
"definition",
|
||||
"references",
|
||||
"diagnostics",
|
||||
"hover",
|
||||
"completion",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Global LSP server registry - singleton per language:root_uri
|
||||
_GLOBAL_SERVERS: Dict[str, "LSPServer"] = {}
|
||||
_GLOBAL_LOCK = asyncio.Lock()
|
||||
_CLEANUP_REGISTERED = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _cleanup_all_servers():
|
||||
"""Cleanup all LSP servers on process exit."""
|
||||
for server in _GLOBAL_SERVERS.values():
|
||||
if server.process and server.process.returncode is None:
|
||||
try:
|
||||
server.process.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
_GLOBAL_SERVERS.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class LSPServer:
|
||||
"""Represents an LSP server instance."""
|
||||
@@ -168,6 +204,14 @@ class LSPServer:
|
||||
config: Dict[str, Any]
|
||||
root_uri: str
|
||||
initialized: bool = False
|
||||
request_id: int = field(default=0)
|
||||
pending_responses: Dict[int, asyncio.Future] = field(default_factory=dict)
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
def next_id(self) -> int:
|
||||
"""Get next request ID."""
|
||||
self.request_id += 1
|
||||
return self.request_id
|
||||
|
||||
|
||||
class LSPTool(BaseTool):
|
||||
@@ -222,8 +266,11 @@ class LSPTool(BaseTool):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.servers: Dict[str, LSPServer] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
# Use global servers registry for persistence across tool instances
|
||||
global _CLEANUP_REGISTERED
|
||||
if not _CLEANUP_REGISTERED:
|
||||
atexit.register(_cleanup_all_servers)
|
||||
_CLEANUP_REGISTERED = True
|
||||
|
||||
def _get_language_from_file(self, file_path: str) -> Optional[str]:
|
||||
"""Detect language from file extension."""
|
||||
@@ -270,13 +317,13 @@ class LSPTool(BaseTool):
|
||||
if not config:
|
||||
return False
|
||||
|
||||
self.logger.info(f"Installing {config['name']} for {language}")
|
||||
logger.info(f"Installing {config['name']} for {language}")
|
||||
|
||||
try:
|
||||
# Check if installer is available
|
||||
installer = config["install_cmd"][0]
|
||||
if not shutil.which(installer):
|
||||
self.logger.error(f"Installer {installer} not found")
|
||||
logger.error(f"Installer {installer} not found")
|
||||
return False
|
||||
|
||||
# Run installation command
|
||||
@@ -289,108 +336,254 @@ class LSPTool(BaseTool):
|
||||
stdout, stderr = await result.communicate()
|
||||
|
||||
if result.returncode != 0:
|
||||
self.logger.error(f"Installation failed: {stderr.decode()}")
|
||||
logger.error(f"Installation failed: {stderr.decode()}")
|
||||
return False
|
||||
|
||||
self.logger.info(f"Successfully installed {config['name']}")
|
||||
logger.info(f"Successfully installed {config['name']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Installation error: {e}")
|
||||
logger.error(f"Installation error: {e}")
|
||||
return False
|
||||
|
||||
async def _ensure_lsp_running(self, language: str, root_uri: str) -> Optional[LSPServer]:
|
||||
"""Ensure LSP server is running for language."""
|
||||
# Check if already running
|
||||
"""Ensure LSP server is running for language (uses global singleton)."""
|
||||
server_key = f"{language}:{root_uri}"
|
||||
if server_key in self.servers:
|
||||
server = self.servers[server_key]
|
||||
|
||||
# Fast path - check without lock first
|
||||
if server_key in _GLOBAL_SERVERS:
|
||||
server = _GLOBAL_SERVERS[server_key]
|
||||
if server.process and server.process.returncode is None:
|
||||
return server
|
||||
|
||||
# Check if installed
|
||||
if not await self._check_lsp_installed(language):
|
||||
# Try to install
|
||||
if not await self._install_lsp(language):
|
||||
# Need to start server - acquire global lock
|
||||
async with _GLOBAL_LOCK:
|
||||
# Double-check after acquiring lock
|
||||
if server_key in _GLOBAL_SERVERS:
|
||||
server = _GLOBAL_SERVERS[server_key]
|
||||
if server.process and server.process.returncode is None:
|
||||
return server
|
||||
# Server died, remove it
|
||||
del _GLOBAL_SERVERS[server_key]
|
||||
|
||||
# Check if installed
|
||||
if not await self._check_lsp_installed(language):
|
||||
if not await self._install_lsp(language):
|
||||
return None
|
||||
|
||||
# Start LSP server
|
||||
config = LSP_SERVERS[language]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*config["start_cmd"],
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=root_uri,
|
||||
)
|
||||
|
||||
server = LSPServer(language=language, process=process, config=config, root_uri=root_uri)
|
||||
|
||||
# Initialize LSP
|
||||
if not await self._initialize_lsp(server):
|
||||
if server.process:
|
||||
server.process.terminate()
|
||||
return None
|
||||
|
||||
_GLOBAL_SERVERS[server_key] = server
|
||||
logger.info(f"Started global LSP server: {config['name']} for {root_uri}")
|
||||
return server
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start LSP: {e}")
|
||||
return None
|
||||
|
||||
# Start LSP server
|
||||
config = LSP_SERVERS[language]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*config["start_cmd"],
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=root_uri,
|
||||
)
|
||||
|
||||
server = LSPServer(language=language, process=process, config=config, root_uri=root_uri)
|
||||
|
||||
# Initialize LSP
|
||||
await self._initialize_lsp(server)
|
||||
|
||||
self.servers[server_key] = server
|
||||
return server
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start LSP: {e}")
|
||||
return None
|
||||
|
||||
async def _initialize_lsp(self, server: LSPServer):
|
||||
async def _initialize_lsp(self, server: LSPServer) -> bool:
|
||||
"""Send initialize request to LSP server."""
|
||||
# This is a simplified initialization
|
||||
# In a real implementation, you'd use the full LSP protocol
|
||||
init_params = {
|
||||
"processId": os.getpid(),
|
||||
"rootUri": f"file://{server.root_uri}",
|
||||
"rootPath": server.root_uri,
|
||||
"capabilities": {
|
||||
"workspace": {
|
||||
"workspaceFolders": True,
|
||||
"applyEdit": True,
|
||||
"symbol": {"dynamicRegistration": True},
|
||||
},
|
||||
"textDocument": {
|
||||
"definition": {"dynamicRegistration": True},
|
||||
"synchronization": {
|
||||
"dynamicRegistration": True,
|
||||
"willSave": True,
|
||||
"willSaveWaitUntil": True,
|
||||
"didSave": True,
|
||||
},
|
||||
"completion": {
|
||||
"dynamicRegistration": True,
|
||||
"completionItem": {
|
||||
"snippetSupport": True,
|
||||
"documentationFormat": ["markdown", "plaintext"],
|
||||
},
|
||||
},
|
||||
"hover": {
|
||||
"dynamicRegistration": True,
|
||||
"contentFormat": ["markdown", "plaintext"],
|
||||
},
|
||||
"signatureHelp": {"dynamicRegistration": True},
|
||||
"definition": {"dynamicRegistration": True, "linkSupport": True},
|
||||
"references": {"dynamicRegistration": True},
|
||||
"rename": {"dynamicRegistration": True},
|
||||
}
|
||||
"documentHighlight": {"dynamicRegistration": True},
|
||||
"documentSymbol": {"dynamicRegistration": True},
|
||||
"formatting": {"dynamicRegistration": True},
|
||||
"rename": {"dynamicRegistration": True, "prepareSupport": True},
|
||||
"publishDiagnostics": {"relatedInformation": True},
|
||||
"implementation": {"dynamicRegistration": True, "linkSupport": True},
|
||||
"typeDefinition": {"dynamicRegistration": True, "linkSupport": True},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": [
|
||||
{"uri": f"file://{server.root_uri}", "name": Path(server.root_uri).name}
|
||||
],
|
||||
}
|
||||
|
||||
# Send initialize request
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"id": server.next_id(),
|
||||
"method": "initialize",
|
||||
"params": init_params,
|
||||
}
|
||||
|
||||
await self._send_request(server, request)
|
||||
server.initialized = True
|
||||
response = await self._send_request(server, request, timeout=60.0)
|
||||
if not response or "error" in response:
|
||||
logger.error(f"Failed to initialize LSP: {response}")
|
||||
return False
|
||||
|
||||
async def _send_request(self, server: LSPServer, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Send JSON-RPC request to LSP server."""
|
||||
if not server.process or server.process.returncode is not None:
|
||||
# Send initialized notification
|
||||
await self._send_notification(server, "initialized", {})
|
||||
server.initialized = True
|
||||
logger.info(f"LSP server {server.config['name']} initialized for {server.root_uri}")
|
||||
return True
|
||||
|
||||
async def _read_lsp_message(self, server: LSPServer, timeout: float = 30.0) -> Optional[Dict[str, Any]]:
|
||||
"""Read a single LSP message from server stdout."""
|
||||
if not server.process or not server.process.stdout:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Serialize request
|
||||
request_str = json.dumps(request)
|
||||
content_length = len(request_str.encode("utf-8"))
|
||||
# Read headers until empty line
|
||||
headers = {}
|
||||
while True:
|
||||
line = await asyncio.wait_for(
|
||||
server.process.stdout.readline(),
|
||||
timeout=timeout
|
||||
)
|
||||
if not line:
|
||||
return None
|
||||
line_str = line.decode("utf-8").strip()
|
||||
if not line_str:
|
||||
break
|
||||
if ":" in line_str:
|
||||
key, value = line_str.split(":", 1)
|
||||
headers[key.strip().lower()] = value.strip()
|
||||
|
||||
# Send LSP message
|
||||
message = f"Content-Length: {content_length}\r\n\r\n{request_str}"
|
||||
server.process.stdin.write(message.encode("utf-8"))
|
||||
# Get content length
|
||||
content_length = int(headers.get("content-length", 0))
|
||||
if content_length == 0:
|
||||
return None
|
||||
|
||||
# Read content
|
||||
content = await asyncio.wait_for(
|
||||
server.process.stdout.read(content_length),
|
||||
timeout=timeout
|
||||
)
|
||||
return json.loads(content.decode("utf-8"))
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Timeout reading LSP message")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading LSP message: {e}")
|
||||
return None
|
||||
|
||||
async def _send_request(self, server: LSPServer, request: Dict[str, Any], timeout: float = 30.0) -> Optional[Dict[str, Any]]:
|
||||
"""Send JSON-RPC request to LSP server and wait for response."""
|
||||
if not server.process or server.process.returncode is not None:
|
||||
return None
|
||||
if not server.process.stdin:
|
||||
return None
|
||||
|
||||
import time
|
||||
async with server.lock:
|
||||
try:
|
||||
# Serialize request
|
||||
request_str = json.dumps(request)
|
||||
content = request_str.encode("utf-8")
|
||||
content_length = len(content)
|
||||
|
||||
# Build and send LSP message
|
||||
header = f"Content-Length: {content_length}\r\n\r\n"
|
||||
message = header.encode("utf-8") + content
|
||||
server.process.stdin.write(message)
|
||||
await server.process.stdin.drain()
|
||||
|
||||
# Read response(s) until we get one with matching id
|
||||
request_id = request.get("id")
|
||||
start_time = time.monotonic()
|
||||
deadline = start_time + timeout
|
||||
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
response = await self._read_lsp_message(server, timeout=remaining)
|
||||
if response is None:
|
||||
# Check if server died
|
||||
if server.process.returncode is not None:
|
||||
logger.error(f"LSP server died with code {server.process.returncode}")
|
||||
return None
|
||||
continue
|
||||
|
||||
# Check if this is our response
|
||||
if "id" in response and response["id"] == request_id:
|
||||
return response
|
||||
|
||||
# Handle notifications (no id) - log and continue
|
||||
if "id" not in response and "method" in response:
|
||||
logger.debug(f"LSP notification: {response.get('method')}")
|
||||
continue
|
||||
|
||||
logger.warning(f"Timeout waiting for response to request {request_id}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LSP communication error: {e}")
|
||||
return None
|
||||
|
||||
async def _send_notification(self, server: LSPServer, method: str, params: Dict[str, Any]) -> bool:
|
||||
"""Send JSON-RPC notification (no response expected)."""
|
||||
if not server.process or server.process.returncode is not None:
|
||||
return False
|
||||
if not server.process.stdin:
|
||||
return False
|
||||
|
||||
try:
|
||||
notification = {
|
||||
"jsonrpc": "2.0",
|
||||
"method": method,
|
||||
"params": params,
|
||||
}
|
||||
notification_str = json.dumps(notification)
|
||||
content = notification_str.encode("utf-8")
|
||||
content_length = len(content)
|
||||
|
||||
header = f"Content-Length: {content_length}\r\n\r\n"
|
||||
message = header.encode("utf-8") + content
|
||||
server.process.stdin.write(message)
|
||||
await server.process.stdin.drain()
|
||||
|
||||
# Read response (simplified - real implementation needs proper parsing)
|
||||
# This is a placeholder - actual LSP requires parsing Content-Length headers
|
||||
response_data = await server.process.stdout.readline()
|
||||
|
||||
if response_data:
|
||||
return json.loads(response_data.decode("utf-8"))
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"LSP communication error: {e}")
|
||||
|
||||
return None
|
||||
logger.error(f"Failed to send notification: {e}")
|
||||
return False
|
||||
|
||||
async def run(
|
||||
self,
|
||||
@@ -474,7 +667,6 @@ class LSPTool(BaseTool):
|
||||
|
||||
return MCPResourceDocument(data=result)
|
||||
|
||||
@auto_timeout("lsp")
|
||||
async def call(self, **kwargs) -> str:
|
||||
"""Tool interface for MCP - converts result to JSON string."""
|
||||
result = await self.run(**kwargs)
|
||||
@@ -500,6 +692,53 @@ class LSPTool(BaseTool):
|
||||
new_name=new_name,
|
||||
)
|
||||
|
||||
async def _open_document(self, server: LSPServer, file_path: str) -> bool:
|
||||
"""Notify LSP server that a document is opened."""
|
||||
abs_path = str(Path(file_path).resolve())
|
||||
uri = f"file://{abs_path}"
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
with open(abs_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
# Determine language ID
|
||||
language_id = server.language
|
||||
if server.language == "typescript":
|
||||
ext = Path(file_path).suffix.lower()
|
||||
if ext in [".tsx", ".jsx"]:
|
||||
language_id = "typescriptreact" if "ts" in ext else "javascriptreact"
|
||||
elif ext in [".js", ".mjs", ".cjs"]:
|
||||
language_id = "javascript"
|
||||
|
||||
params = {
|
||||
"textDocument": {
|
||||
"uri": uri,
|
||||
"languageId": language_id,
|
||||
"version": 1,
|
||||
"text": content,
|
||||
}
|
||||
}
|
||||
|
||||
return await self._send_notification(server, "textDocument/didOpen", params)
|
||||
|
||||
def _parse_location(self, location: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse LSP Location into a readable format."""
|
||||
uri = location.get("uri", "")
|
||||
file_path = uri.replace("file://", "") if uri.startswith("file://") else uri
|
||||
range_info = location.get("range", {})
|
||||
start = range_info.get("start", {})
|
||||
end = range_info.get("end", {})
|
||||
|
||||
return {
|
||||
"file": file_path,
|
||||
"start": {"line": start.get("line", 0) + 1, "character": start.get("character", 0)},
|
||||
"end": {"line": end.get("line", 0) + 1, "character": end.get("character", 0)},
|
||||
}
|
||||
|
||||
async def _execute_lsp_action(
|
||||
self,
|
||||
server: LSPServer,
|
||||
@@ -510,76 +749,247 @@ class LSPTool(BaseTool):
|
||||
new_name: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute specific LSP action."""
|
||||
abs_path = str(Path(file).resolve())
|
||||
uri = f"file://{abs_path}"
|
||||
|
||||
# This is a simplified implementation
|
||||
# Real implementation would use proper LSP protocol
|
||||
# Open document first
|
||||
await self._open_document(server, file)
|
||||
|
||||
# Build position (LSP uses 0-based line and character)
|
||||
position = {
|
||||
"line": (line - 1) if line else 0,
|
||||
"character": character if character else 0,
|
||||
}
|
||||
|
||||
if action == "definition":
|
||||
# textDocument/definition request
|
||||
return {
|
||||
"action": "definition",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"note": "LSP integration pending full implementation",
|
||||
"fallback": "Use mcp__lsp__find_definition tool for now",
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": server.next_id(),
|
||||
"method": "textDocument/definition",
|
||||
"params": {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": position,
|
||||
},
|
||||
}
|
||||
response = await self._send_request(server, request)
|
||||
if response and "result" in response:
|
||||
result = response["result"]
|
||||
if result is None:
|
||||
return {"action": "definition", "file": file, "result": None, "message": "No definition found"}
|
||||
if isinstance(result, list):
|
||||
return {
|
||||
"action": "definition",
|
||||
"file": file,
|
||||
"definitions": [self._parse_location(loc) for loc in result],
|
||||
}
|
||||
elif isinstance(result, dict):
|
||||
return {
|
||||
"action": "definition",
|
||||
"file": file,
|
||||
"definition": self._parse_location(result),
|
||||
}
|
||||
return {"action": "definition", "file": file, "error": response.get("error") if response else "No response"}
|
||||
|
||||
elif action == "references":
|
||||
# textDocument/references request
|
||||
return {
|
||||
"action": "references",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"note": "LSP integration pending full implementation",
|
||||
"fallback": "Use mcp__lsp__find_references tool for now",
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": server.next_id(),
|
||||
"method": "textDocument/references",
|
||||
"params": {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": position,
|
||||
"context": {"includeDeclaration": True},
|
||||
},
|
||||
}
|
||||
response = await self._send_request(server, request)
|
||||
if response and "result" in response:
|
||||
result = response["result"]
|
||||
if result is None:
|
||||
return {"action": "references", "file": file, "references": [], "count": 0}
|
||||
refs = [self._parse_location(loc) for loc in result] if isinstance(result, list) else []
|
||||
return {"action": "references", "file": file, "references": refs, "count": len(refs)}
|
||||
return {"action": "references", "file": file, "error": response.get("error") if response else "No response"}
|
||||
|
||||
elif action == "rename":
|
||||
# textDocument/rename request
|
||||
return {
|
||||
"action": "rename",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"new_name": new_name,
|
||||
"note": "LSP integration pending full implementation",
|
||||
"fallback": "Use mcp__lsp__rename_symbol tool for now",
|
||||
if not new_name:
|
||||
return {"action": "rename", "error": "new_name is required for rename action"}
|
||||
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": server.next_id(),
|
||||
"method": "textDocument/rename",
|
||||
"params": {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": position,
|
||||
"newName": new_name,
|
||||
},
|
||||
}
|
||||
response = await self._send_request(server, request)
|
||||
if response and "result" in response:
|
||||
result = response["result"]
|
||||
if result is None:
|
||||
return {"action": "rename", "file": file, "error": "Rename not possible at this location"}
|
||||
|
||||
# Parse workspace edit
|
||||
changes = {}
|
||||
if "changes" in result:
|
||||
for file_uri, edits in result["changes"].items():
|
||||
file_path = file_uri.replace("file://", "")
|
||||
changes[file_path] = [
|
||||
{
|
||||
"range": self._parse_location({"uri": file_uri, "range": edit["range"]}),
|
||||
"newText": edit["newText"],
|
||||
}
|
||||
for edit in edits
|
||||
]
|
||||
elif "documentChanges" in result:
|
||||
for change in result["documentChanges"]:
|
||||
if "textDocument" in change:
|
||||
file_path = change["textDocument"]["uri"].replace("file://", "")
|
||||
changes[file_path] = [
|
||||
{
|
||||
"range": self._parse_location({"uri": change["textDocument"]["uri"], "range": edit["range"]}),
|
||||
"newText": edit["newText"],
|
||||
}
|
||||
for edit in change.get("edits", [])
|
||||
]
|
||||
|
||||
return {
|
||||
"action": "rename",
|
||||
"file": file,
|
||||
"new_name": new_name,
|
||||
"changes": changes,
|
||||
"files_affected": len(changes),
|
||||
}
|
||||
return {"action": "rename", "file": file, "error": response.get("error") if response else "No response"}
|
||||
|
||||
elif action == "diagnostics":
|
||||
# textDocument/diagnostic request
|
||||
# For diagnostics, we read file to trigger analysis and then wait for publishDiagnostics
|
||||
# However, diagnostics are typically push-based, so we'll use a workaround
|
||||
# Some LSP servers support textDocument/diagnostic (LSP 3.17+)
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": server.next_id(),
|
||||
"method": "textDocument/diagnostic",
|
||||
"params": {
|
||||
"textDocument": {"uri": uri},
|
||||
},
|
||||
}
|
||||
response = await self._send_request(server, request, timeout=10.0)
|
||||
if response and "result" in response:
|
||||
result = response["result"]
|
||||
items = result.get("items", []) if isinstance(result, dict) else []
|
||||
return {
|
||||
"action": "diagnostics",
|
||||
"file": file,
|
||||
"diagnostics": [
|
||||
{
|
||||
"severity": item.get("severity", 1),
|
||||
"message": item.get("message", ""),
|
||||
"range": self._parse_location({"uri": uri, "range": item.get("range", {})}),
|
||||
"source": item.get("source", ""),
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
"count": len(items),
|
||||
}
|
||||
# Fallback: Return note about push-based diagnostics
|
||||
return {
|
||||
"action": "diagnostics",
|
||||
"file": file,
|
||||
"note": "LSP integration pending full implementation",
|
||||
"fallback": "Use mcp__lsp__get_diagnostics tool for now",
|
||||
"note": "Diagnostics are push-based; use language-specific tools (go vet, pylint, etc.) for on-demand checking",
|
||||
}
|
||||
|
||||
elif action == "hover":
|
||||
# textDocument/hover request
|
||||
return {
|
||||
"action": "hover",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"note": "LSP integration pending full implementation",
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": server.next_id(),
|
||||
"method": "textDocument/hover",
|
||||
"params": {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": position,
|
||||
},
|
||||
}
|
||||
response = await self._send_request(server, request)
|
||||
if response and "result" in response:
|
||||
result = response["result"]
|
||||
if result is None:
|
||||
return {"action": "hover", "file": file, "result": None, "message": "No hover info"}
|
||||
|
||||
contents = result.get("contents", "")
|
||||
# Handle different content formats
|
||||
if isinstance(contents, dict):
|
||||
hover_text = contents.get("value", str(contents))
|
||||
elif isinstance(contents, list):
|
||||
hover_text = "\n".join(
|
||||
c.get("value", str(c)) if isinstance(c, dict) else str(c)
|
||||
for c in contents
|
||||
)
|
||||
else:
|
||||
hover_text = str(contents)
|
||||
|
||||
return {
|
||||
"action": "hover",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"contents": hover_text,
|
||||
}
|
||||
return {"action": "hover", "file": file, "error": response.get("error") if response else "No response"}
|
||||
|
||||
elif action == "completion":
|
||||
# textDocument/completion request
|
||||
return {
|
||||
"action": "completion",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"note": "LSP integration pending full implementation",
|
||||
request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": server.next_id(),
|
||||
"method": "textDocument/completion",
|
||||
"params": {
|
||||
"textDocument": {"uri": uri},
|
||||
"position": position,
|
||||
},
|
||||
}
|
||||
response = await self._send_request(server, request, timeout=10.0)
|
||||
if response and "result" in response:
|
||||
result = response["result"]
|
||||
if result is None:
|
||||
return {"action": "completion", "file": file, "completions": [], "count": 0}
|
||||
|
||||
return {"error": "Unknown action"}
|
||||
items = result if isinstance(result, list) else result.get("items", [])
|
||||
completions = [
|
||||
{
|
||||
"label": item.get("label", ""),
|
||||
"kind": item.get("kind", 0),
|
||||
"detail": item.get("detail", ""),
|
||||
"documentation": (
|
||||
item.get("documentation", {}).get("value", "")
|
||||
if isinstance(item.get("documentation"), dict)
|
||||
else str(item.get("documentation", ""))
|
||||
),
|
||||
}
|
||||
for item in items[:50] # Limit to 50 items
|
||||
]
|
||||
return {
|
||||
"action": "completion",
|
||||
"file": file,
|
||||
"position": {"line": line, "character": character},
|
||||
"completions": completions,
|
||||
"count": len(completions),
|
||||
}
|
||||
return {"action": "completion", "file": file, "error": response.get("error") if response else "No response"}
|
||||
|
||||
return {"error": f"Unknown action: {action}"}
|
||||
|
||||
async def cleanup(self):
|
||||
"""Clean up LSP servers."""
|
||||
for server in self.servers.values():
|
||||
if server.process and server.process.returncode is None:
|
||||
server.process.terminate()
|
||||
await server.process.wait()
|
||||
"""Clean up LSP servers (uses global registry)."""
|
||||
async with _GLOBAL_LOCK:
|
||||
for server_key, server in list(_GLOBAL_SERVERS.items()):
|
||||
if server.process and server.process.returncode is None:
|
||||
try:
|
||||
server.process.terminate()
|
||||
await asyncio.wait_for(server.process.wait(), timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
server.process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
_GLOBAL_SERVERS.clear()
|
||||
|
||||
|
||||
# Tool registration
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Refactoring tools for code transformation.
|
||||
|
||||
This module provides advanced refactoring capabilities:
|
||||
- rename: Rename symbols across codebase using LSP
|
||||
- extract_function: Extract code to new function
|
||||
- inline: Inline functions or variables
|
||||
- move: Move code between files
|
||||
- change_signature: Modify function signatures
|
||||
"""
|
||||
|
||||
from hanzo_mcp.tools.refactor.refactor_tool import RefactorTool, create_refactor_tool
|
||||
|
||||
__all__ = ["RefactorTool", "create_refactor_tool"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
"""Search tools for finding code, files, and information."""
|
||||
|
||||
from .find_tool import FindTool, create_find_tool
|
||||
from .unified_search import UnifiedSearch, create_unified_search_tool
|
||||
from .search_tool import SearchTool, create_search_tool
|
||||
|
||||
__all__ = [
|
||||
"UnifiedSearch",
|
||||
"create_unified_search_tool",
|
||||
"SearchTool",
|
||||
"create_search_tool",
|
||||
"FindTool",
|
||||
"create_find_tool",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Fast file finding tool using ffind and intelligent caching."""
|
||||
"""Fast file finding tool using ffind library and intelligent caching."""
|
||||
|
||||
import os
|
||||
import time
|
||||
import fnmatch
|
||||
import subprocess
|
||||
from typing import Any, Set, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
@@ -13,11 +12,11 @@ from hanzo_mcp.types import MCPResourceDocument
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
from hanzo_mcp.tools.common.auto_timeout import auto_timeout
|
||||
|
||||
# Check if ffind command is available
|
||||
# Check if ffind library is available
|
||||
try:
|
||||
subprocess.run(["ffind", "--version"], capture_output=True, check=True)
|
||||
from ffind.ffind import search as ffind_search
|
||||
FFIND_AVAILABLE = True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
except ImportError:
|
||||
FFIND_AVAILABLE = False
|
||||
|
||||
|
||||
@@ -188,21 +187,32 @@ class FindTool(BaseTool):
|
||||
self._gitignore_cache[root] = patterns
|
||||
return patterns
|
||||
|
||||
def _should_ignore(self, path: str, ignore_patterns: Set[str]) -> bool:
|
||||
"""Check if path should be ignored."""
|
||||
def _should_ignore(self, path: str, ignore_patterns: Set[str], root: Optional[Path] = None) -> bool:
|
||||
"""Check if path should be ignored.
|
||||
|
||||
Args:
|
||||
path: The absolute path to check
|
||||
ignore_patterns: Set of gitignore-style patterns
|
||||
root: Optional search root - only check parents BELOW this root
|
||||
"""
|
||||
path_obj = Path(path)
|
||||
|
||||
for pattern in ignore_patterns:
|
||||
# Check against full path and basename
|
||||
# Check against basename only
|
||||
if fnmatch.fnmatch(path_obj.name, pattern):
|
||||
return True
|
||||
if fnmatch.fnmatch(str(path_obj), pattern):
|
||||
return True
|
||||
|
||||
# Check if any parent directory matches
|
||||
for parent in path_obj.parents:
|
||||
if fnmatch.fnmatch(parent.name, pattern):
|
||||
return True
|
||||
# Check relative path if we have a root
|
||||
if root:
|
||||
try:
|
||||
rel_path = path_obj.relative_to(root)
|
||||
# Check relative path parts (parents below root)
|
||||
for part in rel_path.parts[:-1]: # Exclude the filename itself
|
||||
if fnmatch.fnmatch(part, pattern):
|
||||
return True
|
||||
except ValueError:
|
||||
# Path is not relative to root, skip parent checks
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
@@ -441,75 +451,31 @@ class FindTool(BaseTool):
|
||||
respect_gitignore: bool,
|
||||
ignore_patterns: Set[str],
|
||||
) -> List[FileMatch]:
|
||||
"""Use ffind for fast file discovery."""
|
||||
"""Use ffind library for fast file discovery."""
|
||||
matches = []
|
||||
|
||||
# Configure ffind
|
||||
ffind_args = {
|
||||
"path": str(root),
|
||||
"pattern": pattern,
|
||||
"regex": regex,
|
||||
"case_sensitive": case_sensitive,
|
||||
"follow_symlinks": follow_symlinks,
|
||||
}
|
||||
|
||||
if fuzzy:
|
||||
ffind_args["fuzzy"] = True
|
||||
|
||||
if max_depth:
|
||||
ffind_args["max_depth"] = max_depth
|
||||
|
||||
try:
|
||||
# Build ffind command
|
||||
cmd = ["ffind"]
|
||||
|
||||
if not case_sensitive:
|
||||
cmd.append("-i")
|
||||
|
||||
if regex:
|
||||
cmd.append("-E")
|
||||
|
||||
if fuzzy:
|
||||
cmd.append("-f")
|
||||
|
||||
if follow_symlinks:
|
||||
cmd.append("-L")
|
||||
|
||||
if max_depth:
|
||||
cmd.extend(["-D", str(max_depth)])
|
||||
|
||||
# Add path and pattern (ffind expects directory first)
|
||||
cmd.append(str(root))
|
||||
cmd.append(pattern)
|
||||
|
||||
# Run ffind command
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Fall back to Python implementation on error
|
||||
return await self._find_with_python(
|
||||
pattern,
|
||||
root,
|
||||
file_type,
|
||||
case_sensitive,
|
||||
regex,
|
||||
fuzzy,
|
||||
False,
|
||||
max_depth,
|
||||
follow_symlinks,
|
||||
respect_gitignore,
|
||||
ignore_patterns,
|
||||
)
|
||||
|
||||
# Parse results
|
||||
results = result.stdout.strip().split("\n") if result.stdout else []
|
||||
# Use ffind as Python library directly
|
||||
results = ffind_search(
|
||||
directory=str(root),
|
||||
file_pattern=pattern,
|
||||
path_match=False, # Match filename only, not full path
|
||||
follow_symlinks=follow_symlinks,
|
||||
output=False, # Don't print
|
||||
colored=False,
|
||||
ignore_hidden=True,
|
||||
ignore_case=not case_sensitive,
|
||||
ignore_vcs=respect_gitignore,
|
||||
return_results=True,
|
||||
fuzzy=fuzzy,
|
||||
)
|
||||
|
||||
for path in results:
|
||||
if not path: # Skip empty lines
|
||||
continue
|
||||
|
||||
# Check ignore patterns
|
||||
if self._should_ignore(path, ignore_patterns):
|
||||
# Check ignore patterns (pass root to only check relative parents)
|
||||
if self._should_ignore(path, ignore_patterns, root):
|
||||
continue
|
||||
|
||||
# Get file info
|
||||
@@ -609,7 +575,7 @@ class FindTool(BaseTool):
|
||||
# Filter directories to skip
|
||||
if respect_gitignore:
|
||||
dirnames[:] = [
|
||||
d for d in dirnames if not self._should_ignore(os.path.join(dirpath, d), ignore_patterns)
|
||||
d for d in dirnames if not self._should_ignore(os.path.join(dirpath, d), ignore_patterns, root)
|
||||
]
|
||||
|
||||
# Check directories
|
||||
@@ -617,7 +583,7 @@ class FindTool(BaseTool):
|
||||
for dirname in dirnames:
|
||||
if matcher(dirname):
|
||||
full_path = os.path.join(dirpath, dirname)
|
||||
if not self._should_ignore(full_path, ignore_patterns):
|
||||
if not self._should_ignore(full_path, ignore_patterns, root):
|
||||
try:
|
||||
stat = os.stat(full_path)
|
||||
match = FileMatch(
|
||||
@@ -638,7 +604,7 @@ class FindTool(BaseTool):
|
||||
for filename in filenames:
|
||||
full_path = os.path.join(dirpath, filename)
|
||||
|
||||
if self._should_ignore(full_path, ignore_patterns):
|
||||
if self._should_ignore(full_path, ignore_patterns, root):
|
||||
continue
|
||||
|
||||
# Match against filename
|
||||
|
||||
+203
-17
@@ -39,6 +39,14 @@ try:
|
||||
except ImportError:
|
||||
VECTOR_SEARCH_AVAILABLE = False
|
||||
|
||||
# LSP availability check
|
||||
try:
|
||||
from hanzo_mcp.tools.lsp.lsp_tool import LSPTool, LSP_SERVERS
|
||||
|
||||
LSP_AVAILABLE = True
|
||||
except ImportError:
|
||||
LSP_AVAILABLE = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
@@ -76,7 +84,7 @@ class SearchResult:
|
||||
return hash((self.file_path, self.line_number, self.column, self.match_text))
|
||||
|
||||
|
||||
class UnifiedSearch(BaseTool):
|
||||
class SearchTool(BaseTool):
|
||||
"""THE primary search tool - your universal interface for finding anything.
|
||||
|
||||
This is the main search tool you should use for finding:
|
||||
@@ -137,14 +145,20 @@ class UnifiedSearch(BaseTool):
|
||||
Automatically detects query intent and runs appropriate searches in parallel.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, enable_vector_index: bool = False):
|
||||
"""Initialize search tool.
|
||||
|
||||
Args:
|
||||
enable_vector_index: Whether to enable vector search indexing (default: False).
|
||||
When False, vector search is disabled for faster startup.
|
||||
Set to True to enable semantic search capabilities.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ripgrep_available = self._check_ripgrep()
|
||||
self.vector_db = None
|
||||
self.embedder = None
|
||||
|
||||
if VECTOR_SEARCH_AVAILABLE:
|
||||
self._init_vector_search()
|
||||
self._vector_initialized = False
|
||||
self._enable_vector_index = enable_vector_index
|
||||
|
||||
def _check_ripgrep(self) -> bool:
|
||||
"""Check if ripgrep is available."""
|
||||
@@ -154,8 +168,23 @@ class UnifiedSearch(BaseTool):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _init_vector_search(self):
|
||||
"""Initialize vector search components."""
|
||||
def _init_vector_search(self) -> bool:
|
||||
"""Initialize vector search components lazily.
|
||||
|
||||
Returns:
|
||||
True if initialization succeeded, False otherwise.
|
||||
"""
|
||||
if self._vector_initialized:
|
||||
return self.vector_db is not None
|
||||
|
||||
self._vector_initialized = True
|
||||
|
||||
if not VECTOR_SEARCH_AVAILABLE:
|
||||
return False
|
||||
|
||||
if not self._enable_vector_index:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.embedder = SentenceTransformer("all-MiniLM-L6-v2")
|
||||
self.vector_db = chromadb.Client()
|
||||
@@ -163,9 +192,11 @@ class UnifiedSearch(BaseTool):
|
||||
self.collection = self.vector_db.get_or_create_collection(
|
||||
name="code_search", metadata={"description": "Code semantic search"}
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to initialize vector search: {e}")
|
||||
self.vector_db = None
|
||||
return False
|
||||
|
||||
def _should_use_vector_search(self, query: str) -> bool:
|
||||
"""Determine if vector search would be helpful."""
|
||||
@@ -212,6 +243,7 @@ class UnifiedSearch(BaseTool):
|
||||
enable_ast: bool = None,
|
||||
enable_vector: bool = None,
|
||||
enable_symbol: bool = None,
|
||||
enable_lsp: bool = None,
|
||||
page_size: int = 50,
|
||||
page: int = 1,
|
||||
**kwargs,
|
||||
@@ -228,6 +260,7 @@ class UnifiedSearch(BaseTool):
|
||||
search_files: Also search for matching filenames
|
||||
search_memory: Search in memory/knowledge base (auto-detected if None)
|
||||
enable_*: Force enable/disable specific search types (auto if None)
|
||||
enable_lsp: Enable LSP-based reference search (auto if None)
|
||||
page_size: Results per page (default: 50)
|
||||
page: Page number to retrieve (default: 1)
|
||||
"""
|
||||
@@ -244,7 +277,12 @@ class UnifiedSearch(BaseTool):
|
||||
enable_text = True # Always use text search as baseline
|
||||
|
||||
if enable_vector is None:
|
||||
enable_vector = self._should_use_vector_search(pattern) and VECTOR_SEARCH_AVAILABLE
|
||||
# Only enable vector search if explicitly enabled via enable_vector_index
|
||||
enable_vector = (
|
||||
self._enable_vector_index
|
||||
and self._should_use_vector_search(pattern)
|
||||
and VECTOR_SEARCH_AVAILABLE
|
||||
)
|
||||
|
||||
if enable_ast is None:
|
||||
enable_ast = self._should_use_ast_search(pattern) and TREESITTER_AVAILABLE
|
||||
@@ -252,6 +290,10 @@ class UnifiedSearch(BaseTool):
|
||||
if enable_symbol is None:
|
||||
enable_symbol = self._should_use_symbol_search(pattern)
|
||||
|
||||
if enable_lsp is None:
|
||||
# Use LSP for symbol-like queries when LSP is available
|
||||
enable_lsp = LSP_AVAILABLE and self._should_use_symbol_search(pattern)
|
||||
|
||||
# Collect results from all enabled search types
|
||||
all_results = []
|
||||
search_stats = {
|
||||
@@ -287,8 +329,8 @@ class UnifiedSearch(BaseTool):
|
||||
search_stats["search_types_used"].append("symbol")
|
||||
all_results.extend(symbol_results)
|
||||
|
||||
# 4. Vector search - for semantic similarity
|
||||
if enable_vector and self.vector_db:
|
||||
# 4. Vector search - for semantic similarity (lazy init)
|
||||
if enable_vector and self._init_vector_search():
|
||||
start = time.time()
|
||||
vector_results = await self._vector_search(
|
||||
pattern, path, include, exclude, max_results_per_type, context_lines
|
||||
@@ -313,6 +355,14 @@ class UnifiedSearch(BaseTool):
|
||||
search_stats["search_types_used"].append("memory")
|
||||
all_results.extend(memory_results)
|
||||
|
||||
# 7. LSP search - for precise symbol references and definitions
|
||||
if enable_lsp and LSP_AVAILABLE:
|
||||
start = time.time()
|
||||
lsp_results = await self._lsp_search(pattern, path, include, max_results_per_type)
|
||||
search_stats["time_ms"]["lsp"] = int((time.time() - start) * 1000)
|
||||
search_stats["search_types_used"].append("lsp")
|
||||
all_results.extend(lsp_results)
|
||||
|
||||
# Deduplicate and rank results
|
||||
unique_results = self._deduplicate_results(all_results)
|
||||
ranked_results = self._rank_results(unique_results, pattern)
|
||||
@@ -433,11 +483,23 @@ class UnifiedSearch(BaseTool):
|
||||
if data.get("type") == "match":
|
||||
match_data = data["data"]
|
||||
|
||||
# Handle different ripgrep output formats
|
||||
lines_data = match_data.get("lines", {})
|
||||
match_text = lines_data.get("text", "") if isinstance(lines_data, dict) else str(lines_data)
|
||||
|
||||
# Get file path - handle both dict and str formats
|
||||
path_data = match_data.get("path", {})
|
||||
file_path = path_data.get("text", str(path_data)) if isinstance(path_data, dict) else str(path_data)
|
||||
|
||||
# Get column safely
|
||||
submatches = match_data.get("submatches", [{}])
|
||||
column = submatches[0].get("start", 0) if submatches else 0
|
||||
|
||||
result = SearchResult(
|
||||
file_path=match_data["path"]["text"],
|
||||
line_number=match_data["line_number"],
|
||||
column=match_data["submatches"][0]["start"],
|
||||
match_text=match_data["lines"]["text"].strip(),
|
||||
file_path=file_path,
|
||||
line_number=match_data.get("line_number", 0),
|
||||
column=column,
|
||||
match_text=match_text.strip() if isinstance(match_text, str) else str(match_text),
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="text",
|
||||
@@ -727,6 +789,125 @@ class UnifiedSearch(BaseTool):
|
||||
|
||||
return results
|
||||
|
||||
async def _lsp_search(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str,
|
||||
include: Optional[str],
|
||||
max_results: int,
|
||||
) -> List[SearchResult]:
|
||||
"""Search using LSP for precise symbol references.
|
||||
|
||||
Uses AST search to find initial symbol locations, then LSP to find
|
||||
all references across the codebase.
|
||||
"""
|
||||
results = []
|
||||
|
||||
if not LSP_AVAILABLE:
|
||||
return results
|
||||
|
||||
try:
|
||||
# First, find files that might contain the symbol
|
||||
root_path = Path(path).resolve()
|
||||
|
||||
# Get file extensions we can search with LSP
|
||||
supported_extensions = set()
|
||||
for lang_info in LSP_SERVERS.values():
|
||||
supported_extensions.update(lang_info.get("extensions", []))
|
||||
|
||||
# Find matching files using grep to locate the symbol
|
||||
grep_cmd = ["rg", "--files-with-matches", "-l", pattern]
|
||||
if include:
|
||||
grep_cmd.extend(["--glob", include])
|
||||
grep_cmd.append(str(root_path))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
grep_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
matching_files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
matching_files = []
|
||||
|
||||
if not matching_files:
|
||||
return results
|
||||
|
||||
# Create LSP tool
|
||||
lsp_tool = LSPTool()
|
||||
|
||||
# For each matching file, try to find references using LSP
|
||||
files_checked = 0
|
||||
for file_path in matching_files[:5]: # Limit to first 5 files to avoid slowdown
|
||||
if files_checked >= 3: # Also limit files we actually query LSP on
|
||||
break
|
||||
|
||||
# Check if file extension is supported
|
||||
ext = Path(file_path).suffix
|
||||
if ext not in supported_extensions:
|
||||
continue
|
||||
|
||||
# Find the symbol's line and column in this file
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
col = line.find(pattern)
|
||||
if col >= 0:
|
||||
# Found the pattern, try to get LSP references
|
||||
try:
|
||||
lsp_result = await lsp_tool.run(
|
||||
action="references",
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
character=col,
|
||||
)
|
||||
|
||||
if lsp_result.data and "references" in lsp_result.data:
|
||||
for ref in lsp_result.data["references"][:max_results]:
|
||||
ref_path = ref.get("uri", "").replace("file://", "")
|
||||
ref_line = ref.get("range", {}).get("start", {}).get("line", 0) + 1
|
||||
|
||||
# Read the line content
|
||||
try:
|
||||
with open(ref_path, "r", encoding="utf-8", errors="ignore") as rf:
|
||||
lines = rf.readlines()
|
||||
match_text = lines[ref_line - 1].strip() if ref_line <= len(lines) else pattern
|
||||
except (IOError, IndexError):
|
||||
match_text = pattern
|
||||
|
||||
result = SearchResult(
|
||||
file_path=ref_path,
|
||||
line_number=ref_line,
|
||||
column=ref.get("range", {}).get("start", {}).get("character", 0),
|
||||
match_text=match_text,
|
||||
context_before=[],
|
||||
context_after=[],
|
||||
match_type="lsp",
|
||||
score=0.95, # High confidence from LSP
|
||||
semantic_context=f"LSP reference to '{pattern}'",
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
if len(results) >= max_results:
|
||||
return results
|
||||
|
||||
except Exception:
|
||||
# LSP query failed, continue to next occurrence
|
||||
pass
|
||||
|
||||
files_checked += 1
|
||||
break # Only check first occurrence in each file
|
||||
|
||||
except (IOError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"LSP search error: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def _deduplicate_results(self, results: List[SearchResult]) -> List[SearchResult]:
|
||||
"""Remove duplicate results across search types."""
|
||||
seen = set()
|
||||
@@ -948,6 +1129,11 @@ class CodeIndexer:
|
||||
|
||||
|
||||
# Tool registration
|
||||
def create_unified_search_tool():
|
||||
"""Factory function to create unified search tool."""
|
||||
return UnifiedSearch()
|
||||
def create_search_tool(enable_vector_index: bool = False):
|
||||
"""Factory function to create search tool.
|
||||
|
||||
Args:
|
||||
enable_vector_index: Enable vector/semantic search (requires chromadb + sentence-transformers).
|
||||
Default False for fast startup without database dependencies.
|
||||
"""
|
||||
return SearchTool(enable_vector_index=enable_vector_index)
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.9.3"
|
||||
version = "0.9.8"
|
||||
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
@@ -88,7 +88,9 @@ include = ["hanzo_mcp"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# Configuration for pytest
|
||||
addopts = "--no-header --no-summary"
|
||||
addopts = "--no-header --no-summary -p asyncio"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
# Note: --cov-fail-under=96.3 will be added after achieving target coverage
|
||||
markers = [
|
||||
"asyncio: mark test as using asyncio",
|
||||
@@ -101,8 +103,6 @@ testpaths = ["tests"]
|
||||
python_files = ["test_*.py", "*_test.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
|
||||
@@ -10,6 +10,9 @@ import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Register pytest-asyncio plugin explicitly
|
||||
pytest_plugins = ["pytest_asyncio"]
|
||||
|
||||
# Add tests directory to path so we can import test_utils
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -33,12 +36,6 @@ os.environ["PYTEST_CURRENT_TEST"] = "1" # Mark as pytest run
|
||||
# Configure pytest
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest."""
|
||||
# Register asyncio marker
|
||||
config.addinivalue_line("markers", "asyncio: mark test as using asyncio")
|
||||
|
||||
# Configure pytest-asyncio
|
||||
config._inicache["asyncio_default_fixture_loop_scope"] = "function"
|
||||
|
||||
# Register custom markers
|
||||
config.addinivalue_line("markers", "requires_hanzo_agents: mark test as requiring hanzo-agents SDK")
|
||||
config.addinivalue_line("markers", "requires_memory_tools: mark test as requiring hanzo-memory package")
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
"""Tests for the refactor tool."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
from hanzo_mcp.tools.refactor import RefactorTool, create_refactor_tool
|
||||
|
||||
|
||||
class TestRefactorTool:
|
||||
"""Tests for RefactorTool class."""
|
||||
|
||||
@pytest.fixture
|
||||
def tool(self):
|
||||
"""Create a refactor tool instance."""
|
||||
return create_refactor_tool()
|
||||
|
||||
@pytest.fixture
|
||||
def temp_python_file(self):
|
||||
"""Create a temporary Python file for testing."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''def old_function(x, y):
|
||||
result = x + y
|
||||
return result
|
||||
|
||||
def caller():
|
||||
value = old_function(1, 2)
|
||||
return value
|
||||
|
||||
class MyClass:
|
||||
def method(self):
|
||||
return old_function(3, 4)
|
||||
''')
|
||||
temp_path = f.name
|
||||
|
||||
yield temp_path
|
||||
|
||||
# Cleanup
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
@pytest.fixture
|
||||
def temp_js_file(self):
|
||||
"""Create a temporary JavaScript file for testing."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as f:
|
||||
f.write('''function oldFunction(x, y) {
|
||||
const result = x + y;
|
||||
return result;
|
||||
}
|
||||
|
||||
function caller() {
|
||||
const value = oldFunction(1, 2);
|
||||
return value;
|
||||
}
|
||||
''')
|
||||
temp_path = f.name
|
||||
|
||||
yield temp_path
|
||||
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_tool_creation(self, tool):
|
||||
"""Test that the tool can be created."""
|
||||
assert tool is not None
|
||||
assert tool.name == "refactor"
|
||||
|
||||
def test_tool_has_description(self, tool):
|
||||
"""Test that the tool has a description."""
|
||||
assert tool.description is not None
|
||||
assert "refactor" in tool.description.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_action(self, tool):
|
||||
"""Test error handling for invalid action."""
|
||||
result = await tool.run(action="invalid_action", file="test.py")
|
||||
assert result.data["error"] is not None
|
||||
assert "Invalid action" in result.data["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_not_found(self, tool):
|
||||
"""Test error handling for non-existent file."""
|
||||
result = await tool.run(action="rename", file="/nonexistent/file.py", line=1, column=0, new_name="new")
|
||||
assert "error" in result.data
|
||||
assert "not found" in result.data["error"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_references_missing_args(self, tool, temp_python_file):
|
||||
"""Test find_references without required arguments."""
|
||||
result = await tool.run(action="find_references", file=temp_python_file)
|
||||
assert result.data["success"] is False
|
||||
assert "required" in result.data["errors"][0].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_references(self, tool, temp_python_file):
|
||||
"""Test finding references to a symbol."""
|
||||
result = await tool.run(
|
||||
action="find_references",
|
||||
file=temp_python_file,
|
||||
line=1,
|
||||
column=4, # 'old_function'
|
||||
)
|
||||
assert result.data["success"] is True
|
||||
assert result.data["action"] == "find_references"
|
||||
assert len(result.data["changes"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_preview(self, tool, temp_python_file):
|
||||
"""Test rename in preview mode."""
|
||||
result = await tool.run(
|
||||
action="rename",
|
||||
file=temp_python_file,
|
||||
line=1,
|
||||
column=4, # 'old_function'
|
||||
new_name="new_function",
|
||||
preview=True,
|
||||
)
|
||||
assert result.data["success"] is True
|
||||
assert result.data["action"] == "rename"
|
||||
assert "preview" in result.data
|
||||
assert len(result.data["preview"]) > 0
|
||||
|
||||
# Verify file wasn't modified
|
||||
with open(temp_python_file, "r") as f:
|
||||
content = f.read()
|
||||
assert "old_function" in content
|
||||
assert "new_function" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rename_apply(self, tool, temp_python_file):
|
||||
"""Test rename with actual application."""
|
||||
result = await tool.run(
|
||||
action="rename",
|
||||
file=temp_python_file,
|
||||
line=1,
|
||||
column=4, # 'old_function'
|
||||
new_name="new_function",
|
||||
preview=False,
|
||||
)
|
||||
assert result.data["success"] is True
|
||||
assert result.data["action"] == "rename"
|
||||
assert result.data["changes_applied"] > 0
|
||||
|
||||
# Verify file was modified
|
||||
with open(temp_python_file, "r") as f:
|
||||
content = f.read()
|
||||
assert "new_function" in content
|
||||
# All occurrences should be renamed
|
||||
assert "old_function" not in content
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_function_missing_name(self, tool, temp_python_file):
|
||||
"""Test extract_function without new_name."""
|
||||
result = await tool.run(
|
||||
action="extract_function",
|
||||
file=temp_python_file,
|
||||
start_line=2,
|
||||
end_line=3,
|
||||
)
|
||||
assert result.data["success"] is False
|
||||
assert "new_name" in result.data["errors"][0].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_function_preview(self, tool, temp_python_file):
|
||||
"""Test extract_function in preview mode."""
|
||||
result = await tool.run(
|
||||
action="extract_function",
|
||||
file=temp_python_file,
|
||||
start_line=2,
|
||||
end_line=3,
|
||||
new_name="compute_result",
|
||||
preview=True,
|
||||
)
|
||||
assert result.data["success"] is True
|
||||
assert result.data["action"] == "extract_function"
|
||||
assert "preview" in result.data
|
||||
assert len(result.data["preview"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_variable_missing_args(self, tool, temp_python_file):
|
||||
"""Test extract_variable without required arguments."""
|
||||
result = await tool.run(
|
||||
action="extract_variable",
|
||||
file=temp_python_file,
|
||||
)
|
||||
assert result.data["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inline_missing_args(self, tool, temp_python_file):
|
||||
"""Test inline without required arguments."""
|
||||
result = await tool.run(
|
||||
action="inline",
|
||||
file=temp_python_file,
|
||||
)
|
||||
assert result.data["success"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_missing_target(self, tool, temp_python_file):
|
||||
"""Test move without target_file."""
|
||||
result = await tool.run(
|
||||
action="move",
|
||||
file=temp_python_file,
|
||||
line=1,
|
||||
)
|
||||
assert result.data["success"] is False
|
||||
assert "target_file" in result.data["errors"][0].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_organize_imports_python(self, tool):
|
||||
"""Test organize imports for Python file."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
||||
f.write('''import os
|
||||
from typing import List
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
import json
|
||||
|
||||
def main():
|
||||
pass
|
||||
''')
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
result = await tool.run(
|
||||
action="organize_imports",
|
||||
file=temp_path,
|
||||
preview=False,
|
||||
)
|
||||
assert result.data["success"] is True
|
||||
|
||||
with open(temp_path, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Imports should be sorted
|
||||
lines = content.split("\n")
|
||||
import_lines = [l for l in lines if l.startswith("import ")]
|
||||
from_lines = [l for l in lines if l.startswith("from ")]
|
||||
|
||||
# Check imports are sorted
|
||||
assert import_lines == sorted(import_lines, key=str.lower)
|
||||
assert from_lines == sorted(from_lines, key=str.lower)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
class TestRefactorToolHelpers:
|
||||
"""Tests for RefactorTool helper methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def tool(self):
|
||||
"""Create a refactor tool instance."""
|
||||
return RefactorTool()
|
||||
|
||||
def test_get_identifier_at_basic(self, tool):
|
||||
"""Test getting identifier at position."""
|
||||
line = "def my_function(arg1, arg2):"
|
||||
assert tool._get_identifier_at(line, 4) == "my_function"
|
||||
assert tool._get_identifier_at(line, 16) == "arg1"
|
||||
assert tool._get_identifier_at(line, 23) == "arg2"
|
||||
|
||||
def test_get_identifier_at_edge_cases(self, tool):
|
||||
"""Test edge cases for identifier detection."""
|
||||
# Empty line
|
||||
assert tool._get_identifier_at("", 0) is None
|
||||
|
||||
# Column out of range
|
||||
assert tool._get_identifier_at("hello", 100) == "hello"
|
||||
|
||||
# No identifier at position
|
||||
assert tool._get_identifier_at(" ", 2) is None
|
||||
|
||||
def test_get_indentation(self, tool):
|
||||
"""Test indentation detection."""
|
||||
assert tool._get_indentation("def foo():") == ""
|
||||
assert tool._get_indentation(" return x") == " "
|
||||
assert tool._get_indentation("\t\treturn y") == "\t\t"
|
||||
assert tool._get_indentation("") == ""
|
||||
|
||||
def test_get_language(self, tool):
|
||||
"""Test language detection from file extension."""
|
||||
assert tool._get_language("test.py") == "python"
|
||||
assert tool._get_language("test.js") == "javascript"
|
||||
assert tool._get_language("test.ts") == "typescript"
|
||||
assert tool._get_language("test.go") == "go"
|
||||
assert tool._get_language("test.rs") == "rust"
|
||||
assert tool._get_language("test.unknown") == "unknown"
|
||||
|
||||
def test_find_used_variables(self, tool):
|
||||
"""Test finding used variables in code."""
|
||||
code = "result = x + y"
|
||||
vars = tool._find_used_variables(code, "python")
|
||||
assert "x" in vars
|
||||
assert "y" in vars
|
||||
assert "result" in vars
|
||||
|
||||
def test_find_defined_variables_python(self, tool):
|
||||
"""Test finding defined variables in Python."""
|
||||
code = "x = 10\ny = 20"
|
||||
vars = tool._find_defined_variables(code, "python")
|
||||
assert "x" in vars
|
||||
assert "y" in vars
|
||||
|
||||
def test_find_defined_variables_javascript(self, tool):
|
||||
"""Test finding defined variables in JavaScript."""
|
||||
code = "const x = 10;\nlet y = 20;"
|
||||
vars = tool._find_defined_variables(code, "javascript")
|
||||
assert "x" in vars
|
||||
assert "y" in vars
|
||||
|
||||
def test_build_function_python(self, tool):
|
||||
"""Test building Python function."""
|
||||
func = tool._build_function("my_func", ["x", "y"], "return x + y", "python", "")
|
||||
assert "def my_func(x, y):" in func
|
||||
assert "return x + y" in func
|
||||
|
||||
def test_build_function_javascript(self, tool):
|
||||
"""Test building JavaScript function."""
|
||||
func = tool._build_function("myFunc", ["x", "y"], "return x + y;", "javascript", "")
|
||||
assert "function myFunc(x, y)" in func
|
||||
assert "return x + y;" in func
|
||||
|
||||
def test_build_variable_declaration_python(self, tool):
|
||||
"""Test building Python variable declaration."""
|
||||
decl = tool._build_variable_declaration("x", "10 + 20", "python", " ")
|
||||
assert decl == " x = 10 + 20"
|
||||
|
||||
def test_build_variable_declaration_javascript(self, tool):
|
||||
"""Test building JavaScript variable declaration."""
|
||||
decl = tool._build_variable_declaration("x", "10 + 20", "javascript", " ")
|
||||
assert decl == " const x = 10 + 20;"
|
||||
|
||||
|
||||
# Test factory function
|
||||
def test_create_refactor_tool():
|
||||
"""Test the factory function."""
|
||||
tool = create_refactor_tool()
|
||||
assert isinstance(tool, RefactorTool)
|
||||
assert tool.name == "refactor"
|
||||
+32
-19
@@ -1,4 +1,4 @@
|
||||
"""Test unified search tool functionality."""
|
||||
"""Test search tool functionality."""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
@@ -6,17 +6,17 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hanzo_mcp.tools.search import create_find_tool, create_unified_search_tool
|
||||
from hanzo_mcp.tools.search import create_find_tool, create_search_tool
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_search_basic():
|
||||
"""Test basic unified search functionality."""
|
||||
async def test_search_basic():
|
||||
"""Test basic search functionality."""
|
||||
# Create search tool
|
||||
search_tool = create_unified_search_tool()
|
||||
search_tool = create_search_tool()
|
||||
|
||||
# Test with current directory
|
||||
result = await search_tool.run(pattern="UnifiedSearch", path=".", max_results_per_type=5)
|
||||
result = await search_tool.run(pattern="SearchTool", path=".", max_results_per_type=5)
|
||||
|
||||
assert result.data is not None
|
||||
assert "results" in result.data
|
||||
@@ -29,15 +29,15 @@ async def test_unified_search_basic():
|
||||
# Check statistics
|
||||
stats = result.data["statistics"]
|
||||
assert "query" in stats
|
||||
assert stats["query"] == "UnifiedSearch"
|
||||
assert stats["query"] == "SearchTool"
|
||||
assert "search_types_used" in stats
|
||||
assert "text" in stats["search_types_used"] # Should use text search
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_search_auto_detection():
|
||||
async def test_search_auto_detection():
|
||||
"""Test automatic search type detection."""
|
||||
search_tool = create_unified_search_tool()
|
||||
search_tool = create_search_tool()
|
||||
|
||||
# Test natural language query (should trigger vector search if available)
|
||||
result = await search_tool.run(pattern="how does search work in this codebase", max_results_per_type=5)
|
||||
@@ -55,9 +55,9 @@ async def test_unified_search_auto_detection():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_search_code_patterns():
|
||||
async def test_search_code_patterns():
|
||||
"""Test code pattern search."""
|
||||
search_tool = create_unified_search_tool()
|
||||
search_tool = create_search_tool()
|
||||
|
||||
# Test AST pattern detection
|
||||
result = await search_tool.run(pattern="class SearchResult", max_results_per_type=5)
|
||||
@@ -74,9 +74,9 @@ async def test_unified_search_code_patterns():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_search_with_files():
|
||||
async def test_search_with_files():
|
||||
"""Test file search integration."""
|
||||
search_tool = create_unified_search_tool()
|
||||
search_tool = create_search_tool()
|
||||
|
||||
# Test file search
|
||||
result = await search_tool.run(pattern="*.py", search_files=True, max_results_per_type=10)
|
||||
@@ -151,20 +151,20 @@ async def test_find_tool_fuzzy_search():
|
||||
|
||||
# Test fuzzy matching
|
||||
result = await find_tool.run(
|
||||
pattern="unifd", # Misspelled "unified"
|
||||
pattern="srchtl", # Misspelled "search_tool"
|
||||
fuzzy=True,
|
||||
max_results=5,
|
||||
)
|
||||
|
||||
assert result.data is not None
|
||||
# Fuzzy search might find "unified_search.py"
|
||||
# Fuzzy search might find "search_tool.py"
|
||||
# But results depend on actual files in directory
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_search_pagination():
|
||||
"""Test pagination in unified search."""
|
||||
search_tool = create_unified_search_tool()
|
||||
async def test_search_pagination():
|
||||
"""Test pagination in search."""
|
||||
search_tool = create_search_tool()
|
||||
|
||||
# First page
|
||||
result1 = await search_tool.run(pattern="def", page_size=5, page=1)
|
||||
@@ -195,8 +195,21 @@ async def test_unified_search_pagination():
|
||||
assert files1 != files2 or len(files1) == 0 or len(files2) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_vector_opt_in():
|
||||
"""Test that vector search is opt-in and disabled by default."""
|
||||
# Default: vector search disabled
|
||||
search_tool = create_search_tool()
|
||||
assert search_tool._enable_vector_index is False
|
||||
assert search_tool._vector_initialized is False
|
||||
|
||||
# Explicitly enable vector search
|
||||
search_tool_with_vector = create_search_tool(enable_vector_index=True)
|
||||
assert search_tool_with_vector._enable_vector_index is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests
|
||||
asyncio.run(test_unified_search_basic())
|
||||
asyncio.run(test_search_basic())
|
||||
asyncio.run(test_find_tool_basic())
|
||||
print("Basic tests passed!")
|
||||
Reference in New Issue
Block a user