Skip to content

Commit aa156f4

Browse files
committed
Add a /search command for string-matching to add files
1 parent 5da4e18 commit aa156f4

File tree

3 files changed

+179
-1
lines changed

3 files changed

+179
-1
lines changed

aider/commands.py

+50-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from aider.repo import ANY_GIT_ERROR
2323
from aider.run_cmd import run_cmd
2424
from aider.scrape import Scraper, install_playwright
25-
from aider.utils import is_image_file
25+
from aider.utils import is_image_file, is_likely_binary
2626

2727
from .dump import dump # noqa: F401
2828

@@ -1649,6 +1649,55 @@ def completions_find(self):
16491649

16501650
return sorted(symbols)
16511651

1652+
def cmd_search(self, args):
1653+
"Search for exact string matches in files and add matching files to chat"
1654+
1655+
if not args.strip():
1656+
self.io.tool_error("Please provide a text string to search for.")
1657+
return
1658+
1659+
search_string = args.strip()
1660+
found_files = set()
1661+
1662+
# Create a spinner for user feedback
1663+
self.io.tool_output(f"Searching for '{search_string}'...")
1664+
chat_files = set(self.coder.abs_fnames)
1665+
other_files = set(self.coder.get_all_abs_files()) - chat_files
1666+
1667+
# Search through all files
1668+
for fname in other_files:
1669+
# Skip binary files
1670+
if is_likely_binary(fname):
1671+
continue
1672+
1673+
# Read file with silent=True to suppress error messages
1674+
content = self.io.read_text(fname, silent=True)
1675+
if content and search_string in content:
1676+
found_files.add(fname)
1677+
rel_fname = self.coder.get_rel_fname(fname)
1678+
self.io.tool_output(f"Found '{search_string}' in {rel_fname}")
1679+
1680+
if not found_files:
1681+
self.io.tool_error(f"No files found containing '{search_string}'.")
1682+
return
1683+
1684+
# Add the files to the chat
1685+
for abs_file_path in found_files:
1686+
if abs_file_path in self.coder.abs_fnames:
1687+
self.io.tool_output(
1688+
f"{self.coder.get_rel_fname(abs_file_path)} is already in the chat"
1689+
)
1690+
continue
1691+
1692+
content = self.io.read_text(abs_file_path)
1693+
if content is None:
1694+
self.io.tool_error(f"Unable to read {abs_file_path}")
1695+
else:
1696+
self.coder.abs_fnames.add(abs_file_path)
1697+
rel_fname = self.coder.get_rel_fname(abs_file_path)
1698+
self.io.tool_output(f"Added {rel_fname} to the chat")
1699+
self.coder.check_added_files()
1700+
16521701
def cmd_copy_context(self, args=None):
16531702
"""Copy the current chat context as markdown, suitable to paste into a web UI"""
16541703

aider/utils.py

+55
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@
1212

1313
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp", ".pdf"}
1414

15+
# Extensions for files that are likely binary
16+
BINARY_EXTENSIONS = {
17+
# Images
18+
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".icns", ".webp", ".tiff", ".svg",
19+
# Fonts
20+
".ttf", ".otf", ".woff", ".woff2", ".eot",
21+
# Audio/Video
22+
".mp3", ".mp4", ".wav", ".ogg", ".flac", ".avi", ".mov", ".mkv",
23+
# Archives
24+
".zip", ".tar", ".gz", ".rar", ".7z", ".iso", ".jar",
25+
# Compiled files
26+
".exe", ".dll", ".so", ".dylib", ".class", ".pyc",
27+
# Other binary formats
28+
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".db", ".bin"
29+
}
30+
1531

1632
class IgnorantTemporaryDirectory:
1733
def __init__(self):
@@ -93,6 +109,45 @@ def is_image_file(file_name):
93109
return any(file_name.endswith(ext) for ext in IMAGE_EXTENSIONS)
94110

95111

112+
def is_likely_binary(file_name):
113+
"""
114+
Check if a file is likely binary based on extension and content.
115+
116+
:param file_name: The name of the file to check.
117+
:return: True if the file is likely binary, False otherwise.
118+
"""
119+
file_name = str(file_name) # Convert file_name to string
120+
121+
# Check for directory
122+
if os.path.isdir(file_name):
123+
return True
124+
125+
# Check extension first (faster)
126+
ext = os.path.splitext(file_name)[1].lower()
127+
if ext in BINARY_EXTENSIONS:
128+
return True
129+
130+
# For files without telling extensions, check content
131+
try:
132+
# Read first 1024 bytes to check for binary content
133+
with open(file_name, 'rb') as f:
134+
chunk = f.read(1024)
135+
# Check for null bytes (common in binary files)
136+
if b'\x00' in chunk:
137+
return True
138+
139+
# Heuristic: If there are many non-ASCII bytes, it's likely binary
140+
# Count bytes that are not ASCII printable or common control chars
141+
non_text = sum(1 for b in chunk if b < 9 or 13 < b < 32 or b > 126)
142+
if len(chunk) > 0 and non_text / len(chunk) > 0.3: # >30% non-text chars
143+
return True
144+
except (IOError, OSError):
145+
# If we can't read the file, assume it's not binary to be safe
146+
return False
147+
148+
return False
149+
150+
96151
def safe_abs_path(res):
97152
"Gives an abs path, which safely returns a full (not 8.3) windows path"
98153
res = Path(res).resolve()

tests/basic/test_commands.py

+74
Original file line numberDiff line numberDiff line change
@@ -2190,3 +2190,77 @@ def mock_get_tags(fname, rel_fname):
21902190

21912191
# Verify no files were added
21922192
self.assertEqual(len(coder.abs_fnames), 0)
2193+
2194+
def test_cmd_search(self):
2195+
from unittest import mock
2196+
2197+
# Create test environment
2198+
with GitTemporaryDirectory() as repo_dir:
2199+
io = InputOutput(pretty=False, fancy_input=False, yes=True)
2200+
coder = Coder.create(self.GPT35, None, io)
2201+
2202+
# Setup test files
2203+
file1_path = Path(repo_dir) / "file1.py"
2204+
file2_path = Path(repo_dir) / "file2.py"
2205+
file3_path = Path(repo_dir) / "file3.py" # File without the search string
2206+
file1_path.write_text("def test_function(): pass")
2207+
file2_path.write_text("# This file contains search_string in a comment")
2208+
file3_path.write_text("# This file has no reference to the search term")
2209+
2210+
# Mock get_all_abs_files to return our test files
2211+
coder.get_all_abs_files = mock.MagicMock(return_value=[
2212+
str(file1_path),
2213+
str(file2_path),
2214+
str(file3_path)
2215+
])
2216+
2217+
# Mock read_text to return the actual file contents
2218+
def mock_read_text(fname, silent=False):
2219+
if str(file1_path) in str(fname):
2220+
return "def test_function(): pass"
2221+
elif str(file2_path) in str(fname):
2222+
return "# This file contains search_string in a comment"
2223+
elif str(file3_path) in str(fname):
2224+
return "# This file has no reference to the search term"
2225+
return None
2226+
2227+
io.read_text = mock.MagicMock(side_effect=mock_read_text)
2228+
2229+
# Run the search command
2230+
commands = Commands(io, coder)
2231+
# Test searching for a string that exists
2232+
commands.cmd_search("search_string")
2233+
2234+
# Check that the expected files were added to the chat
2235+
self.assertEqual(len(coder.abs_fnames), 1)
2236+
2237+
# Convert abs_fnames to a list of rel paths for easier comparison
2238+
added_files = [str(Path(path).name) for path in coder.abs_fnames]
2239+
self.assertIn("file2.py", added_files)
2240+
self.assertNotIn("file1.py", added_files)
2241+
self.assertNotIn("file3.py", added_files)
2242+
2243+
# Clear added files
2244+
coder.abs_fnames.clear()
2245+
2246+
# Test searching for a string in a single file
2247+
io.read_text = mock.MagicMock(side_effect=mock_read_text)
2248+
commands.cmd_search("test_function")
2249+
2250+
# Check that only the file with "test_function" was added
2251+
self.assertEqual(len(coder.abs_fnames), 1)
2252+
added_files = [str(Path(path).name) for path in coder.abs_fnames]
2253+
self.assertIn("file1.py", added_files)
2254+
self.assertNotIn("file2.py", added_files)
2255+
self.assertNotIn("file3.py", added_files)
2256+
2257+
# Clear added files
2258+
coder.abs_fnames.clear()
2259+
2260+
# Test searching for a non-existent string
2261+
with mock.patch.object(io, "tool_error") as mock_tool_error:
2262+
commands.cmd_search("nonexistent_string")
2263+
mock_tool_error.assert_called_with("No files found containing 'nonexistent_string'.")
2264+
2265+
# Verify no files were added
2266+
self.assertEqual(len(coder.abs_fnames), 0)

0 commit comments

Comments
 (0)