Skip to content

Commit ecf513d

Browse files
committed
Add a /regex command, a corollary to /find and /search
1 parent aa156f4 commit ecf513d

File tree

2 files changed

+136
-0
lines changed

2 files changed

+136
-0
lines changed

aider/commands.py

+56
Original file line numberDiff line numberDiff line change
@@ -1698,6 +1698,62 @@ def cmd_search(self, args):
16981698
self.io.tool_output(f"Added {rel_fname} to the chat")
16991699
self.coder.check_added_files()
17001700

1701+
def cmd_regex(self, args):
1702+
"Search for regex pattern matches in files and add matching files to chat"
1703+
import re
1704+
1705+
if not args.strip():
1706+
self.io.tool_error("Please provide a regex pattern to search for.")
1707+
return
1708+
1709+
pattern = args.strip()
1710+
found_files = set()
1711+
1712+
try:
1713+
regex = re.compile(pattern)
1714+
except re.error as e:
1715+
self.io.tool_error(f"Invalid regex pattern: {e}")
1716+
return
1717+
1718+
# Create a spinner for user feedback
1719+
self.io.tool_output(f"Searching for regex pattern '{pattern}'...")
1720+
chat_files = set(self.coder.abs_fnames)
1721+
other_files = set(self.coder.get_all_abs_files()) - chat_files
1722+
1723+
# Search through all files
1724+
for fname in other_files:
1725+
# Skip binary files
1726+
if is_likely_binary(fname):
1727+
continue
1728+
1729+
# Read file with silent=True to suppress error messages
1730+
content = self.io.read_text(fname, silent=True)
1731+
if content and regex.search(content):
1732+
found_files.add(fname)
1733+
rel_fname = self.coder.get_rel_fname(fname)
1734+
self.io.tool_output(f"Found regex match for '{pattern}' in {rel_fname}")
1735+
1736+
if not found_files:
1737+
self.io.tool_error(f"No files found matching regex pattern '{pattern}'.")
1738+
return
1739+
1740+
# Add the files to the chat
1741+
for abs_file_path in found_files:
1742+
if abs_file_path in self.coder.abs_fnames:
1743+
self.io.tool_output(
1744+
f"{self.coder.get_rel_fname(abs_file_path)} is already in the chat"
1745+
)
1746+
continue
1747+
1748+
content = self.io.read_text(abs_file_path)
1749+
if content is None:
1750+
self.io.tool_error(f"Unable to read {abs_file_path}")
1751+
else:
1752+
self.coder.abs_fnames.add(abs_file_path)
1753+
rel_fname = self.coder.get_rel_fname(abs_file_path)
1754+
self.io.tool_output(f"Added {rel_fname} to the chat")
1755+
self.coder.check_added_files()
1756+
17011757
def cmd_copy_context(self, args=None):
17021758
"""Copy the current chat context as markdown, suitable to paste into a web UI"""
17031759

tests/basic/test_commands.py

+80
Original file line numberDiff line numberDiff line change
@@ -2264,3 +2264,83 @@ def mock_read_text(fname, silent=False):
22642264

22652265
# Verify no files were added
22662266
self.assertEqual(len(coder.abs_fnames), 0)
2267+
2268+
def test_cmd_regex(self):
2269+
from unittest import mock
2270+
2271+
# Create test environment
2272+
with GitTemporaryDirectory() as repo_dir:
2273+
io = InputOutput(pretty=False, fancy_input=False, yes=True)
2274+
coder = Coder.create(self.GPT35, None, io)
2275+
2276+
# Setup test files
2277+
file1_path = Path(repo_dir) / "file1.py"
2278+
file2_path = Path(repo_dir) / "file2.py"
2279+
file3_path = Path(repo_dir) / "file3.py"
2280+
file1_path.write_text("def test_function(): pass")
2281+
file2_path.write_text("# This file contains search_string in a comment")
2282+
file3_path.write_text("def another_function(value=123): return value")
2283+
2284+
# Mock get_all_abs_files to return our test files
2285+
coder.get_all_abs_files = mock.MagicMock(return_value=[
2286+
str(file1_path),
2287+
str(file2_path),
2288+
str(file3_path)
2289+
])
2290+
2291+
# Mock read_text to return the actual file contents
2292+
def mock_read_text(fname, silent=False):
2293+
if str(file1_path) in str(fname):
2294+
return "def test_function(): pass"
2295+
elif str(file2_path) in str(fname):
2296+
return "# This file contains search_string in a comment"
2297+
elif str(file3_path) in str(fname):
2298+
return "def another_function(value=123): return value"
2299+
return None
2300+
2301+
io.read_text = mock.MagicMock(side_effect=mock_read_text)
2302+
2303+
# Run the regex command
2304+
commands = Commands(io, coder)
2305+
2306+
# Test searching with a regex pattern that matches multiple files
2307+
commands.cmd_regex(r"def\s+\w+\(")
2308+
2309+
# Check that the expected files were added to the chat
2310+
self.assertEqual(len(coder.abs_fnames), 2)
2311+
2312+
# Convert abs_fnames to a list of rel paths for easier comparison
2313+
added_files = [str(Path(path).name) for path in coder.abs_fnames]
2314+
self.assertIn("file1.py", added_files)
2315+
self.assertIn("file3.py", added_files)
2316+
self.assertNotIn("file2.py", added_files)
2317+
2318+
# Clear added files
2319+
coder.abs_fnames.clear()
2320+
2321+
# Test searching with a regex that only matches one file
2322+
commands.cmd_regex(r"value=\d+")
2323+
2324+
# Check that only the file with the pattern was added
2325+
self.assertEqual(len(coder.abs_fnames), 1)
2326+
added_files = [str(Path(path).name) for path in coder.abs_fnames]
2327+
self.assertIn("file3.py", added_files)
2328+
self.assertNotIn("file1.py", added_files)
2329+
self.assertNotIn("file2.py", added_files)
2330+
2331+
# Clear added files
2332+
coder.abs_fnames.clear()
2333+
2334+
# Test with an invalid regex pattern
2335+
with mock.patch.object(io, "tool_error") as mock_tool_error:
2336+
commands.cmd_regex(r"[invalid regex")
2337+
mock_tool_error.assert_called_with(mock.ANY) # Should call tool_error with an error message
2338+
2339+
# Test searching for a non-matching pattern
2340+
coder.abs_fnames.clear()
2341+
with mock.patch.object(io, "tool_error") as mock_tool_error:
2342+
commands.cmd_regex(r"nonexistent_pattern\d+")
2343+
mock_tool_error.assert_called_with("No files found matching regex pattern 'nonexistent_pattern\\d+'.")
2344+
2345+
# Verify no files were added
2346+
self.assertEqual(len(coder.abs_fnames), 0)

0 commit comments

Comments
 (0)