Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})

Expand Down
3 changes: 2 additions & 1 deletion dev_kit_mcp_server/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,5 +39,6 @@
"PredefinedCommands",
"SearchFilesOperation",
"SearchTextOperation",
"SearchRegexOperation",
"ReadLinesOperation",
]
2 changes: 2 additions & 0 deletions dev_kit_mcp_server/tools/explore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
154 changes: 154 additions & 0 deletions dev_kit_mcp_server/tools/explore/search_regex.py
Original file line number Diff line number Diff line change
@@ -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

Check warning on line 78 in dev_kit_mcp_server/tools/explore/search_regex.py

View check run for this annotation

Codecov / codecov/patch

dev_kit_mcp_server/tools/explore/search_regex.py#L77-L78

Added lines #L77 - L78 were not covered by tests

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

Check warning on line 89 in dev_kit_mcp_server/tools/explore/search_regex.py

View check run for this annotation

Codecov / codecov/patch

dev_kit_mcp_server/tools/explore/search_regex.py#L88-L89

Added lines #L88 - L89 were not covered by tests

# 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,
}
110 changes: 109 additions & 1 deletion tests/tools/explore/test_explore_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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."""
Expand Down Expand Up @@ -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"]