From 43a430d0086d01413896e2ea7a1241ea5af25368 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:36:03 +0000 Subject: [PATCH 1/3] Initial plan for issue From 7dad8882ee8ca0d9c64ada4508665b1c2dfadf69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:45:47 +0000 Subject: [PATCH 2/3] Complete SearchRegexOperation implementation with documentation - Update README.md with search_regex tool documentation and examples - Tool is fully functional and tested with all edge cases covered - Maintains compatibility with existing tools and follows conventions Co-authored-by: eh-main-bot <171766998+eh-main-bot@users.noreply.github.com> --- README.md | 15 ++ dev_kit_mcp_server/tools/__init__.py | 3 +- dev_kit_mcp_server/tools/explore/__init__.py | 2 + .../tools/explore/search_regex.py | 181 ++++++++++++++++++ .../tools/explore/test_explore_operations.py | 121 +++++++++++- 5 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 dev_kit_mcp_server/tools/explore/search_regex.py 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..ba4764d --- /dev/null +++ b/dev_kit_mcp_server/tools/explore/search_regex.py @@ -0,0 +1,181 @@ +"""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, Optional + +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], + context: Optional[int] = None, + 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) + context: Number of context lines to include before/after matches (optional) + 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"), + } + + # Add context lines if requested + if context is not None and context > 0: + start_line = max(0, line_num - 1 - context) + end_line = min(len(lines), line_num + context) + + context_lines = [] + for i in range(start_line, end_line): + context_lines.append({ + "line_number": i + 1, + "line": lines[i].rstrip("\n\r"), + "is_match": i == line_num - 1, + }) + match_data["context"] = context_lines + + 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: + if context is not None and "context" in match: + content_lines.append(f"=== {match['file']} ===") + for ctx in match["context"]: + prefix = ">>> " if ctx["is_match"] else " " + content_lines.append(f"{prefix}{ctx['line_number']:4d}: {ctx['line']}") + content_lines.append("") + else: + # 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, + "context": context, + "files": files, + } + + async def __call__( + self, + pattern: str, + files: List[str], + context: Optional[int] = None, + 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) + context: Number of context lines to include before/after matches (optional) + 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, context, 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, + "context": context, + } diff --git a/tests/tools/explore/test_explore_operations.py b/tests/tools/explore/test_explore_operations.py index 291c020..0b5c3fe 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,116 @@ 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_with_context(self, search_regex_operation, setup_test_files): + """Test regex search with context lines.""" + result = await search_regex_operation(pattern="search", files=["sample.txt"], context=1) + + assert result["status"] == "success" + assert result["matches_found"] == 2 + assert "=== sample.txt ===" in result["content"] + assert ">>>" in result["content"] # Context marker for match lines + assert " " in result["content"] # Context marker for non-match lines + + @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"] From 1c6a77d1a11fc71e734a15d689461fa9e747859b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:23:56 +0000 Subject: [PATCH 3/3] Remove context parameter from search_regex to simplify implementation Co-authored-by: eh-main-bot <171766998+eh-main-bot@users.noreply.github.com> --- .../tools/explore/search_regex.py | 35 +++---------------- .../tools/explore/test_explore_operations.py | 11 ------ 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/dev_kit_mcp_server/tools/explore/search_regex.py b/dev_kit_mcp_server/tools/explore/search_regex.py index ba4764d..1f2c5de 100644 --- a/dev_kit_mcp_server/tools/explore/search_regex.py +++ b/dev_kit_mcp_server/tools/explore/search_regex.py @@ -3,7 +3,7 @@ import re from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List from ...core import AsyncOperation @@ -18,7 +18,6 @@ def _search_regex( self, pattern: str, files: List[str], - context: Optional[int] = None, max_chars: int = 2000, ) -> Dict[str, Any]: """Search for regex patterns in specified files. @@ -26,7 +25,6 @@ def _search_regex( Args: pattern: Regex pattern to match against file content files: List of file paths to search (required) - context: Number of context lines to include before/after matches (optional) max_chars: Maximum characters to return in output Returns: @@ -85,20 +83,6 @@ def _search_regex( "line": line.rstrip("\n\r"), } - # Add context lines if requested - if context is not None and context > 0: - start_line = max(0, line_num - 1 - context) - end_line = min(len(lines), line_num + context) - - context_lines = [] - for i in range(start_line, end_line): - context_lines.append({ - "line_number": i + 1, - "line": lines[i].rstrip("\n\r"), - "is_match": i == line_num - 1, - }) - match_data["context"] = context_lines - matches.append(match_data) except (UnicodeDecodeError, OSError, PermissionError) as e: @@ -112,15 +96,8 @@ def _search_regex( content_lines.append("No matches found") else: for match in matches: - if context is not None and "context" in match: - content_lines.append(f"=== {match['file']} ===") - for ctx in match["context"]: - prefix = ">>> " if ctx["is_match"] else " " - content_lines.append(f"{prefix}{ctx['line_number']:4d}: {ctx['line']}") - content_lines.append("") - else: - # Simple format: file_path:line_number: line_content - content_lines.append(f"{match['file']}:{match['line_number']}: {match['line']}") + # 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) @@ -137,7 +114,6 @@ def _search_regex( "files_searched": total_files_searched, "lines_searched": total_lines_searched, "pattern": pattern, - "context": context, "files": files, } @@ -145,7 +121,6 @@ async def __call__( self, pattern: str, files: List[str], - context: Optional[int] = None, max_chars: int = 2000, ) -> Dict[str, Any]: """Search for regex patterns in specified files. @@ -153,7 +128,6 @@ async def __call__( Args: pattern: Regex pattern to match against file content (required) files: List of file paths to search (required, cannot be empty) - context: Number of context lines to include before/after matches (optional) max_chars: Maximum characters to return in output (optional, default 2000) Returns: @@ -161,7 +135,7 @@ async def __call__( """ try: - result = self._search_regex(pattern, files, context, max_chars) + result = self._search_regex(pattern, files, max_chars) return { "status": "success", "message": ( @@ -177,5 +151,4 @@ async def __call__( "error": str(e), "pattern": pattern, "files": files, - "context": context, } diff --git a/tests/tools/explore/test_explore_operations.py b/tests/tools/explore/test_explore_operations.py index 0b5c3fe..1865a17 100644 --- a/tests/tools/explore/test_explore_operations.py +++ b/tests/tools/explore/test_explore_operations.py @@ -417,17 +417,6 @@ async def test_search_regex_multiple_files(self, search_regex_operation, setup_t assert "search" in result["content"] assert not result["truncated"] - @pytest.mark.asyncio - async def test_search_regex_with_context(self, search_regex_operation, setup_test_files): - """Test regex search with context lines.""" - result = await search_regex_operation(pattern="search", files=["sample.txt"], context=1) - - assert result["status"] == "success" - assert result["matches_found"] == 2 - assert "=== sample.txt ===" in result["content"] - assert ">>>" in result["content"] # Context marker for match lines - assert " " in result["content"] # Context marker for non-match lines - @pytest.mark.asyncio async def test_search_regex_regex_pattern(self, search_regex_operation, setup_test_files): """Test regex search with regex pattern."""