From 954d8554fd551a6d77510c85c95f50b529605798 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 23 Jul 2026 21:43:29 +0200 Subject: [PATCH] Make GlobMatcher with cached regex patterns the only glob matching mechanism --- CHANGELOG.md | 2 +- src/serena/config/serena_config.py | 4 ++-- src/serena/tools/file_tools.py | 11 ++++----- src/serena/util/text_utils.py | 38 +++++++++++++----------------- test/serena/test_text_utils.py | 14 +++++------ 5 files changed, 30 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 054e1de1..7bc6f3c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Status of the `main` branch. Changes prior to the next official version change w - Add Grok Build support (context `grok`, setup CLI, hooks) - The `languages` key in project configurations was changed to `language_servers` to better reflect the actual semantics (configurations are automatically migrated) - - Fix: `glob_match` bare `*` and `?` in non-`**` patterns matched across `/`, contradicting its documented behavior #1732 + - Fix: glob matching bare `*` and `?` in non-`**` patterns matched across `/`, contradicting documented behaviour #1732 * Language Servers: - Allow language server priorities to be configured in `serena_config.yml` (for auto-detection during diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 063cc658..9a169962 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -33,7 +33,7 @@ from serena.constants import ( SERENA_MANAGED_DIR_NAME, ) from serena.util.inspection import compute_language_server_support_composition -from serena.util.text_utils import glob_match +from serena.util.text_utils import GlobMatcher from serena.util.yaml import YamlCommentNormalisation, load_yaml, normalise_yaml_comments, save_yaml, transfer_yaml_comments from solidlsp.ls_config import LanguageServerId @@ -1437,7 +1437,7 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes): """ project_root_str = str(project_root) for pattern in self.trusted_project_path_patterns: - if glob_match(pattern, project_root_str): + if GlobMatcher(pattern).matches(project_root_str): return True return False diff --git a/src/serena/tools/file_tools.py b/src/serena/tools/file_tools.py index 3cc55d82..bd52f5db 100644 --- a/src/serena/tools/file_tools.py +++ b/src/serena/tools/file_tools.py @@ -16,10 +16,9 @@ from serena.tools import SUCCESS_RESULT, EditedFileContext, EditingToolWithDiagn from serena.util.file_system import scan_directory from serena.util.text_utils import ( ContentReplacer, + GlobMatcher, MultiFileContentReplacer, ReplacementOccurrence, - expand_braces, - glob_match, ) from solidlsp.ls_utils import TextUtils @@ -350,13 +349,13 @@ class ReplaceInFilesTool(EditingToolWithDiagnostics): is_ignored_file=self.project.is_ignored_path, relative_to=self.get_project_root(), ) - include_patterns = expand_braces(paths_include_glob.strip()) if paths_include_glob.strip() else None - exclude_patterns = expand_braces(paths_exclude_glob.strip()) if paths_exclude_glob.strip() else None + include_glob_matcher = GlobMatcher(paths_include_glob.strip()) if paths_include_glob.strip() else None + exclude_glob_matcher = GlobMatcher(paths_exclude_glob.strip()) if paths_exclude_glob.strip() else None files: list[tuple[str, str]] = [] for path in sorted(rel_paths): - if include_patterns and not any(glob_match(p, path) for p in include_patterns): + if include_glob_matcher and not include_glob_matcher.matches(path): continue - if exclude_patterns and any(glob_match(p, path) for p in exclude_patterns): + if exclude_glob_matcher and exclude_glob_matcher.matches(path): continue try: files.append((path, self.project.read_file(path))) diff --git a/src/serena/util/text_utils.py b/src/serena/util/text_utils.py index 67053632..b5da601d 100644 --- a/src/serena/util/text_utils.py +++ b/src/serena/util/text_utils.py @@ -4,11 +4,11 @@ import re from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum -from functools import lru_cache from typing import Any, Literal, Self from bs4 import BeautifulSoup from joblib import Parallel, delayed +from sensai.util.string import ToStringMixin from serena.util.file_proxy import FileCollection, FileProxy from solidlsp.ls_utils import TextUtils @@ -185,7 +185,7 @@ def search_text( return matches -def expand_braces(pattern: str) -> list[str]: +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"]. @@ -212,7 +212,6 @@ def expand_braces(pattern: str) -> list[str]: return patterns -@lru_cache(maxsize=256) def _translate_glob_to_regex(pattern: str) -> str: res = [] i = 0 @@ -263,9 +262,9 @@ def _translate_glob_to_regex(pattern: str) -> str: return f"(?s:{inner})\\Z" -def glob_match(pattern: str, path: str) -> bool: +class GlobMatcher(ToStringMixin): """ - Match a file path against a glob pattern. + Supports matching a file path against a glob pattern. Supports standard glob patterns: - * matches any number of characters except / @@ -275,28 +274,23 @@ def glob_match(pattern: str, path: str) -> bool: 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 """ - # normalise backslashes to forward slashes in both path and pattern - pattern = pattern.replace("\\", "/") - path = path.replace("\\", "/") - return bool(re.match(_translate_glob_to_regex(pattern), path)) - - -class GlobMatcher: def __init__(self, expr: str): - self._patterns = expand_braces(expr) + """ + :param expr: a glob pattern which may make use of brace expansion (e.g., "src/**/*.{js,jsx,ts,tsx}") + """ + expr = expr.replace("\\", "/") # normalise backslashes to forward slashes + self._glob_expr = expr + self._glob_patterns = _expand_braces(expr) + self._regex_patterns = [re.compile(_translate_glob_to_regex(p)) for p in self._glob_patterns] + + def _tostring_includes(self) -> list[str]: + return ["_glob_expr"] def matches(self, path: str) -> bool: - return any(glob_match(p, path) for p in self._patterns) + path = path.replace("\\", "/") # normalise backslashes to forward slashes + return any(regex.match(path) for regex in self._regex_patterns) def search_files( diff --git a/test/serena/test_text_utils.py b/test/serena/test_text_utils.py index 3fdcfa12..b9dedd07 100644 --- a/test/serena/test_text_utils.py +++ b/test/serena/test_text_utils.py @@ -3,7 +3,7 @@ from collections.abc import Callable import pytest from serena.util.file_proxy import FileCollection, FileProxy -from serena.util.text_utils import LineType, MultiFileContentReplacer, search_files, search_text +from serena.util.text_utils import GlobMatcher, LineType, MultiFileContentReplacer, search_files, search_text class TestSearchText: @@ -555,9 +555,7 @@ class TestGlobMatch: ) def test_glob_match(self, pattern, path, expected): """Test glob_match function with various patterns.""" - from serena.util.text_utils import glob_match - - assert glob_match(pattern, path) == expected + assert GlobMatcher(pattern).matches(path) == expected class TestExpandBraces: @@ -582,9 +580,9 @@ class TestExpandBraces: ) def test_expand_braces(self, pattern, expected): """Test brace expansion for glob patterns.""" - from serena.util.text_utils import expand_braces + from serena.util.text_utils import _expand_braces - assert sorted(expand_braces(pattern)) == sorted(expected) + assert sorted(_expand_braces(pattern)) == sorted(expected) @pytest.mark.parametrize( "pattern", @@ -596,10 +594,10 @@ class TestExpandBraces: ) def test_expand_braces_rejects_malformed_braces(self, pattern): """Malformed brace globs should fail instead of looping forever.""" - from serena.util.text_utils import expand_braces + from serena.util.text_utils import _expand_braces with pytest.raises(ValueError, match="Invalid glob brace expression"): - expand_braces(pattern) + _expand_braces(pattern) class TestMultiFileContentReplacer: