From 8abfe58c67731c58911892c59ff3e7aaa1407110 Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Tue, 14 Oct 2025 04:15:39 -0700 Subject: [PATCH] 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 --- src/serena/text_utils.py | 49 +++++++++++++++--- src/serena/tools/file_tools.py | 2 + test/serena/test_text_utils.py | 93 ++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/serena/text_utils.py b/src/serena/text_utils.py index 4be99d78..19987cee 100644 --- a/src/serena/text_utils.py +++ b/src/serena/text_utils.py @@ -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.") diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 56d2cf94..f3db634e 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -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. diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index d122f6a5..d3701ad9 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -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)