mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 06:15:43 +00:00
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:
1 parent
675a421287
commit
8abfe58c67
3 files changed
+138
-6
No files matched your search
@@ -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.")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user