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

valksor / php-bundle / 19637698767

24 Nov 2025 02:24PM UTC coverage: 65.046%. Remained the same
19637698767

push

github

k0d3r1s
cs fixes

0 of 28 new or added lines in 1 file covered. (0.0%)

61 existing lines in 1 file now uncovered.

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
    ) {
80
        $this->excludePatterns = $patterns;
×
81
    }
82

83
    // ===== BACKWARD COMPATIBILITY METHODS =====
84
    // These methods provide compatibility with the old categorized PathFilter interface
85
    // used by HotReloadService and other legacy components.
86

87
    /**
88
     * Get ignored directory patterns from unified patterns.
89
     *
90
     * Extracts patterns that are typically directory names (no slashes, no dots, no wildcards).
91
     *
92
     * @return array<string> Directory patterns to ignore
93
     */
94
    public function getIgnoredDirectories(): array
95
    {
NEW
96
        $directories = [];
×
97

98
        foreach ($this->excludePatterns as $pattern) {
99
            $pattern = strtolower(trim($pattern, '/'));
100

101
            // Include as directory if:
102
            // - No dots (not an extension)
103
            // - No wildcards (not a glob)
104
            // - Not too short (likely not a filename like 'a' or 'in')
105
            if (!str_contains($pattern, '.')
106
                && !str_contains($pattern, '*')
107
                && !str_contains($pattern, '?')
108
                && strlen($pattern) > 2
109
                && !str_starts_with($pattern, '**')) {
110
                $directories[] = $pattern;
111
            }
112
        }
113

114
        return $directories;
115
    }
116

117
    /**
118
     * Get ignored file extension patterns from unified patterns.
119
     *
120
     * Extracts patterns that start with a dot and are likely file extensions.
121
     *
122
     * @return array<string> File extensions to ignore
123
     */
124
    public function getIgnoredExtensions(): array
125
    {
NEW
126
        $extensions = [];
×
NEW
127

×
128
        foreach ($this->excludePatterns as $pattern) {
129
            $pattern = strtolower(trim($pattern, '/'));
130

NEW
131
            // Include as extension if:
×
132
            // - No slashes (not a path)
133
            // - No wildcards (not a glob)
NEW
134
            // - Starts with dot (typical extension format)
×
135
            if (!str_contains($pattern, '/')
NEW
136
                && !str_contains($pattern, '*')
×
NEW
137
                && !str_contains($pattern, '?')
×
138
                && str_starts_with($pattern, '.')) {
139
                $extensions[] = $pattern;
140
            }
141
        }
142

NEW
143
        return $extensions;
×
144
    }
145

146
    /**
147
     * Get ignored filename patterns from unified patterns.
148
     *
NEW
149
     * Extracts patterns that are typical filenames (no slashes, no dots, no wildcards).
×
150
     *
NEW
151
     * @return array<string> Filename patterns to ignore
×
NEW
152
     */
×
153
    public function getIgnoredFilenames(): array
154
    {
NEW
155
        $filenames = [];
×
156

157
        foreach ($this->excludePatterns as $pattern) {
NEW
158
            $pattern = strtolower(trim($pattern, '/'));
×
NEW
159

×
NEW
160
            // Include as filename if:
×
NEW
161
            // - No slashes (not a path)
×
NEW
162
            // - No dots or starts with dot (not an extension)
×
163
            // - No wildcards (not a glob)
NEW
164
            // - Shorter length (typical filenames)
×
165
            if (!str_contains($pattern, '/')
166
                && !str_contains($pattern, '*')
167
                && !str_contains($pattern, '?')
168
                && strlen($pattern) <= 10
169
                && (!str_contains($pattern, '.') || str_starts_with($pattern, '.'))) {
170
                $filenames[] = $pattern;
171
            }
172
        }
173

174
        return $filenames;
175
    }
176

177
    /**
178
     * Get ignored glob patterns from unified patterns.
179
     *
NEW
180
     * Extracts patterns containing wildcards or glob syntax.
×
181
     *
NEW
182
     * @return array<string> Glob patterns to ignore
×
NEW
183
     */
×
184
    public function getIgnoredGlobs(): array
185
    {
NEW
186
        $globs = [];
×
NEW
187

×
188
        foreach ($this->excludePatterns as $pattern) {
189
            $pattern = strtolower(trim($pattern, '/'));
NEW
190

×
NEW
191
            // Include as glob if it contains wildcards
×
192
            if (str_contains($pattern, '*') || str_contains($pattern, '?') || str_starts_with($pattern, '**')) {
193
                $globs[] = $pattern;
194
            }
NEW
195
        }
×
NEW
196

×
NEW
197
        return $globs;
×
198
    }
199

200
    /**
UNCOV
201
     * Check if a directory should be ignored during file system traversal.
×
UNCOV
202
     *
×
UNCOV
203
     * This method uses unified pattern matching - any pattern can match
×
204
     * both files and directories, eliminating categorization issues.
205
     *
UNCOV
206
     * @param string $basename Directory basename (without path)
×
207
     *
208
     * @return bool True if directory should be ignored, false if it should be watched
209
     */
UNCOV
210
    public function shouldIgnoreDirectory(
×
UNCOV
211
        string $basename,
×
UNCOV
212
    ): bool {
×
213
        return $this->matchesAnyPattern($basename);
214
    }
215

216
    /**
UNCOV
217
     * Check if a file path should be ignored during file system monitoring.
×
UNCOV
218
     *
×
UNCOV
219
     * This method implements comprehensive path filtering using multiple strategies
×
220
     * to determine if a file should trigger build processes. It combines filename
221
     * matching, extension filtering, and glob pattern matching for maximum flexibility.
222
     *
223
     * Filtering Strategy (in order of evaluation):
UNCOV
224
     * 1. Basic validation for null/empty paths
×
225
     * 2. Filename matching for specific files (.gitignore, .gitkeep)
226
     * 3. Extension filtering for file types (.md, .log, etc.)
227
     * 4. Glob pattern matching for complex path scenarios
228
     *
229
     *
230
     * Performance Considerations:
231
     * - Simple checks (filename, extension) are performed first
232
     * - Expensive glob matching is performed last
233
     * - Case-insensitive matching for cross-platform compatibility
234
     *
235
     * @param string|null $path Full file path to check
UNCOV
236
     *
×
237
     * @return bool True if file should be ignored, false if it should trigger rebuilds
UNCOV
238
     */
×
UNCOV
239
    public function shouldIgnorePath(
×
240
        ?string $path,
UNCOV
241
    ): bool {
×
UNCOV
242
        // Basic validation - handle null or empty paths gracefully
×
243
        if (null === $path || '' === $path) {
×
244
            return false;
245
        }
246

UNCOV
247
        // Convert absolute paths to be relative to project root for pattern matching
×
248
        $relativePath = $path;
×
249

250
        // If we have a project directory, try to make the path relative to it
251
        if (!empty($this->projectDir)) {
UNCOV
252
            // Direct match - remove project directory prefix
×
253
            if (str_starts_with($path, $this->projectDir)) {
×
254
                $relativePath = substr($path, strlen($this->projectDir) + 1);
UNCOV
255
            }
×
UNCOV
256
            // If path doesn't start with project dir, use it as-is (already relative)
×
UNCOV
257
        }
×
258

259
        // Use unified pattern matching
260
        return $this->matchesAnyPattern($relativePath);
UNCOV
261
    }
×
UNCOV
262

×
263
    public static function createDefault(
264
        string $projectDir,
265
    ): self {
266
        $allPatterns = [
×
267
            // Directories and simple patterns
268
            'node_modules', 'vendor', 'public', 'var', '.git', '.idea', '.webpack-cache',
269
            '.gitignore', '.gitkeep',
UNCOV
270

×
271
            // File extensions
272
            '.md',
273

274
            // Glob patterns for comprehensive exclusion
275
            '**/node_modules/**', 'node_modules/**', '**/vendor/**', 'vendor/**',
276
            '**/public/**', 'public/**', '**/var/**', 'var/**', '**/.git/**', '.git/**',
277
            '**/.idea/**', '.idea/**', '**/.webpack-cache/**', '.webpack-cache/**',
278
            '**/*.md', '**/.gitignore', '**/.gitkeep',
279
        ];
280

281
        return new self($allPatterns, $projectDir);
282
    }
UNCOV
283

×
UNCOV
284
    /**
×
285
     * Check if a path matches any exclusion pattern.
286
     *
287
     * This unified method eliminates artificial categorization and checks
UNCOV
288
     * the path against all patterns using appropriate matching strategies.
×
UNCOV
289
     *
×
290
     * @param string $path Path to check
291
     *
UNCOV
292
     * @return bool True if path should be ignored, false otherwise
×
293
     */
294
    private function matchesAnyPattern(
UNCOV
295
        string $path,
×
296
    ): bool {
297
        $lowerPath = strtolower($path);
×
UNCOV
298

×
299
        foreach ($this->excludePatterns as $pattern) {
300
            $lowerPattern = strtolower($pattern);
301

UNCOV
302
            // Normalize pattern by removing trailing slashes for matching
×
303
            $patternToMatch = rtrim($lowerPattern, '/');
×
304
            $hadTrailingSlash = $lowerPattern !== $patternToMatch;
×
305

306
            // Exact match
307
            if ($lowerPath === $patternToMatch) {
×
308
                return true;
309
            }
310

UNCOV
311
            // Directory/component match (for patterns like LICENSE, vendor)
×
312
            if ($lowerPath === $patternToMatch  // Exact match
×
313
                || str_starts_with($lowerPath, $patternToMatch . '/')) {  // Pattern at start
314
                return true;
315
            }
UNCOV
316

×
UNCOV
317
            // For patterns without trailing slashes (like LICENSE), also match nested occurrences
×
318
            if (!$hadTrailingSlash && (
319
                str_contains($lowerPath, '/' . $patternToMatch . '/')
320
                || str_ends_with($lowerPath, '/' . $patternToMatch)
×
321
            )
322
            ) {
323
                return true;
324
            }
325

326
            // File extension patterns (like .neon, .md, .lock)
327
            if (str_starts_with($patternToMatch, '.') && !str_contains($patternToMatch, '/')) {
328
                if (str_ends_with($lowerPath, $patternToMatch)) {
329
                    return true;
330
                }
UNCOV
331
            }
×
UNCOV
332

×
333
            // Glob pattern matching
334
            if (str_contains($pattern, '*') || str_contains($pattern, '?')) {
335
                if ($this->matchGlobPattern($pattern, $path)) {
×
336
                    return true;
×
337
                }
UNCOV
338
            }
×
339
        }
340

341
        return false;
342
    }
343

344
    /**
345
     * Pattern matching with proper ** wildcard support.
346
     * Uses simple string matching for common patterns and fnmatch for others.
347
     */
348
    private static function matchGlobPattern(
349
        string $pattern,
350
        string $path,
351
    ): bool {
352
        // Handle common ** patterns with simple string matching for reliability
353
        if (str_contains($pattern, '**')) {
UNCOV
354
            // Pattern: **/dirname/** → check if path contains /dirname/ or ends with /dirname
×
355
            if (preg_match('#^\*\*/([^/]+)/\*\*$#', $pattern, $matches)) {
×
356
                $dirname = $matches[1];
×
357

358
                return str_contains($path, '/' . $dirname . '/')
359
                       || str_ends_with($path, '/' . $dirname)
360
                       || $path === $dirname;
UNCOV
361
            }
×
UNCOV
362

×
UNCOV
363
            // Pattern: **/*.ext → check if path ends with .ext
×
364
            if (preg_match('#^\*\*/\*\.(.+)$#', $pattern, $matches)) {
×
365
                return str_ends_with($path, '.' . $matches[1]);
×
UNCOV
366
            }
×
367

368
            // Pattern: **/filename → check if path contains /filename/ or ends with /filename
369
            if (preg_match('#^\*\*/([^*]+)$#', $pattern, $matches)) {
×
370
                $filename = $matches[1];
371

372
                return str_contains($path, '/' . $filename . '/')
373
                       || str_ends_with($path, '/' . $filename)
374
                       || $path === $filename;
375
            }
376

377
            // Pattern: ** → matches everything (shouldn't be used for exclusions)
378
            if ('**' === $pattern) {
379
                return true;
380
            }
UNCOV
381

×
UNCOV
382
            // For other complex ** patterns, fall back to manual matching
×
383
            return self::matchRecursivePatternSimple($pattern, $path);
×
384
        }
UNCOV
385

×
UNCOV
386
        // For patterns without **, use the standard fnmatch with FNM_PATHNAME
×
387
        return fnmatch($pattern, $path, FNM_PATHNAME | FNM_NOESCAPE);
388
    }
UNCOV
389

×
390
    /**
391
     * Recursively match pattern parts against path parts.
392
     */
393
    private static function matchPatternParts(
394
        array $patternParts,
395
        array $pathParts,
396
        int $pIdx = 0,
397
        int $pathIdx = 0,
398
    ): bool {
399
        // Base case: both exhausted
400
        if ($pIdx >= count($patternParts) && $pathIdx >= count($pathParts)) {
401
            return true;
×
UNCOV
402
        }
×
UNCOV
403

×
404
        // Pattern exhausted but path remains
405
        if ($pIdx >= count($patternParts)) {
406
            return false;
407
        }
408

409
        $patternPart = $patternParts[$pIdx];
×
UNCOV
410

×
UNCOV
411
        // Handle ** wildcard
×
412
        if ('**' === $patternPart) {
×
UNCOV
413
            // Try matching zero parts
×
414
            if (self::matchPatternParts($patternParts, $pathParts, $pIdx + 1, $pathIdx)) {
×
415
                return true;
416
            }
UNCOV
417

×
418
            // Try matching one or more parts
419
            if ($pathIdx < count($pathParts)
420
                && self::matchPatternParts($patternParts, $pathParts, $pIdx, $pathIdx + 1)) {
421
                return true;
422
            }
423

424
            return false;
425
        }
426

427
        // Path exhausted but pattern remains
428
        if ($pathIdx >= count($pathParts)) {
429
            return false;
×
UNCOV
430
        }
×
UNCOV
431

×
432
        // Match current part
433
        if (fnmatch($patternPart, $pathParts[$pathIdx], FNM_NOESCAPE)) {
434
            return self::matchPatternParts($patternParts, $pathParts, $pIdx + 1, $pathIdx + 1);
435
        }
UNCOV
436

×
437
        return false;
×
UNCOV
438
    }
×
UNCOV
439

×
UNCOV
440
    /**
×
441
     * Simple recursive pattern matching for complex ** patterns.
442
     */
UNCOV
443
    private static function matchRecursivePatternSimple(
×
444
        string $pattern,
445
        string $path,
446
    ): bool {
447
        // Normalize paths
448
        $pattern = trim($pattern, '/');
449
        $path = trim($path, '/');
450

451
        // Split into parts
452
        $patternParts = explode('/', $pattern);
453
        $pathParts = explode('/', $path);
454

455
        return self::matchPatternParts($patternParts, $pathParts);
456
    }
457
}
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