diff --git a/README.md b/README.md index 0673158..b5c52d8 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ The server provides the following tools: #### Exploration Operations - **search_files**: Search for files by regex pattern in the project directory. Supports optional root directory and output length limits. - **search_text**: Search for lines in files matching a given pattern. Supports file filtering, context lines, and output length limits. +- **search_regex**: Search for regex patterns in specified files only. Requires a list of files to search, supports context lines and output length limits. - **read_lines**: Read specific lines or a range from a file. Supports line range selection and output length limits. #### Predefined Commands @@ -187,6 +188,20 @@ async def example(): "context": 2 }) + # Search for regex patterns in specific files only + result = await client.call_tool("search_regex", { + "pattern": "def\\s+\\w+\\(", + "files": ["main.py", "utils.py"] + }) + + # Search regex with context lines + result = await client.call_tool("search_regex", { + "pattern": "class\\s+\\w+", + "files": ["src/models.py"], + "context": 3, + "max_chars": 1000 + }) + # Read specific lines from a file result = await client.call_tool("read_lines", {"file_path": "README.md", "start": 1, "end": 10}) diff --git a/dev_kit_mcp_server/tools/__init__.py b/dev_kit_mcp_server/tools/__init__.py index 403af5c..bd10370 100644 --- a/dev_kit_mcp_server/tools/__init__.py +++ b/dev_kit_mcp_server/tools/__init__.py @@ -3,7 +3,7 @@ import importlib.util from .commands import ExecMakeTarget, PredefinedCommands -from .explore import ReadLinesOperation, SearchFilesOperation, SearchTextOperation +from .explore import ReadLinesOperation, SearchFilesOperation, SearchRegexOperation, SearchTextOperation from .file_sys.create import CreateDirOperation from .file_sys.edit import EditFileOperation from .file_sys.move import MoveDirOperation @@ -39,5 +39,6 @@ "PredefinedCommands", "SearchFilesOperation", "SearchTextOperation", + "SearchRegexOperation", "ReadLinesOperation", ] diff --git a/dev_kit_mcp_server/tools/explore/__init__.py b/dev_kit_mcp_server/tools/explore/__init__.py index 8254c50..5f26753 100644 --- a/dev_kit_mcp_server/tools/explore/__init__.py +++ b/dev_kit_mcp_server/tools/explore/__init__.py @@ -2,10 +2,12 @@ from .read_lines import ReadLinesOperation from .search_files import SearchFilesOperation +from .search_regex import SearchRegexOperation from .search_text import SearchTextOperation __all__ = [ "SearchFilesOperation", "SearchTextOperation", + "SearchRegexOperation", "ReadLinesOperation", ] diff --git a/dev_kit_mcp_server/tools/explore/search_regex.py b/dev_kit_mcp_server/tools/explore/search_regex.py new file mode 100644 index 0000000..1f2c5de --- /dev/null +++ b/dev_kit_mcp_server/tools/explore/search_regex.py @@ -0,0 +1,154 @@ +"""Module for targeted regex pattern searching in specific files.""" + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List + +from ...core import AsyncOperation + + +@dataclass +class SearchRegexOperation(AsyncOperation): + """Class to search for regex patterns in specified files only.""" + + name = "search_regex" + + def _search_regex( + self, + pattern: str, + files: List[str], + max_chars: int = 2000, + ) -> Dict[str, Any]: + """Search for regex patterns in specified files. + + Args: + pattern: Regex pattern to match against file content + files: List of file paths to search (required) + max_chars: Maximum characters to return in output + + Returns: + Dictionary with search results + + Raises: + ValueError: If pattern is invalid regex, file paths are invalid, or files list is empty + + """ + if not files: + raise ValueError("Files list cannot be empty. Please specify at least one file to search.") + + # Validate regex pattern + try: + compiled_pattern = re.compile(pattern) + except re.error as e: + raise ValueError(f"Invalid regex pattern: {pattern}. Error: {e}") from e + + # Validate and collect specified files + search_files: List[Path] = [] + for file_str in files: + abs_path = self._validate_path_in_root(self._root_path, file_str) + file_path = Path(abs_path) + if not file_path.exists(): + raise ValueError(f"File does not exist: {file_str}") + if not file_path.is_file(): + raise ValueError(f"Path is not a file: {file_str}") + search_files.append(file_path) + + # Search for matches + matches: List[Dict[str, Any]] = [] + total_files_searched = 0 + total_lines_searched = 0 + + for file_path in search_files: + total_files_searched += 1 + try: + # Try to read as text file + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + + total_lines_searched += len(lines) + + # Find matching lines + for line_num, line in enumerate(lines, 1): + if compiled_pattern.search(line): + # Get relative path from project root + try: + relative_path = file_path.relative_to(self._root_path) + except ValueError: + relative_path = file_path + + match_data = { + "file": str(relative_path), + "line_number": line_num, + "line": line.rstrip("\n\r"), + } + + matches.append(match_data) + + except (UnicodeDecodeError, OSError, PermissionError) as e: + raise ValueError(f"Failed to read file {file_path}: {str(e)}") from e + + # Prepare output + files_list = ", ".join(files) + content_lines = [f"Regex search results for pattern '{pattern}' in files: {files_list}", ""] + + if not matches: + content_lines.append("No matches found") + else: + for match in matches: + # Simple format: file_path:line_number: line_content + content_lines.append(f"{match['file']}:{match['line_number']}: {match['line']}") + + content = "\n".join(content_lines) + total_chars = len(content) + truncated = total_chars > max_chars + + if truncated: + content = content[:max_chars] + + return { + "content": content, + "total_chars": total_chars, + "truncated": truncated, + "matches_found": len(matches), + "files_searched": total_files_searched, + "lines_searched": total_lines_searched, + "pattern": pattern, + "files": files, + } + + async def __call__( + self, + pattern: str, + files: List[str], + max_chars: int = 2000, + ) -> Dict[str, Any]: + """Search for regex patterns in specified files. + + Args: + pattern: Regex pattern to match against file content (required) + files: List of file paths to search (required, cannot be empty) + max_chars: Maximum characters to return in output (optional, default 2000) + + Returns: + A dictionary containing the search results and metadata + + """ + try: + result = self._search_regex(pattern, files, max_chars) + return { + "status": "success", + "message": ( + f"Regex search completed. Found {result['matches_found']} matches " + f"in {result['files_searched']} file(s)." + ), + **result, + } + except Exception as e: + return { + "status": "error", + "message": f"Regex search failed: {str(e)}", + "error": str(e), + "pattern": pattern, + "files": files, + } diff --git a/tests/tools/explore/test_explore_operations.py b/tests/tools/explore/test_explore_operations.py index 291c020..1865a17 100644 --- a/tests/tools/explore/test_explore_operations.py +++ b/tests/tools/explore/test_explore_operations.py @@ -4,7 +4,7 @@ import pytest -from dev_kit_mcp_server.tools import ReadLinesOperation, SearchFilesOperation, SearchTextOperation +from dev_kit_mcp_server.tools import ReadLinesOperation, SearchFilesOperation, SearchRegexOperation, SearchTextOperation @pytest.fixture(scope="function") @@ -25,6 +25,12 @@ def search_text_operation(temp_root_dir: str) -> SearchTextOperation: return SearchTextOperation(root_dir=temp_root_dir) +@pytest.fixture +def search_regex_operation(temp_root_dir: str) -> SearchRegexOperation: + """Create a SearchRegexOperation instance with a temporary root directory.""" + return SearchRegexOperation(root_dir=temp_root_dir) + + @pytest.fixture def read_lines_operation(temp_root_dir: str) -> ReadLinesOperation: """Create a ReadLinesOperation instance with a temporary root directory.""" @@ -383,3 +389,105 @@ async def test_read_lines_subdirectory(self, read_lines_operation, setup_test_fi assert result["lines_returned"] == 3 assert "# Test README" in result["content"] assert os.path.normpath(subdir_path) in result["content"] + + +class TestSearchRegexOperation: + """Test the SearchRegexOperation class.""" + + @pytest.mark.asyncio + async def test_search_regex_single_file(self, search_regex_operation, setup_test_files): + """Test regex search in a single file.""" + result = await search_regex_operation(pattern="search", files=["sample.txt"]) + + assert result["status"] == "success" + assert result["matches_found"] == 2 # Two lines in sample.txt contain "search" + assert result["files_searched"] == 1 + assert "sample.txt:2: Line 2: This contains the word 'search'" in result["content"] + assert "sample.txt:4: Line 4: Final line with search term" in result["content"] + assert not result["truncated"] + + @pytest.mark.asyncio + async def test_search_regex_multiple_files(self, search_regex_operation, setup_test_files): + """Test regex search across multiple files.""" + result = await search_regex_operation(pattern="search", files=["sample.txt", "subdir/README.md", "data.json"]) + + assert result["status"] == "success" + assert result["matches_found"] >= 3 # Should find in all three files + assert result["files_searched"] == 3 + assert "search" in result["content"] + assert not result["truncated"] + + @pytest.mark.asyncio + async def test_search_regex_regex_pattern(self, search_regex_operation, setup_test_files): + """Test regex search with regex pattern.""" + result = await search_regex_operation(pattern="Line \\d+:", files=["sample.txt"]) + + assert result["status"] == "success" + assert result["matches_found"] == 5 # All lines in sample.txt match this pattern + + @pytest.mark.asyncio + async def test_search_regex_no_matches(self, search_regex_operation, setup_test_files): + """Test regex search with no matches.""" + result = await search_regex_operation(pattern="nonexistent_pattern", files=["sample.txt"]) + + assert result["status"] == "success" + assert result["matches_found"] == 0 + assert "No matches found" in result["content"] + + @pytest.mark.asyncio + async def test_search_regex_invalid_regex(self, search_regex_operation, setup_test_files): + """Test regex search with invalid regex pattern.""" + result = await search_regex_operation(pattern="[invalid", files=["sample.txt"]) + + assert result["status"] == "error" + assert "Invalid regex pattern" in result["message"] + + @pytest.mark.asyncio + async def test_search_regex_invalid_file(self, search_regex_operation, setup_test_files): + """Test regex search with invalid file.""" + result = await search_regex_operation(pattern="test", files=["nonexistent.txt"]) + + assert result["status"] == "error" + assert "does not exist" in result["message"] + + @pytest.mark.asyncio + async def test_search_regex_outside_root(self, search_regex_operation, setup_test_files): + """Test regex search with file outside root directory.""" + result = await search_regex_operation(pattern="test", files=["../../etc/passwd"]) + + assert result["status"] == "error" + assert "not within the root directory" in result["message"] + + @pytest.mark.asyncio + async def test_search_regex_empty_files_list(self, search_regex_operation, setup_test_files): + """Test regex search with empty files list.""" + result = await search_regex_operation(pattern="test", files=[]) + + assert result["status"] == "error" + assert "Files list cannot be empty" in result["message"] + + @pytest.mark.asyncio + async def test_search_regex_max_chars(self, search_regex_operation, setup_test_files): + """Test regex search with character limit.""" + result = await search_regex_operation(pattern=".", files=["sample.txt"], max_chars=100) + + assert result["status"] == "success" + assert result["total_chars"] > 100 + assert result["truncated"] + assert len(result["content"]) == 100 + + @pytest.mark.asyncio + async def test_search_regex_case_sensitive(self, search_regex_operation, setup_test_files): + """Test regex search is case sensitive by default.""" + result = await search_regex_operation(pattern="SEARCH", files=["sample.txt"]) + + assert result["status"] == "success" + assert result["matches_found"] == 0 + + @pytest.mark.asyncio + async def test_search_regex_directory_not_file(self, search_regex_operation, setup_test_files): + """Test regex search with directory instead of file.""" + result = await search_regex_operation(pattern="test", files=["subdir"]) + + assert result["status"] == "error" + assert "not a file" in result["message"]