• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

valksor / php-bundle / 19634156412

24 Nov 2025 12:21PM UTC coverage: 65.046% (-13.4%) from 78.447%
19634156412

push

github

k0d3r1s
add valksor-dev snapshot

0 of 130 new or added lines in 2 files covered. (0.0%)

495 of 761 relevant lines covered (65.05%)

1.61 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/Service/PathFilter.php
1
<?php declare(strict_types = 1);
2

3
/*
4
 * This file is part of the Valksor package.
5
 *
6
 * (c) Davis Zalitis (k0d3r1s)
7
 * (c) SIA Valksor <packages@valksor.com>
8
 *
9
 * For the full copyright and license information, please view the LICENSE
10
 * file that was distributed with this source code.
11
 */
12

13
namespace Valksor\Bundle\Service;
14

15
use function count;
16
use function fnmatch;
17
use function strlen;
18
use function strtolower;
19

20
use const FNM_NOESCAPE;
21
use const FNM_PATHNAME;
22

23
/**
24
 * Path filtering utility for file system monitoring and build processes.
25
 *
26
 * This class provides intelligent file and directory filtering to optimize
27
 * file watching performance and prevent unnecessary processing of irrelevant
28
 * files. It's used extensively by the RecursiveInotifyWatcher and build services
29
 * to focus on source files while ignoring noise.
30
 *
31
 * Filtering Strategy:
32
 * - Directory-based filtering for large dependency folders (node_modules, vendor)
33
 * - File extension filtering for non-source files (.md, .log, etc.)
34
 * - Filename filtering for specific configuration files (.gitignore, .gitkeep)
35
 * - Glob pattern matching for complex path exclusions
36
 *
37
 * Performance Benefits:
38
 * - Reduces inotify watch descriptors by excluding irrelevant directories
39
 * - Minimizes file system events from build artifacts and dependencies
40
 * - Improves hot reload responsiveness by focusing on source files only
41
 * - Prevents infinite loops from watching build output directories
42
 *
43
 * Default Ignore Patterns:
44
 * - Dependencies: node_modules, vendor
45
 * - Build artifacts: public, var
46
 * - Development tools: .git, .idea, .webpack-cache
47
 * - Documentation: *.md files
48
 * - Git files: .gitignore, .gitkeep
49
 */
50
final class PathFilter
51
{
52
    /**
53
     * List of all patterns to ignore during file system traversal.
54
     * This includes directory names, file extensions, filenames, and glob patterns.
55
     * Unified approach - any pattern can match both files and directories.
56
     *
57
     * @var array<string>
58
     */
59
    private readonly array $excludePatterns;
60

61
    /**
62
     * Initialize the path filter with ignore patterns.
63
     *
64
     * The constructor receives a unified list of ignore patterns that can match
65
     * both files and directories. This eliminates artificial categorization and
66
     * ensures any pattern works regardless of whether it matches a file or directory.
67
     *
68
     * @param array  $patterns   List of patterns to ignore (unified approach)
69
     * @param string $projectDir Project root directory for path normalization
70
     */
71
    public function __construct(
72
        array $patterns,
73
        /**
74
         * Project root directory for path normalization.
75
         *
76
         * @var string
77
         */
78
        private string $projectDir,
79
    ) {
NEW
80
        $this->excludePatterns = $patterns;
×
81
    }
82

83
    /**
84
     * Check if a directory should be ignored during file system traversal.
85
     *
86
     * This method uses unified pattern matching - any pattern can match
87
     * both files and directories, eliminating categorization issues.
88
     *
89
     * @param string $basename Directory basename (without path)
90
     *
91
     * @return bool True if directory should be ignored, false if it should be watched
92
     */
93
    public function shouldIgnoreDirectory(
94
        string $basename,
95
    ): bool {
NEW
96
        return $this->matchesAnyPattern($basename);
×
97
    }
98

99
    /**
100
     * Check if a file path should be ignored during file system monitoring.
101
     *
102
     * This method implements comprehensive path filtering using multiple strategies
103
     * to determine if a file should trigger build processes. It combines filename
104
     * matching, extension filtering, and glob pattern matching for maximum flexibility.
105
     *
106
     * Filtering Strategy (in order of evaluation):
107
     * 1. Basic validation for null/empty paths
108
     * 2. Filename matching for specific files (.gitignore, .gitkeep)
109
     * 3. Extension filtering for file types (.md, .log, etc.)
110
     * 4. Glob pattern matching for complex path scenarios
111
     *
112
     *
113
     * Performance Considerations:
114
     * - Simple checks (filename, extension) are performed first
115
     * - Expensive glob matching is performed last
116
     * - Case-insensitive matching for cross-platform compatibility
117
     *
118
     * @param string|null $path Full file path to check
119
     *
120
     * @return bool True if file should be ignored, false if it should trigger rebuilds
121
     */
122
    public function shouldIgnorePath(
123
        ?string $path,
124
    ): bool {
125
        // Basic validation - handle null or empty paths gracefully
NEW
126
        if (null === $path || '' === $path) {
×
NEW
127
            return false;
×
128
        }
129

130
        // Convert absolute paths to be relative to project root for pattern matching
NEW
131
        $relativePath = $path;
×
132

133
        // If we have a project directory, try to make the path relative to it
NEW
134
        if (!empty($this->projectDir)) {
×
135
            // Direct match - remove project directory prefix
NEW
136
            if (str_starts_with($path, $this->projectDir)) {
×
NEW
137
                $relativePath = substr($path, strlen($this->projectDir) + 1);
×
138
            }
139
            // If path doesn't start with project dir, use it as-is (already relative)
140
        }
141

142
        // Use unified pattern matching
NEW
143
        return $this->matchesAnyPattern($relativePath);
×
144
    }
145

146
    public static function createDefault(
147
        string $projectDir,
148
    ): self {
NEW
149
        $allPatterns = [
×
150
            // Directories and simple patterns
NEW
151
            'node_modules', 'vendor', 'public', 'var', '.git', '.idea', '.webpack-cache',
×
NEW
152
            '.gitignore', '.gitkeep',
×
153

154
            // File extensions
NEW
155
            '.md',
×
156

157
            // Glob patterns for comprehensive exclusion
NEW
158
            '**/node_modules/**', 'node_modules/**', '**/vendor/**', 'vendor/**',
×
NEW
159
            '**/public/**', 'public/**', '**/var/**', 'var/**', '**/.git/**', '.git/**',
×
NEW
160
            '**/.idea/**', '.idea/**', '**/.webpack-cache/**', '.webpack-cache/**',
×
NEW
161
            '**/*.md', '**/.gitignore', '**/.gitkeep',
×
NEW
162
        ];
×
163

NEW
164
        return new self($allPatterns, $projectDir);
×
165
    }
166

167
    /**
168
     * Check if a path matches any exclusion pattern.
169
     *
170
     * This unified method eliminates artificial categorization and checks
171
     * the path against all patterns using appropriate matching strategies.
172
     *
173
     * @param string $path Path to check
174
     *
175
     * @return bool True if path should be ignored, false otherwise
176
     */
177
    private function matchesAnyPattern(
178
        string $path,
179
    ): bool {
NEW
180
        $lowerPath = strtolower($path);
×
181

NEW
182
        foreach ($this->excludePatterns as $pattern) {
×
NEW
183
            $lowerPattern = strtolower($pattern);
×
184

185
            // Normalize pattern by removing trailing slashes for matching
NEW
186
            $patternToMatch = rtrim($lowerPattern, '/');
×
NEW
187
            $hadTrailingSlash = $lowerPattern !== $patternToMatch;
×
188

189
            // Exact match
NEW
190
            if ($lowerPath === $patternToMatch) {
×
NEW
191
                return true;
×
192
            }
193

194
            // Directory/component match (for patterns like LICENSE, vendor)
NEW
195
            if ($lowerPath === $patternToMatch  // Exact match
×
NEW
196
                || str_starts_with($lowerPath, $patternToMatch . '/')) {  // Pattern at start
×
NEW
197
                return true;
×
198
            }
199

200
            // For patterns without trailing slashes (like LICENSE), also match nested occurrences
NEW
201
            if (!$hadTrailingSlash && (
×
NEW
202
                str_contains($lowerPath, '/' . $patternToMatch . '/')
×
NEW
203
                || str_ends_with($lowerPath, '/' . $patternToMatch)
×
204
            )
205
            ) {
NEW
206
                return true;
×
207
            }
208

209
            // File extension patterns (like .neon, .md, .lock)
NEW
210
            if (str_starts_with($patternToMatch, '.') && !str_contains($patternToMatch, '/')) {
×
NEW
211
                if (str_ends_with($lowerPath, $patternToMatch)) {
×
NEW
212
                    return true;
×
213
                }
214
            }
215

216
            // Glob pattern matching
NEW
217
            if (str_contains($pattern, '*') || str_contains($pattern, '?')) {
×
NEW
218
                if ($this->matchGlobPattern($pattern, $path)) {
×
NEW
219
                    return true;
×
220
                }
221
            }
222
        }
223

NEW
224
        return false;
×
225
    }
226

227
    /**
228
     * Pattern matching with proper ** wildcard support.
229
     * Uses simple string matching for common patterns and fnmatch for others.
230
     */
231
    private static function matchGlobPattern(
232
        string $pattern,
233
        string $path,
234
    ): bool {
235
        // Handle common ** patterns with simple string matching for reliability
NEW
236
        if (str_contains($pattern, '**')) {
×
237
            // Pattern: **/dirname/** → check if path contains /dirname/ or ends with /dirname
NEW
238
            if (preg_match('#^\*\*/([^/]+)/\*\*$#', $pattern, $matches)) {
×
NEW
239
                $dirname = $matches[1];
×
240

NEW
241
                return str_contains($path, '/' . $dirname . '/')
×
NEW
242
                       || str_ends_with($path, '/' . $dirname)
×
NEW
243
                       || $path === $dirname;
×
244
            }
245

246
            // Pattern: **/*.ext → check if path ends with .ext
NEW
247
            if (preg_match('#^\*\*/\*\.(.+)$#', $pattern, $matches)) {
×
NEW
248
                return str_ends_with($path, '.' . $matches[1]);
×
249
            }
250

251
            // Pattern: **/filename → check if path contains /filename/ or ends with /filename
NEW
252
            if (preg_match('#^\*\*/([^*]+)$#', $pattern, $matches)) {
×
NEW
253
                $filename = $matches[1];
×
254

NEW
255
                return str_contains($path, '/' . $filename . '/')
×
NEW
256
                       || str_ends_with($path, '/' . $filename)
×
NEW
257
                       || $path === $filename;
×
258
            }
259

260
            // Pattern: ** → matches everything (shouldn't be used for exclusions)
NEW
261
            if ('**' === $pattern) {
×
NEW
262
                return true;
×
263
            }
264

265
            // For other complex ** patterns, fall back to manual matching
NEW
266
            return self::matchRecursivePatternSimple($pattern, $path);
×
267
        }
268

269
        // For patterns without **, use the standard fnmatch with FNM_PATHNAME
NEW
270
        return fnmatch($pattern, $path, FNM_PATHNAME | FNM_NOESCAPE);
×
271
    }
272

273
    /**
274
     * Recursively match pattern parts against path parts.
275
     */
276
    private static function matchPatternParts(
277
        array $patternParts,
278
        array $pathParts,
279
        int $pIdx = 0,
280
        int $pathIdx = 0,
281
    ): bool {
282
        // Base case: both exhausted
NEW
283
        if ($pIdx >= count($patternParts) && $pathIdx >= count($pathParts)) {
×
NEW
284
            return true;
×
285
        }
286

287
        // Pattern exhausted but path remains
NEW
288
        if ($pIdx >= count($patternParts)) {
×
NEW
289
            return false;
×
290
        }
291

NEW
292
        $patternPart = $patternParts[$pIdx];
×
293

294
        // Handle ** wildcard
NEW
295
        if ('**' === $patternPart) {
×
296
            // Try matching zero parts
NEW
297
            if (self::matchPatternParts($patternParts, $pathParts, $pIdx + 1, $pathIdx)) {
×
NEW
298
                return true;
×
299
            }
300

301
            // Try matching one or more parts
NEW
302
            if ($pathIdx < count($pathParts)
×
NEW
303
                && self::matchPatternParts($patternParts, $pathParts, $pIdx, $pathIdx + 1)) {
×
NEW
304
                return true;
×
305
            }
306

NEW
307
            return false;
×
308
        }
309

310
        // Path exhausted but pattern remains
NEW
311
        if ($pathIdx >= count($pathParts)) {
×
NEW
312
            return false;
×
313
        }
314

315
        // Match current part
NEW
316
        if (fnmatch($patternPart, $pathParts[$pathIdx], FNM_NOESCAPE)) {
×
NEW
317
            return self::matchPatternParts($patternParts, $pathParts, $pIdx + 1, $pathIdx + 1);
×
318
        }
319

NEW
320
        return false;
×
321
    }
322

323
    /**
324
     * Simple recursive pattern matching for complex ** patterns.
325
     */
326
    private static function matchRecursivePatternSimple(
327
        string $pattern,
328
        string $path,
329
    ): bool {
330
        // Normalize paths
NEW
331
        $pattern = trim($pattern, '/');
×
NEW
332
        $path = trim($path, '/');
×
333

334
        // Split into parts
NEW
335
        $patternParts = explode('/', $pattern);
×
NEW
336
        $pathParts = explode('/', $path);
×
337

NEW
338
        return self::matchPatternParts($patternParts, $pathParts);
×
339
    }
340

341
    // ===== BACKWARD COMPATIBILITY METHODS =====
342
    // These methods provide compatibility with the old categorized PathFilter interface
343
    // used by HotReloadService and other legacy components.
344

345
    /**
346
     * Get ignored directory patterns from unified patterns.
347
     *
348
     * Extracts patterns that are typically directory names (no slashes, no dots, no wildcards).
349
     *
350
     * @return array<string> Directory patterns to ignore
351
     */
352
    public function getIgnoredDirectories(): array
353
    {
NEW
354
        $directories = [];
×
NEW
355
        foreach ($this->excludePatterns as $pattern) {
×
NEW
356
            $pattern = strtolower(trim($pattern, '/'));
×
357
            // Include as directory if:
358
            // - No dots (not an extension)
359
            // - No wildcards (not a glob)
360
            // - Not too short (likely not a filename like 'a' or 'in')
NEW
361
            if (!str_contains($pattern, '.')
×
NEW
362
                && !str_contains($pattern, '*')
×
NEW
363
                && !str_contains($pattern, '?')
×
NEW
364
                && strlen($pattern) > 2
×
NEW
365
                && !str_starts_with($pattern, '**')) {
×
NEW
366
                $directories[] = $pattern;
×
367
            }
368
        }
NEW
369
        return $directories;
×
370
    }
371

372
    /**
373
     * Get ignored glob patterns from unified patterns.
374
     *
375
     * Extracts patterns containing wildcards or glob syntax.
376
     *
377
     * @return array<string> Glob patterns to ignore
378
     */
379
    public function getIgnoredGlobs(): array
380
    {
NEW
381
        $globs = [];
×
NEW
382
        foreach ($this->excludePatterns as $pattern) {
×
NEW
383
            $pattern = strtolower(trim($pattern, '/'));
×
384
            // Include as glob if it contains wildcards
NEW
385
            if (str_contains($pattern, '*') || str_contains($pattern, '?') || str_starts_with($pattern, '**')) {
×
NEW
386
                $globs[] = $pattern;
×
387
            }
388
        }
NEW
389
        return $globs;
×
390
    }
391

392
    /**
393
     * Get ignored filename patterns from unified patterns.
394
     *
395
     * Extracts patterns that are typical filenames (no slashes, no dots, no wildcards).
396
     *
397
     * @return array<string> Filename patterns to ignore
398
     */
399
    public function getIgnoredFilenames(): array
400
    {
NEW
401
        $filenames = [];
×
NEW
402
        foreach ($this->excludePatterns as $pattern) {
×
NEW
403
            $pattern = strtolower(trim($pattern, '/'));
×
404
            // Include as filename if:
405
            // - No slashes (not a path)
406
            // - No dots or starts with dot (not an extension)
407
            // - No wildcards (not a glob)
408
            // - Shorter length (typical filenames)
NEW
409
            if (!str_contains($pattern, '/')
×
NEW
410
                && !str_contains($pattern, '*')
×
NEW
411
                && !str_contains($pattern, '?')
×
NEW
412
                && strlen($pattern) <= 10
×
NEW
413
                && (!str_contains($pattern, '.') || str_starts_with($pattern, '.'))) {
×
NEW
414
                $filenames[] = $pattern;
×
415
            }
416
        }
NEW
417
        return $filenames;
×
418
    }
419

420
    /**
421
     * Get ignored file extension patterns from unified patterns.
422
     *
423
     * Extracts patterns that start with a dot and are likely file extensions.
424
     *
425
     * @return array<string> File extensions to ignore
426
     */
427
    public function getIgnoredExtensions(): array
428
    {
NEW
429
        $extensions = [];
×
NEW
430
        foreach ($this->excludePatterns as $pattern) {
×
NEW
431
            $pattern = strtolower(trim($pattern, '/'));
×
432
            // Include as extension if:
433
            // - No slashes (not a path)
434
            // - No wildcards (not a glob)
435
            // - Starts with dot (typical extension format)
NEW
436
            if (!str_contains($pattern, '/')
×
NEW
437
                && !str_contains($pattern, '*')
×
NEW
438
                && !str_contains($pattern, '?')
×
NEW
439
                && str_starts_with($pattern, '.')) {
×
NEW
440
                $extensions[] = $pattern;
×
441
            }
442
        }
NEW
443
        return $extensions;
×
444
    }
445
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc