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

valksor / php-dev-build / 19384258487

15 Nov 2025 04:07AM UTC coverage: 19.747% (+2.5%) from 17.283%
19384258487

push

github

k0d3r1s
prettier

16 of 30 new or added lines in 4 files covered. (53.33%)

516 existing lines in 7 files now uncovered.

484 of 2451 relevant lines covered (19.75%)

1.03 hits per line

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

59.09
/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 ValksorDev\Build\Service;
14

15
use function array_map;
16
use function count;
17
use function fnmatch;
18
use function in_array;
19
use function pathinfo;
20
use function strlen;
21
use function strtolower;
22

23
use const FNM_NOESCAPE;
24
use const FNM_PATHNAME;
25
use const PATHINFO_BASENAME;
26
use const PATHINFO_EXTENSION;
27

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

66
    /**
67
     * List of file extensions to ignore (with dot prefix).
68
     * These extensions typically represent non-source files like documentation,
69
     * logs, or temporary files that don't affect the build process.
70
     *
71
     * @var array<string>
72
     */
73
    private array $ignoredExtensions;
74

75
    /**
76
     * List of specific filenames to ignore.
77
     * These are typically configuration files or meta-files that don't contain
78
     * source code but might be frequently updated by development tools.
79
     *
80
     * @var array<string>
81
     */
82
    private array $ignoredFilenames;
83

84
    /**
85
     * List of glob patterns for advanced path filtering.
86
     * These patterns support wildcards and recursive matching for complex
87
     * exclusion scenarios like "ignore all files in any node_modules directory".
88
     *
89
     * @var array<string>
90
     */
91
    private readonly array $ignoredGlobs;
92

93
    /**
94
     * Initialize the path filter with ignore patterns.
95
     *
96
     * The constructor receives four categories of ignore patterns and normalizes
97
     * them to lowercase for case-insensitive matching. This ensures consistent
98
     * behavior across different operating systems and file systems.
99
     *
100
     * @param array  $directories List of directory names to ignore
101
     * @param array  $globs       List of glob patterns for complex path matching
102
     * @param array  $filenames   List of specific filenames to ignore
103
     * @param array  $extensions  List of file extensions to ignore
104
     * @param string $projectDir  Project root directory for path normalization
105
     */
106
    public function __construct(
107
        array $directories,
108
        array $globs,
109
        array $filenames,
110
        array $extensions,
111
        /**
112
         * Project root directory for path normalization.
113
         *
114
         * @var string
115
         */
116
        private string $projectDir,
117
    ) {
118
        $this->ignoredDirectories = array_map('strtolower', $directories);
44✔
119
        $this->ignoredGlobs = $globs;
44✔
120
        $this->ignoredFilenames = array_map('strtolower', $filenames);
44✔
121
        $this->ignoredExtensions = array_map('strtolower', $extensions);
44✔
122
    }
123

124
    /**
125
     * Check if a directory should be ignored during file system traversal.
126
     *
127
     * This method is used by file watchers and directory scanners to determine
128
     * whether to descend into a directory. Ignoring large dependency directories
129
     * (node_modules, vendor) significantly improves performance and reduces
130
     * inotify watch descriptor usage.
131
     *
132
     * Common ignored directories:
133
     * - node_modules: JavaScript dependencies (thousands of files)
134
     * - vendor: PHP/Composer dependencies
135
     * - public: Build output and static assets
136
     * - var: Symfony cache and log files
137
     * - .git: Git repository metadata
138
     * - .idea: IDE configuration files
139
     *
140
     * @param string $basename Directory basename (without path)
141
     *
142
     * @return bool True if directory should be ignored, false if it should be watched
143
     */
144
    public function shouldIgnoreDirectory(
145
        string $basename,
146
    ): bool {
147
        return in_array(strtolower($basename), $this->ignoredDirectories, true);
10✔
148
    }
149

150
    /**
151
     * Check if a file path should be ignored during file system monitoring.
152
     *
153
     * This method implements comprehensive path filtering using multiple strategies
154
     * to determine if a file should trigger build processes. It combines filename
155
     * matching, extension filtering, and glob pattern matching for maximum flexibility.
156
     *
157
     * Filtering Strategy (in order of evaluation):
158
     * 1. Basic validation for null/empty paths
159
     * 2. Filename matching for specific files (.gitignore, .gitkeep)
160
     * 3. Extension filtering for file types (.md, .log, etc.)
161
     * 4. Glob pattern matching for complex path scenarios
162
     *
163
     *
164
     * Performance Considerations:
165
     * - Simple checks (filename, extension) are performed first
166
     * - Expensive glob matching is performed last
167
     * - Case-insensitive matching for cross-platform compatibility
168
     *
169
     * @param string|null $path Full file path to check
170
     *
171
     * @return bool True if file should be ignored, false if it should trigger rebuilds
172
     */
173
    public function shouldIgnorePath(
174
        ?string $path,
175
    ): bool {
176
        // Basic validation - handle null or empty paths gracefully
177
        if (null === $path || '' === $path) {
10✔
178
            return false;
1✔
179
        }
180

181
        // Convert absolute paths to be relative to project root for pattern matching
182
        $relativePath = $path;
10✔
183

184
        // If we have a project directory, try to make the path relative to it
185
        if (!empty($this->projectDir)) {
10✔
186
            // Direct match - remove project directory prefix
187
            if (str_starts_with($path, $this->projectDir)) {
10✔
UNCOV
188
                $relativePath = substr($path, strlen($this->projectDir) + 1);
×
189
            }
190
            // If path doesn't start with project dir, use it as-is (already relative)
191
        }
192

193
        // Check filename against ignored filenames list
194
        // This catches specific files like .gitignore, .gitkeep that shouldn't trigger builds
195
        $basename = strtolower(pathinfo($relativePath, PATHINFO_BASENAME));
10✔
196

197
        if ('' !== $basename && in_array($basename, $this->ignoredFilenames, true)) {
10✔
198
            return true;
1✔
199
        }
200

201
        // Check file extension against ignored extensions list
202
        // This efficiently filters out entire file categories like documentation
203
        $extension = strtolower(pathinfo($relativePath, PATHINFO_EXTENSION));
10✔
204

205
        if ('' !== $extension && in_array('.' . $extension, $this->ignoredExtensions, true)) {
10✔
206
            return true;
1✔
207
        }
208

209
        // Check against glob patterns for complex path matching
210
        // This handles advanced scenarios like "any file in any node_modules directory"
211
        $ignored = false;
10✔
212

213
        foreach ($this->ignoredGlobs as $glob) {
10✔
214
            if (self::matchGlobPattern($glob, $relativePath)) {
10✔
215
                $ignored = true;
1✔
216

217
                break;
1✔
218
            }
219
        }
220

221
        return $ignored;
10✔
222
    }
223

224
    public static function createDefault(
225
        string $projectDir,
226
    ): self {
227
        return new self(
44✔
228
            ['node_modules', 'vendor', 'public', 'var', '.git', '.idea', '.webpack-cache'],
44✔
229
            ['**/node_modules/**', '**/vendor/**', '**/public/**', '**/var/**', '**/.git/**', '**/.idea/**', '**/.webpack-cache/**', '**/*.md', '**/.gitignore', '**/.gitkeep'],
44✔
230
            ['.gitignore', '.gitkeep'],
44✔
231
            ['.md'],
44✔
232
            $projectDir,
44✔
233
        );
44✔
234
    }
235

236
    /**
237
     * Pattern matching with proper ** wildcard support.
238
     * Uses simple string matching for common patterns and fnmatch for others.
239
     */
240
    private static function matchGlobPattern(
241
        string $pattern,
242
        string $path,
243
    ): bool {
244
        // Handle common ** patterns with simple string matching for reliability
245
        if (str_contains($pattern, '**')) {
10✔
246
            // Pattern: **/dirname/** → check if path contains /dirname/ or ends with /dirname
247
            if (preg_match('#^\*\*/([^/]+)/\*\*$#', $pattern, $matches)) {
10✔
248
                $dirname = $matches[1];
10✔
249

250
                return str_contains($path, '/' . $dirname . '/')
10✔
251
                       || str_ends_with($path, '/' . $dirname)
10✔
252
                       || $path === $dirname;
10✔
253
            }
254

255
            // Pattern: **/*.ext → check if path ends with .ext
256
            if (preg_match('#^\*\*/\*\.(.+)$#', $pattern, $matches)) {
10✔
257
                return str_ends_with($path, '.' . $matches[1]);
10✔
258
            }
259

260
            // Pattern: **/filename → check if path ends with /filename
261
            if (preg_match('#^\*\*/([^*]+)$#', $pattern, $matches)) {
10✔
262
                return str_ends_with($path, '/' . $matches[1]);
10✔
263
            }
264

265
            // Pattern: ** → matches everything (shouldn't be used for exclusions)
UNCOV
266
            if ('**' === $pattern) {
×
UNCOV
267
                return true;
×
268
            }
269

270
            // For other complex ** patterns, fall back to manual matching
UNCOV
271
            return self::matchRecursivePatternSimple($pattern, $path);
×
272
        }
273

274
        // For patterns without **, use the standard fnmatch with FNM_PATHNAME
UNCOV
275
        return fnmatch($pattern, $path, FNM_PATHNAME | FNM_NOESCAPE);
×
276
    }
277

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

292
        // Pattern exhausted but path remains
UNCOV
293
        if ($pIdx >= count($patternParts)) {
×
UNCOV
294
            return false;
×
295
        }
296

UNCOV
297
        $patternPart = $patternParts[$pIdx];
×
298

299
        // Handle ** wildcard
UNCOV
300
        if ('**' === $patternPart) {
×
301
            // Try matching zero parts
UNCOV
302
            if (self::matchPatternParts($patternParts, $pathParts, $pIdx + 1, $pathIdx)) {
×
UNCOV
303
                return true;
×
304
            }
305

306
            // Try matching one or more parts
UNCOV
307
            if ($pathIdx < count($pathParts)
×
UNCOV
308
                && self::matchPatternParts($patternParts, $pathParts, $pIdx, $pathIdx + 1)) {
×
UNCOV
309
                return true;
×
310
            }
311

UNCOV
312
            return false;
×
313
        }
314

315
        // Path exhausted but pattern remains
UNCOV
316
        if ($pathIdx >= count($pathParts)) {
×
UNCOV
317
            return false;
×
318
        }
319

320
        // Match current part
UNCOV
321
        if (fnmatch($patternPart, $pathParts[$pathIdx], FNM_NOESCAPE)) {
×
UNCOV
322
            return self::matchPatternParts($patternParts, $pathParts, $pIdx + 1, $pathIdx + 1);
×
323
        }
324

UNCOV
325
        return false;
×
326
    }
327

328
    /**
329
     * Simple recursive pattern matching for complex ** patterns.
330
     */
331
    private static function matchRecursivePatternSimple(
332
        string $pattern,
333
        string $path,
334
    ): bool {
335
        // Normalize paths
UNCOV
336
        $pattern = trim($pattern, '/');
×
UNCOV
337
        $path = trim($path, '/');
×
338

339
        // Split into parts
UNCOV
340
        $patternParts = explode('/', $pattern);
×
UNCOV
341
        $pathParts = explode('/', $path);
×
342

UNCOV
343
        return self::matchPatternParts($patternParts, $pathParts);
×
344
    }
345
}
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