diff --git a/src/orgecc/filematcher/core/file_matcher_base.py b/src/orgecc/filematcher/core/file_matcher_base.py index 896c2b5..28897a2 100644 --- a/src/orgecc/filematcher/core/file_matcher_base.py +++ b/src/orgecc/filematcher/core/file_matcher_base.py @@ -1,20 +1,30 @@ from typing import override from functools import lru_cache -from ..file_matcher_api import FileMatcher, FileMatcherFactory, DenyPatternSource +from ..file_matcher_api import FileMatcher, FileMatcherFactory, DenyPatternSource, AllowPatternSource class FileMatcherFactoryBase(FileMatcherFactory): - def _new_matcher(self, patterns: tuple[str, ...]) -> FileMatcher: ... + def _new_matcher( + self, deny_patterns: tuple[str, ...], + allow_patterns: tuple[str, ...] = tuple() + ) -> FileMatcher: ... @lru_cache(maxsize=128) - def _cached_pattern2matcher(self, patterns: tuple[str, ...]) -> FileMatcher: - return self._new_matcher(patterns) + def _cached_pattern2matcher( + self, deny_patterns: tuple[str, ...], + allow_patterns: tuple[str, ...] = tuple() + ) -> FileMatcher: + return self._new_matcher(deny_patterns, allow_patterns) @override def pattern2matcher( self, - deny_source: DenyPatternSource + deny_source: DenyPatternSource, + allow_source: AllowPatternSource | None = None ) -> FileMatcher: - return self._cached_pattern2matcher(deny_source.deny_patterns) + return self._cached_pattern2matcher( + deny_source.deny_patterns, + allow_source.allow_patterns if allow_source is not None else tuple() + ) diff --git a/src/orgecc/filematcher/core/file_matcher_ext_gitignorefile.py b/src/orgecc/filematcher/core/file_matcher_ext_gitignorefile.py index f1a44e3..87023cf 100644 --- a/src/orgecc/filematcher/core/file_matcher_ext_gitignorefile.py +++ b/src/orgecc/filematcher/core/file_matcher_ext_gitignorefile.py @@ -8,7 +8,7 @@ class ExtLibGitignorefileMatcherFactory(FileMatcherFactoryBase): """ This factory creates matchers that delegate pattern matching to the - external library 'gitignorefile'. + external library 'gitignorefile' """ def __enter__(self): """Context manager entry point.""" @@ -19,17 +19,18 @@ def __exit__(self, exc_type, exc_val, exc_tb): pass @override - def _new_matcher(self, patterns: tuple[str, ...]) -> FileMatcher: + def _new_matcher(self, deny_patterns: tuple[str, ...], allow_patterns=tuple()) -> FileMatcher: """ Create a new matcher instance for the given patterns. Args: - patterns: A tuple of gitignore pattern strings. + deny_patterns: A tuple of gitignore pattern strings. Returns: A FileMatcher instance configured with the given patterns. + :param allow_patterns: """ - return _ExtLibGitignorefileMatcher(patterns) + return _ExtLibGitignorefileMatcher(deny_patterns) class _ExtLibGitignorefileMatcher(FileMatcher): diff --git a/src/orgecc/filematcher/core/file_matcher_ext_pathspec.py b/src/orgecc/filematcher/core/file_matcher_ext_pathspec.py index e6b3bc7..5b9c295 100644 --- a/src/orgecc/filematcher/core/file_matcher_ext_pathspec.py +++ b/src/orgecc/filematcher/core/file_matcher_ext_pathspec.py @@ -19,17 +19,18 @@ def __exit__(self, exc_type, exc_val, exc_tb): pass @override - def _new_matcher(self, patterns: tuple[str, ...]) -> FileMatcher: + def _new_matcher(self, deny_patterns: tuple[str, ...], allow_patterns=tuple()) -> FileMatcher: """ Create a new matcher instance for the given patterns. Args: - patterns: A tuple of gitignore pattern strings. + deny_patterns: A tuple of gitignore pattern strings. Returns: A FileMatcher instance configured with the given patterns. + :param allow_patterns: """ - return _ExtLibPathspecMatcher(patterns) + return _ExtLibPathspecMatcher(deny_patterns) class _ExtLibPathspecMatcher(FileMatcher): diff --git a/src/orgecc/filematcher/core/file_matcher_git.py b/src/orgecc/filematcher/core/file_matcher_git.py index 631abf8..8d3c309 100644 --- a/src/orgecc/filematcher/core/file_matcher_git.py +++ b/src/orgecc/filematcher/core/file_matcher_git.py @@ -59,11 +59,11 @@ def __exit__(self, exc_type, exc_val, exc_tb): pass @override - def _new_matcher(self, patterns: tuple[str, ...]) -> FileMatcher: + def _new_matcher(self, deny_patterns: tuple[str, ...], allow_patterns=tuple()) -> FileMatcher: with self._lock: self._instance_counter += 1 instance_id = self._instance_counter - return _GitIgnoreNativeMatcher(patterns, instance_id, self) + return _GitIgnoreNativeMatcher(deny_patterns, instance_id, self) def cleanup_matcher(self, instance_id: int) -> None: if self._temp_dir: diff --git a/src/orgecc/filematcher/core/file_matcher_python.py b/src/orgecc/filematcher/core/file_matcher_python.py index 2f1f8b0..125dc27 100644 --- a/src/orgecc/filematcher/core/file_matcher_python.py +++ b/src/orgecc/filematcher/core/file_matcher_python.py @@ -27,17 +27,18 @@ def __exit__(self, exc_type, exc_val, exc_tb): pass @override - def _new_matcher(self, patterns: tuple[str, ...]) -> FileMatcher: + def _new_matcher(self, deny_patterns: tuple[str, ...], allow_patterns: tuple[str, ...] = tuple()) -> FileMatcher: """ Create a new matcher instance for the given patterns. Args: - patterns: A tuple of gitignore pattern strings. + deny_patterns: A tuple of gitignore pattern strings. Returns: A FileMatcher instance configured with the given patterns. + :param allow_patterns: """ - return _GitIgnorePythonMatcher(patterns) + return _GitIgnorePythonMatcher(deny_patterns, allow_patterns) @lru_cache(maxsize=512) def gitignore_syntax_2_fnmatch( @@ -318,25 +319,38 @@ class _GitIgnorePythonMatcher(FileMatcher): behavior of .gitignore files. """ - __slots__ = ('patterns', 'base_path') + __slots__ = ('deny_patterns', 'allow_patterns', 'base_path') - def __init__(self, patterns: tuple[str, ...], base_path: str = "."): + def __init__( + self, + deny_patterns: tuple[str, ...], + allow_patterns: tuple[str, ...] = tuple(), + base_path: str = "." + ): """ Initialize GitIgnoreParser with a list of patterns. Args: - patterns: list of gitignore pattern strings. + deny_patterns: list of gitignore pattern strings. base_path: Base directory for relative patterns. """ - self.patterns: list[FilePattern] = [] + self.deny_patterns: list[FilePattern] = [] + self.allow_patterns: list[FilePattern] = [] self.base_path = Path(base_path).resolve() - for pattern_str in patterns: + for pattern_str in deny_patterns: parsed = FilePattern.from_line(pattern_str) # Debug: Log the result of parsing each pattern - logging.debug("[_parse_pattern] '%s' -> %s", pattern_str, parsed) + logging.debug("[_parse_pattern deny] '%s' -> %s", pattern_str, parsed) if parsed is not None: - self.patterns.append(parsed) + self.deny_patterns.append(parsed) + + for pattern_str in allow_patterns: + parsed = FilePattern.from_line(pattern_str) + # Debug: Log the result of parsing each pattern + logging.debug("[_parse_pattern allow] '%s' -> %s", pattern_str, parsed) + if parsed is not None: + self.allow_patterns.append(parsed) @override def match(self, path: str, is_dir: bool=False) -> FileMatchResult: @@ -355,11 +369,25 @@ def match(self, path: str, is_dir: bool=False) -> FileMatchResult: # Last match wins _match = None - for file_pattern in self.patterns: + for file_pattern in self.deny_patterns: + result = file_pattern.match(path, path_is_dir) + if result.matches: + _match = result._replace(matches=not file_pattern.is_negative) + if _match.matches and _match.by_dir: + _match = _match._replace(description=f"{_match.description} (early stop)") + break + result_before_allow = _match or FileMatchResult(False) + if result_before_allow.matches or not self.allow_patterns: + return result_before_allow + + # matches = False + + _match = FileMatchResult(False) + for file_pattern in self.allow_patterns: result = file_pattern.match(path, path_is_dir) if result.matches: _match = result._replace(matches=not file_pattern.is_negative) if _match.matches and _match.by_dir: _match = _match._replace(description=f"{_match.description} (early stop)") break - return _match or FileMatchResult(False) + return result_before_allow._replace(matches=not _match.matches) diff --git a/src/orgecc/filematcher/file_matcher_api.py b/src/orgecc/filematcher/file_matcher_api.py index 83c85c7..712fa83 100644 --- a/src/orgecc/filematcher/file_matcher_api.py +++ b/src/orgecc/filematcher/file_matcher_api.py @@ -35,6 +35,7 @@ from typing import Protocol, Iterable from collections import namedtuple + class DenyPatternSource(Iterable[str]): @property def deny_patterns(self) -> tuple[str, ...]: ... @@ -42,6 +43,7 @@ def deny_patterns(self) -> tuple[str, ...]: ... def __iter__(self) -> Iterable[str]: return iter(self.deny_patterns) + class AllowPatternSource(Iterable[str]): @property def allow_patterns(self) -> set[str]: ... @@ -49,6 +51,7 @@ def allow_patterns(self) -> set[str]: ... def __iter__(self) -> Iterable[str]: return iter(self.allow_patterns) + FileMatchResult = namedtuple('FileMatchResult', ['matches', 'description', 'by_dir'], defaults=[None, False]) """ Represents the result of a file matching operation. @@ -68,7 +71,7 @@ class FileMatcher(Protocol): while maintaining a consistent interface. """ - def match(self, path: str, is_dir: bool=False) -> FileMatchResult: + def match(self, path: str, is_dir: bool = False) -> FileMatchResult: """ Check if a given path matches the configured patterns. @@ -81,6 +84,7 @@ def match(self, path: str, is_dir: bool=False) -> FileMatchResult: """ ... + class FileMatcherFactory(Protocol): """ Protocol defining the interface for creating file matcher instances. @@ -89,7 +93,11 @@ class FileMatcherFactory(Protocol): while maintaining a consistent way to create matcher instances. """ - def pattern2matcher(self, deny_source: DenyPatternSource) -> FileMatcher: + def pattern2matcher( + self, + deny_source: DenyPatternSource, + allow_source: AllowPatternSource | None = None + ) -> FileMatcher: """ Create a new matcher instance from patterns or pattern files. @@ -102,4 +110,5 @@ def pattern2matcher(self, deny_source: DenyPatternSource) -> FileMatcher: ... def __enter__(self): ... + def __exit__(self, exc_type, exc_val, exc_tb): ... diff --git a/src/orgecc/filematcher/patterns/__init__.py b/src/orgecc/filematcher/patterns/__init__.py index 922f0e3..daa31fd 100644 --- a/src/orgecc/filematcher/patterns/__init__.py +++ b/src/orgecc/filematcher/patterns/__init__.py @@ -1,8 +1,9 @@ from importlib.resources.abc import Traversable +from pathlib import PurePath from typing import Iterable -from pathlib import Path, PurePath -from ..file_matcher_api import DenyPatternSource + from .pattern_kit import DenyPatternSourceImpl, DenyPatternSourceGroup +from ..file_matcher_api import DenyPatternSource __all__ = ('new_deny_pattern_source', 'merge_deny_pattern_sources')