feat(text_utils): add brace expansion for glob patterns (#652)

* adds expand_braces function to handle glob patterns
* updates search_files to use it for include/exclude patterns
* allows for flexible file filtering `**/*.{js,ts}` to match ts/js
This commit is contained in:
Drew Volz authored and GitHub committed 2025-10-14 13:15:39 +02:00
1 parent 675a421287
commit 8abfe58c67
3 files changed
+138 -6

No files matched your search

+43 -6
View File
@@ -241,6 +241,29 @@ def default_file_reader(file_path: str) -> str:
return f.read()
def expand_braces(pattern: str) -> list[str]:
"""
Expands brace patterns in a glob string.
For example, "**/*.{js,jsx,ts,tsx}" becomes ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"].
Handles multiple brace sets as well.
"""
patterns = [pattern]
while any("{" in p for p in patterns):
new_patterns = []
for p in patterns:
match = re.search(r"\{([^{}]+)\}", p)
if match:
prefix = p[: match.start()]
suffix = p[match.end() :]
options = match.group(1).split(",")
for option in options:
new_patterns.append(f"{prefix}{option}{suffix}")
else:
new_patterns.append(p)
patterns = new_patterns
return patterns
def glob_match(pattern: str, path: str) -> bool:
"""
Match a file path against a glob pattern.
@@ -251,6 +274,13 @@ def glob_match(pattern: str, path: str) -> bool:
- ? matches a single character except /
- [seq] matches any character in seq
Supports brace expansion:
- {a,b,c} expands to multiple patterns (including nesting)
Unsupported patterns:
- Bash extended glob features are unavailable in Python's fnmatch
- Extended globs like !(), ?(), +(), *(), @() are not supported
:param pattern: Glob pattern (e.g., 'src/**/*.py', '**agent.py')
:param path: File path to match against
:return: True if path matches pattern
@@ -313,14 +343,21 @@ def search_files(
"""
# Pre-filter paths (done sequentially to avoid overhead)
# Use proper glob matching instead of gitignore patterns
include_patterns = expand_braces(paths_include_glob) if paths_include_glob else None
exclude_patterns = expand_braces(paths_exclude_glob) if paths_exclude_glob else None
filtered_paths = []
for path in relative_file_paths:
if paths_include_glob and not glob_match(paths_include_glob, path):
log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}")
continue
if paths_exclude_glob and glob_match(paths_exclude_glob, path):
log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}")
continue
if include_patterns:
if not any(glob_match(p, path) for p in include_patterns):
log.debug(f"Skipping {path}: does not match include pattern {paths_include_glob}")
continue
if exclude_patterns:
if any(glob_match(p, path) for p in exclude_patterns):
log.debug(f"Skipping {path}: matches exclude pattern {paths_exclude_glob}")
continue
filtered_paths.append(path)
log.info(f"Processing {len(filtered_paths)} files.")
+2
View File
@@ -339,9 +339,11 @@ class SearchForPatternTool(Tool):
:param context_lines_after: Number of lines of context to include after each match
:param paths_include_glob: optional glob pattern specifying files to include in the search.
Matches against relative file paths from the project root (e.g., "*.py", "src/**/*.ts").
Supports standard glob patterns (*, ?, [seq], **, etc.) and brace expansion {a,b,c}.
Only matches files, not directories. If left empty, all non-ignored files will be included.
:param paths_exclude_glob: optional glob pattern specifying files to exclude from the search.
Matches against relative file paths from the project root (e.g., "*test*", "**/*_generated.py").
Supports standard glob patterns (*, ?, [seq], **, etc.) and brace expansion {a,b,c}.
Takes precedence over paths_include_glob. Only matches files, not directories. If left empty, no files are excluded.
:param relative_path: only subpaths of this path (relative to the repo root) will be analyzed. If a path to a single
file is passed, only that will be searched. The path must exist, otherwise a `FileNotFoundError` is raised.
+93
View File
@@ -358,6 +358,72 @@ class TestSearchFiles:
assert result.matched_lines[0].line_content == "This line contains a match."
assert result.matched_lines[0].match_type == LineType.MATCH
@pytest.mark.parametrize(
"file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description",
[
# Brace expansion in include glob
(
["a.py", "b.js", "c.txt"],
"match",
"*.{py,js}",
None,
["a.py", "b.js"],
"Brace expansion in include glob",
),
# Brace expansion in exclude glob
(
["a.py", "b.log", "c.txt"],
"match",
"*.{py,log,txt}",
"*.{log,txt}",
["a.py"],
"Brace expansion in exclude glob",
),
# Brace expansion in both include and exclude
(
["src/a.ts", "src/b.js", "test/a.ts", "test/b.js"],
"match",
"**/*.{ts,js}",
"test/**/*.{ts,js}",
["src/a.ts", "src/b.js"],
"Brace expansion in both include and exclude",
),
# No matching files with brace expansion
(
["a.py", "b.js"],
"match",
"*.{c,h}",
None,
[],
"Brace expansion with no matching files",
),
# Multiple brace expansions
(
["src/a/a.py", "src/b/b.py", "lib/a/a.py", "lib/b/b.py"],
"match",
"{src,lib}/{a,b}/*.py",
"lib/b/*.py",
["src/a/a.py", "src/b/b.py", "lib/a/a.py"],
"Multiple brace expansions in include/exclude",
),
],
ids=lambda x: x if isinstance(x, str) else "",
)
def test_search_files_with_brace_expansion(
self, file_paths, pattern, paths_include_glob, paths_exclude_glob, expected_matched_files, description
):
"""Test search_files with glob patterns containing brace expansions."""
results = search_files(
relative_file_paths=file_paths,
pattern=pattern,
file_reader=mock_reader_always_match,
paths_include_glob=paths_include_glob,
paths_exclude_glob=paths_exclude_glob,
)
actual_matched_files = sorted([result.source_file_path for result in results if result.source_file_path])
assert actual_matched_files == sorted(expected_matched_files), f"Test failed: {description}"
def test_search_files_no_pattern_match_in_content(self):
"""Test that no results are returned if the pattern doesn't match the file content, even if files pass filters."""
file_paths = ["a.py", "b.txt"]
@@ -482,3 +548,30 @@ class TestGlobMatch:
from src.serena.text_utils import glob_match
assert glob_match(pattern, path) == expected
class TestExpandBraces:
"""Test the expand_braces function."""
@pytest.mark.parametrize(
"pattern, expected",
[
# Basic case
("src/*.{js,ts}", ["src/*.js", "src/*.ts"]),
# No braces
("src/*.py", ["src/*.py"]),
# Multiple brace sets
("src/{a,b}/{c,d}.py", ["src/a/c.py", "src/a/d.py", "src/b/c.py", "src/b/d.py"]),
# Empty string
("", [""]),
# Braces with empty elements
("src/{a,,b}.py", ["src/a.py", "src/.py", "src/b.py"]),
# No commas
("src/{a}.py", ["src/a.py"]),
],
)
def test_expand_braces(self, pattern, expected):
"""Test brace expansion for glob patterns."""
from serena.text_utils import expand_braces
assert sorted(expand_braces(pattern)) == sorted(expected)