1212 */
1313final class FileFilter
1414{
15+ /**
16+ * In-memory cache of boolean exclusion results keyed by normalized file path.
17+ *
18+ * @var array<string, bool>
19+ */
20+ private static array $ pathFilterCache = [];
21+
22+ /**
23+ * Pre-compiled include regex patterns and match lengths.
24+ *
25+ * @var array<int, array{len: int, regex: string}>|null
26+ */
27+ private static ?array $ compiledIncludes = null ;
28+
29+ /**
30+ * Pre-compiled exclude regex patterns and match lengths.
31+ *
32+ * @var array<int, array{len: int, regex: string}>|null
33+ */
34+ private static ?array $ compiledExcludes = null ;
35+
36+ /**
37+ * Cached normalized cache directory path.
38+ */
39+ private static ?string $ cachedCacheDir = null ;
40+
41+ /**
42+ * Resets the path decision cache and pre-compiled regex patterns. Useful for test isolation.
43+ */
44+ public static function reset (): void
45+ {
46+ self ::$ pathFilterCache = [];
47+ self ::$ compiledIncludes = null ;
48+ self ::$ compiledExcludes = null ;
49+ self ::$ cachedCacheDir = null ;
50+ }
51+
1552 /**
1653 * Determines whether a given file path is excluded from contract inheritance.
1754 * Non-PHP files and excluded paths return true.
@@ -24,16 +61,54 @@ public static function isFileExcluded(string|false|null $fileName): bool
2461
2562 $ normalizedPath = str_replace ('\\' , '/ ' , $ fileName );
2663
64+ if (isset (self ::$ pathFilterCache [$ normalizedPath ])) {
65+ return self ::$ pathFilterCache [$ normalizedPath ];
66+ }
67+
2768 // Non-PHP files are always excluded from PHPDoc contract processing
2869 if (! str_ends_with (strtolower ($ normalizedPath ), '.php ' )) {
29- return true ;
70+ return self :: $ pathFilterCache [ $ normalizedPath ] = true ;
3071 }
3172
32- $ normalizedCacheDir = rtrim (str_replace ('\\' , '/ ' , CacheManager::getCacheDir ()), '/ ' ) . '/ ' ;
33- if (str_starts_with ($ normalizedPath , $ normalizedCacheDir )) {
34- return true ;
73+ if (self ::$ cachedCacheDir === null ) {
74+ self ::$ cachedCacheDir = rtrim (str_replace ('\\' , '/ ' , CacheManager::getCacheDir ()), '/ ' ) . '/ ' ;
3575 }
3676
77+ if (str_starts_with ($ normalizedPath , self ::$ cachedCacheDir )) {
78+ return self ::$ pathFilterCache [$ normalizedPath ] = true ;
79+ }
80+
81+ if (self ::$ compiledIncludes === null || self ::$ compiledExcludes === null ) {
82+ self ::compilePatterns ();
83+ }
84+
85+ $ longestIncludeMatch = 0 ;
86+ /** @var array<int, array{len: int, regex: string}> $includes */
87+ $ includes = self ::$ compiledIncludes ;
88+ foreach ($ includes as $ compiled ) {
89+ if (preg_match ($ compiled ['regex ' ], $ normalizedPath ) === 1 ) {
90+ $ longestIncludeMatch = max ($ longestIncludeMatch , $ compiled ['len ' ]);
91+ }
92+ }
93+
94+ $ longestExcludeMatch = 0 ;
95+ /** @var array<int, array{len: int, regex: string}> $excludes */
96+ $ excludes = self ::$ compiledExcludes ;
97+ foreach ($ excludes as $ compiled ) {
98+ if (preg_match ($ compiled ['regex ' ], $ normalizedPath ) === 1 ) {
99+ $ longestExcludeMatch = max ($ longestExcludeMatch , $ compiled ['len ' ]);
100+ }
101+ }
102+
103+ // Equal specificity tie-breaker: Exclude wins!
104+ return self ::$ pathFilterCache [$ normalizedPath ] = ($ longestExcludeMatch >= $ longestIncludeMatch );
105+ }
106+
107+ /**
108+ * Compiles configured include and exclude globs into regex patterns once per configuration lifecycle.
109+ */
110+ private static function compilePatterns (): void
111+ {
37112 $ config = Config::get ();
38113 /** @var array<mixed> $includes */
39114 $ includes = \is_array ($ config ['include ' ] ?? null ) ? $ config ['include ' ] : ['** ' ];
@@ -42,30 +117,27 @@ public static function isFileExcluded(string|false|null $fileName): bool
42117
43118 $ baseDir = Config::getProjectRoot ();
44119
45- $ longestIncludeMatch = 0 ;
120+ self :: $ compiledIncludes = [] ;
46121 foreach ($ includes as $ pattern ) {
47- if (! \is_string ($ pattern )) {
48- continue ;
49- }
50- $ regex = self :: compileGlobToRegex ( $ pattern , $ baseDir );
51- if ( preg_match ( $ regex , $ normalizedPath ) === 1 ) {
52- $ longestIncludeMatch = max ( $ longestIncludeMatch , \strlen ( trim ( $ pattern ))) ;
122+ if (\is_string ($ pattern )) {
123+ $ trimmed = trim ( $ pattern ) ;
124+ self :: $ compiledIncludes [] = [
125+ ' len ' => \strlen ( $ trimmed ),
126+ ' regex ' => self :: compileGlobToRegex ( $ trimmed , $ baseDir ),
127+ ] ;
53128 }
54129 }
55130
56- $ longestExcludeMatch = 0 ;
131+ self :: $ compiledExcludes = [] ;
57132 foreach ($ excludes as $ pattern ) {
58- if (! \is_string ($ pattern )) {
59- continue ;
60- }
61- $ regex = self :: compileGlobToRegex ( $ pattern , $ baseDir );
62- if ( preg_match ( $ regex , $ normalizedPath ) === 1 ) {
63- $ longestExcludeMatch = max ( $ longestExcludeMatch , \strlen ( trim ( $ pattern ))) ;
133+ if (\is_string ($ pattern )) {
134+ $ trimmed = trim ( $ pattern ) ;
135+ self :: $ compiledExcludes [] = [
136+ ' len ' => \strlen ( $ trimmed ),
137+ ' regex ' => self :: compileGlobToRegex ( $ trimmed , $ baseDir ),
138+ ] ;
64139 }
65140 }
66-
67- // Equal specificity tie-breaker: Exclude wins!
68- return $ longestExcludeMatch >= $ longestIncludeMatch ;
69141 }
70142
71143 /**
@@ -89,4 +161,4 @@ private static function compileGlobToRegex(string $glob, string $baseDir): strin
89161
90162 return '# ' . $ pattern . '#i ' ;
91163 }
92- }
164+ }
0 commit comments