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

valksor / php-dev-build / 21323318062

24 Jan 2026 11:21PM UTC coverage: 27.706% (-2.8%) from 30.503%
21323318062

push

github

k0d3r1s
wip

1 of 2 new or added lines in 2 files covered. (50.0%)

909 existing lines in 16 files now uncovered.

791 of 2855 relevant lines covered (27.71%)

0.96 hits per line

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

30.7
/Service/HotReloadService.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 Exception;
16
use JsonException;
17
use RuntimeException;
18
use Symfony\Component\Console\Command\Command;
19
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
20
use Valksor\Bundle\Service\PathFilter;
21
use Valksor\Component\Sse\Service\AbstractService;
22

23
use function array_key_exists;
24
use function array_keys;
25
use function array_merge;
26
use function closedir;
27
use function count;
28
use function extension_loaded;
29
use function file_put_contents;
30
use function is_dir;
31
use function is_writable;
32
use function json_encode;
33
use function ltrim;
34
use function max;
35
use function memory_get_usage;
36
use function microtime;
37
use function opendir;
38
use function pathinfo;
39
use function preg_match;
40
use function preg_quote;
41
use function readdir;
42
use function sprintf;
43
use function str_contains;
44
use function str_ends_with;
45
use function str_replace;
46
use function str_starts_with;
47
use function stream_select;
48
use function strtolower;
49
use function usleep;
50

51
use const DIRECTORY_SEPARATOR;
52
use const JSON_THROW_ON_ERROR;
53
use const PATHINFO_EXTENSION;
54

55
/**
56
 * Hot reload service for automatically triggering browser refreshes when files change.
57
 *
58
 * This service monitors file system changes and sends reload signals to connected browsers
59
 * via Server-Sent Events (SSE). It handles:
60
 * - File system monitoring with inotify
61
 * - Intelligent debouncing to prevent excessive reloads
62
 * - File transformation patterns (e.g., .tailwind.css → .css)
63
 * - Rate limiting and duplicate change detection
64
 * - Multi-app project structure support
65
 */
66
final class HotReloadService extends AbstractService
67
{
68
    /**
69
     * Deadline for when debounced changes should be flushed.
70
     * This prevents excessive reloads when multiple files change rapidly.
71
     */
72
    private float $debounceDeadline = 0.0;
73

74
    /**
75
     * File transformation patterns for mapping source files to output files.
76
     * Example: '*.tailwind.css' → ['output_pattern' => '{path}/{name}.css', 'debounce_delay' => 0.5].
77
     *
78
     * @var array<string,array<string,mixed>>
79
     */
80
    private array $fileTransformations = [];
81

82
    /**
83
     * Path filter for ignoring directories and files during file watching.
84
     */
85
    private PathFilter $filter;
86

87
    /**
88
     * Registry of known output files that should trigger reloads.
89
     * These are the target files generated by transformations (e.g., .css files).
90
     *
91
     * @var array<string,bool>
92
     */
93
    private array $outputFiles = [];
94

95
    /**
96
     * Pending file changes waiting to be debounced and flushed.
97
     * Prevents duplicate changes and implements debouncing logic.
98
     *
99
     * @var array<string,bool>
100
     */
101
    private array $pendingChanges = [];
102

103
    public function __construct(
104
        ParameterBagInterface $bag,
105
    ) {
106
        parent::__construct($bag);
12✔
107
        $this->filter = PathFilter::createDefault($this->projectDir);
12✔
108
    }
109

110
    public function reload(): void
111
    {
112
        // Hot reload service doesn't support manual reload
UNCOV
113
        $this->io->note('Hot reload service reload requested (no action needed)');
×
114
    }
115

116
    /**
117
     * @param array<string,mixed> $config Configuration from hot_reload config section
118
     */
119
    public function start(
120
        array $config = [],
121
    ): int {
122
        // SSE server communication now handled via direct service calls
123

124
        // Get hot reload configuration from service-based structure
125
        $hotReloadConfig = $this->parameterBag->get('valksor.build.services.hot_reload.options');
4✔
126

127
        // Create custom path filter with user exclusions
128
        $excludePatterns = $hotReloadConfig['exclude'] ?? [];
4✔
129
        $this->filter = $this->createPathFilterWithExclusions($excludePatterns);
4✔
130

131
        // Validate configuration early to fail fast if setup is incorrect
132
        if (!$this->validateConfiguration($hotReloadConfig)) {
4✔
UNCOV
133
            return 1; // Return error code for invalid config
×
134
        }
135

136
        $watchDirs = $hotReloadConfig['watch_dirs'] ?? [];
4✔
137

138
        // Early return if no watch directories configured - nothing to monitor
139
        if (empty($watchDirs)) {
4✔
140
            $this->io->warning('No watch directories configured. Hot reload disabled.');
1✔
141

142
            return Command::SUCCESS;
1✔
143
        }
144

145
        // Extract configuration with sensible defaults
146
        $debounceDelay = $hotReloadConfig['debounce_delay'] ?? 0.3;           // Default 300ms debounce
3✔
147
        $extendedExtensions = $hotReloadConfig['extended_extensions'] ?? [];     // File extensions to watch
3✔
148
        $extendedSuffixes = $hotReloadConfig['extended_suffixes'] ?? ['.tailwind.css' => 0.5]; // Specific file suffixes
3✔
149
        $fileTransformations = $hotReloadConfig['file_transformations'] ?? [
3✔
150
            // Default transformation: Tailwind source files to CSS output
151
            '*.tailwind.css' => [
3✔
152
                'output_pattern' => '{path}/{name}.css',    // Convert .tailwind.css to .css
3✔
153
                'debounce_delay' => 0.5,                   // Longer debounce for compiled files
3✔
154
                'track_output' => true,                    // Monitor the output CSS file
3✔
155
            ],
3✔
156
        ];
157

158
        $this->fileTransformations = $fileTransformations;
3✔
159
        // Lazy load output files only when needed, not during startup
160
        $this->outputFiles = [];
3✔
161

162
        try {
163
            $watcher = new RecursiveInotifyWatcher($this->filter, function (string $path) use ($extendedSuffixes, $debounceDelay, $extendedExtensions): void {
3✔
UNCOV
164
                $this->handleFilesystemChange($path, $extendedSuffixes, $extendedExtensions, $debounceDelay);
×
165
            });
3✔
166

167
            $this->io->text('File watcher initialized successfully');
3✔
UNCOV
168
        } catch (RuntimeException $e) {
×
UNCOV
169
            $this->io->error(sprintf('Failed to initialize file watcher: %s', $e->getMessage()));
×
170
            $this->io->warning('Hot reload will continue without file watching (manual reload only)');
×
171

172
            // Continue without watcher - will fall back to manual mode
UNCOV
173
            $watcher = null;
×
UNCOV
174
        } catch (Exception $e) {
×
175
            $this->io->error(sprintf('Unexpected error initializing watcher: %s', $e->getMessage()));
×
176

177
            // Continue without watcher
UNCOV
178
            $watcher = null;
×
179
        }
180

181
        $watchTargets = $this->collectWatchTargets($this->parameterBag->get('kernel.project_dir'), $watchDirs);
3✔
182

183
        if ([] === $watchTargets) {
3✔
184
            $this->io->warning('No watch targets found. Hot reload will remain idle.');
3✔
185
        } else {
UNCOV
186
            foreach ($watchTargets as $target) {
×
187
                $watcher->addRoot($target);
×
188
            }
189

UNCOV
190
            $this->io->note(sprintf('Watching %d directories for changes', count($watchTargets)));
×
191
        }
192

193
        $this->running = true;
3✔
194

195
        $this->io->success('Hot reload service started');
3✔
196
        $this->io->text(sprintf('[RESOURCE] Watching %d directories | Memory: %.2f MB', count($watchTargets), memory_get_usage(true) / 1024 / 1024));
3✔
197

198
        while ($this->running) {
3✔
199
            if ($watcher) {
3✔
200
                $read = [$watcher->getStream()];
3✔
201
                $write = null;
3✔
202
                $except = null;
3✔
203

204
                // Calculate dynamic timeout based on debouncing needs
205
                $timeout = $this->calculateSelectTimeout();
3✔
206
                $seconds = $timeout >= 1 ? (int) $timeout : 0;
3✔
207
                $microseconds = $timeout >= 1 ? (int) (($timeout - $seconds) * 1_000_000) : (int) max($timeout * 1_000_000, 50_000);
3✔
208
                // Minimum 50ms timeout to prevent busy waiting, maximum based on debounce deadline
209

210
                // Add error handling around stream_select
211
                try {
212
                    $ready = @stream_select($read, $write, $except, $seconds, $microseconds);
3✔
213

214
                    if (false === $ready) {
3✔
215
                        // stream_select failed, likely due to signal interruption
216
                        $this->io->text('Stream select interrupted, continuing...');
3✔
217
                        usleep(100000); // Wait 100ms before retry
3✔
218

219
                        continue;
3✔
220
                    }
221

UNCOV
222
                    if (!empty($read)) {
×
223
                        $watcher->poll();
×
224
                    }
225

UNCOV
226
                    $this->flushPendingReloads();
×
UNCOV
227
                } catch (Exception $e) {
×
UNCOV
228
                    $this->io->error(sprintf('Error in file watching loop: %s', $e->getMessage()));
×
229
                    // Continue running but log the error
230
                }
231
            } else {
232
                // No watcher available, just wait with timeout
UNCOV
233
                usleep(100000); // 100ms
×
234
            }
235
        }
236

237
        return 0;
3✔
238
    }
239

240
    public function stop(): void
241
    {
242
        $this->running = false;
4✔
243
    }
244

245
    public static function getServiceName(): string
246
    {
247
        return 'hot-reload';
2✔
248
    }
249

250
    private function calculateSelectTimeout(): float
251
    {
252
        $now = microtime(true);
3✔
253

254
        if ($this->debounceDeadline > 0) {
3✔
UNCOV
255
            return max(0.05, $this->debounceDeadline - $now);
×
256
        }
257

258
        return 1.0;
3✔
259
    }
260

261
    /**
262
     * Categorize an exclude pattern into the appropriate filter type.
263
     */
264
    private function categorizeExcludePattern(
265
        string $pattern,
266
        array &$dirs,
267
        array &$globs,
268
        array &$extensions,
269
    ): void {
270
        // Glob patterns (contain wildcards)
UNCOV
271
        if (str_contains($pattern, '*') || str_contains($pattern, '?')) {
×
272
            $globs[] = $pattern;
×
273

UNCOV
274
            return;
×
275
        }
276

277
        // File extensions (start with dot)
UNCOV
278
        if (str_starts_with($pattern, '.')) {
×
279
            $extensions[] = $pattern;
×
280

UNCOV
281
            return;
×
282
        }
283

284
        // Path patterns (contain path separators)
UNCOV
285
        if (str_contains($pattern, '/')) {
×
286
            $globs[] = $pattern;
×
287

UNCOV
288
            return;
×
289
        }
290

291
        // Simple directory names (no slashes, no wildcards)
UNCOV
292
        if (!str_contains($pattern, '.')) {
×
293
            $dirs[] = $pattern;
×
294

UNCOV
295
            return;
×
296
        }
297

298
        // Everything else treat as glob pattern
UNCOV
299
        $globs[] = $pattern;
×
300
    }
301

302
    /**
303
     * @return array<int,string>
304
     */
305
    private function collectWatchTargets(
306
        string $projectRoot,
307
        array $watchDirs,
308
    ): array {
309
        $targets = [];
3✔
310

311
        foreach ($watchDirs as $dir) {
3✔
312
            $fullPath = $projectRoot . DIRECTORY_SEPARATOR . ltrim($dir, '/');
3✔
313

314
            if (is_dir($fullPath) && !$this->shouldExcludeDirectory($fullPath)) {
3✔
UNCOV
315
                $targets[] = $fullPath;
×
316
            }
317
        }
318

319
        return $targets;
3✔
320
    }
321

322
    /**
323
     * Create a PathFilter that merges default exclusions with custom exclude patterns.
324
     */
325
    private function createPathFilterWithExclusions(
326
        array $excludePatterns = [],
327
    ): PathFilter {
328
        if (empty($excludePatterns)) {
4✔
329
            return PathFilter::createDefault($this->projectDir);
4✔
330
        }
331

332
        // Get default filter values
UNCOV
333
        $defaultFilter = PathFilter::createDefault($this->projectDir);
×
334

335
        // Start with default exclusions using reflection to access private properties
UNCOV
336
        $defaultExclusions = $this->extractDefaultExclusions($defaultFilter);
×
337

338
        // Categorize custom patterns
339
        $customDirs = [];
×
340
        $customGlobs = [];
×
UNCOV
341
        $customFilenames = [];
×
342
        $customExtensions = [];
×
343

UNCOV
344
        foreach ($excludePatterns as $pattern) {
×
UNCOV
345
            $this->categorizeExcludePattern($pattern, $customDirs, $customGlobs, $customExtensions);
×
346
        }
347

348
        // Merge with defaults
349
        $mergedDirs = array_merge($defaultExclusions['directories'], $customDirs);
×
350
        $mergedGlobs = array_merge($defaultExclusions['globs'], $customGlobs);
×
UNCOV
351
        $mergedFilenames = array_merge($defaultExclusions['filenames'], $customFilenames);
×
UNCOV
352
        $mergedExtensions = array_merge($defaultExclusions['extensions'], $customExtensions);
×
353

354
        // Create unified patterns array for the new PathFilter
355
        $allPatterns = array_merge(
×
356
            $mergedDirs,
×
357
            $mergedGlobs,
×
358
            $mergedFilenames,
×
UNCOV
359
            $mergedExtensions,
×
360
        );
×
361

UNCOV
362
        return new PathFilter($allPatterns, $this->projectDir);
×
363
    }
364

365
    private function determineReloadDelay(
366
        string $path,
367
        array $extendedExtensions,
368
        array $extendedSuffixes,
369
        float $debounceDelay,
370
    ): float {
371
        // Priority 1: Check if this is a source file that matches transformation patterns
372
        // Source files get their configured debounce delay (typically longer for compiled files)
373
        foreach ($this->fileTransformations as $pattern => $config) {
×
UNCOV
374
            if ($this->matchesPattern($path, $pattern)) {
×
UNCOV
375
                return $config['debounce_delay'] ?? $debounceDelay;
×
376
            }
377
        }
378

379
        // Priority 2: Check extended suffixes (legacy support for specific file patterns)
380
        // Provides backward compatibility with older configuration formats
381
        foreach ($extendedSuffixes as $suffix => $delay) {
×
UNCOV
382
            if (str_ends_with($path, $suffix)) {
×
UNCOV
383
                return $delay;
×
384
            }
385
        }
386

387
        // Priority 3: Check file extensions for general file type handling
388
        $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
×
389

UNCOV
390
        if ('' !== $extension && array_key_exists($extension, $extendedExtensions)) {
×
UNCOV
391
            return $extendedExtensions[$extension];
×
392
        }
393

394
        // Priority 4: Use default debounce delay for all other files
UNCOV
395
        return $debounceDelay;
×
396
    }
397

398
    /**
399
     * @param array<string,array<string,mixed>> $transformations
400
     *
401
     * @return array<string,bool>
402
     */
403
    private function discoverOutputFiles(
404
        string $projectRoot,
405
        array $transformations,
406
    ): array {
407
        $outputs = [];
×
408

409
        foreach ($transformations as $pattern => $config) {
×
UNCOV
410
            if (!($config['track_output'] ?? true)) {
×
UNCOV
411
                continue;
×
412
            }
413

414
            $roots = $config['watch_dirs'] ?? [
×
UNCOV
415
                $projectRoot . $this->parameterBag->get('valksor.project.apps_dir'),
×
UNCOV
416
                $projectRoot . $this->parameterBag->get('valksor.project.infrastructure_dir'),
×
417
            ];
418

UNCOV
419
            foreach ($roots as $root) {
×
420
                $root = $projectRoot . DIRECTORY_SEPARATOR . ltrim($root, '/');
×
421

UNCOV
422
                if (!is_dir($root)) {
×
UNCOV
423
                    continue;
×
424
                }
425

UNCOV
426
                $this->visitSources($root, $pattern, $config['output_pattern'], $outputs);
×
427
            }
428
        }
429

UNCOV
430
        return $outputs;
×
431
    }
432

433
    /**
434
     * Extract default exclusions from PathFilter using compatibility methods.
435
     */
436
    private function extractDefaultExclusions(
437
        PathFilter $filter,
438
    ): array {
439
        return [
×
440
            'directories' => $filter->getIgnoredDirectories(),
×
441
            'globs' => $filter->getIgnoredGlobs(),
×
442
            'filenames' => $filter->getIgnoredFilenames(),
×
UNCOV
443
            'extensions' => $filter->getIgnoredExtensions(),
×
UNCOV
444
        ];
×
445
    }
446

447
    /**
448
     * @throws JsonException
449
     */
450
    private function flushPendingReloads(): void
451
    {
UNCOV
452
        if ([] === $this->pendingChanges || microtime(true) < $this->debounceDeadline) {
×
UNCOV
453
            return;
×
454
        }
455

456
        $files = array_keys($this->pendingChanges);
×
UNCOV
457
        $this->pendingChanges = [];
×
UNCOV
458
        $this->debounceDeadline = 0.0;
×
459

460
        // Write signal file for SSE service with error handling
UNCOV
461
        $signalFile = $this->parameterBag->get('kernel.project_dir') . '/var/run/valksor-reload.signal';
×
UNCOV
462
        $signalData = json_encode(['files' => $files, 'timestamp' => microtime(true)], JSON_THROW_ON_ERROR);
×
463

464
        try {
465
            $result = file_put_contents($signalFile, $signalData);
×
466

UNCOV
467
            if (false === $result) {
×
468
                throw new RuntimeException('Failed to write signal file');
×
469
            }
UNCOV
470
        } catch (Exception $e) {
×
471
            $this->io->error(sprintf('Failed to write reload signal: %s', $e->getMessage()));
×
472

UNCOV
473
            return;
×
474
        }
475

UNCOV
476
        $this->io->success(sprintf('Reload signal sent for %d changed files', count($files)));
×
477

478
        // Refresh file list after sending signal to handle new/deleted files
UNCOV
479
        $this->refreshWatchTargets();
×
480
    }
481

482
    /**
483
     * Generate output file path from pattern.
484
     */
485
    private function generateOutputPath(
486
        string $projectRoot,
487
        string $relativeDir,
488
        string $filename,
489
        string $outputPattern,
490
    ): string {
491
        $placeholders = [
×
492
            '{path}' => $relativeDir,
×
UNCOV
493
            '{name}' => $filename,
×
494
        ];
×
495

496
        $outputPath = $outputPattern;
×
497

UNCOV
498
        foreach ($placeholders as $placeholder => $value) {
×
UNCOV
499
            $outputPath = str_replace($placeholder, $value, $outputPath);
×
500
        }
501

UNCOV
502
        return $projectRoot . DIRECTORY_SEPARATOR . $outputPath;
×
503
    }
504

505
    private function handleFilesystemChange(
506
        string $path,
507
        array $extendedSuffixes,
508
        array $extendedExtensions,
509
        float $debounceDelay,
510
    ): void {
511
        // Ignore files that match the path filter (e.g., .git, node_modules, vendor)
UNCOV
512
        if ($this->filter->shouldIgnorePath($path)) {
×
UNCOV
513
            return;
×
514
        }
515

516
        // Lazy load output files discovery to avoid expensive directory scanning during startup
UNCOV
517
        if (empty($this->outputFiles)) {
×
UNCOV
518
            $this->outputFiles = $this->discoverOutputFiles($this->parameterBag->get('kernel.project_dir'), $this->fileTransformations);
×
519
        }
520

UNCOV
521
        $now = microtime(true);
×
522

523
        // Determine the appropriate debounce delay for this specific file type
UNCOV
524
        $fileDelay = $this->determineReloadDelay($path, $extendedExtensions, $extendedSuffixes, $debounceDelay);
×
525

526
        // Calculate when this change should be flushed (debounce deadline)
527
        // Always reset the deadline - this is the key to proper debouncing behavior
UNCOV
528
        $this->debounceDeadline = $now + $fileDelay;
×
529

530
        // Add this file to the pending changes queue
531
        $this->pendingChanges[$path] = true;
×
532

UNCOV
533
        $this->io->text(sprintf('File changed: %s', $path));
×
534
    }
535

536
    /**
537
     * Check if a file path matches a glob pattern.
538
     *
539
     * Converts simple glob patterns (like *.tailwind.css) to regex for matching
540
     * against absolute file paths. This supports the transformation pattern system.
541
     *
542
     * Examples:
543
     * - "*.tailwind.css" matches all .tailwind.css files in any directory
544
     *
545
     * @param string $path    Absolute file path to check
546
     * @param string $pattern Glob pattern to match against
547
     *
548
     * @return bool True if the path matches the pattern
549
     */
550
    private function matchesPattern(
551
        string $path,
552
        string $pattern,
553
    ): bool {
554
        // Convert glob pattern to regex pattern
555
        // * becomes .* (match any characters)
556
        // ? becomes . (match any single character)
UNCOV
557
        $pattern = str_replace(['*', '?'], ['.*', '.'], $pattern);
×
558

559
        // Create full regex pattern that matches from project root
560
        $pattern = '#^' . preg_quote($this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR, '#') . $pattern . '$#';
×
561

UNCOV
562
        return 1 === preg_match($pattern, $path);
×
563
    }
564

565
    /**
566
     * Refresh watch targets and output files after a reload cycle.
567
     * This ensures new and deleted files are properly tracked.
568
     */
569
    private function refreshWatchTargets(): void
570
    {
571
        // Clear existing output files and rediscover
572
        $this->outputFiles = $this->discoverOutputFiles($this->parameterBag->get('kernel.project_dir'), $this->fileTransformations);
×
573

UNCOV
574
        $this->io->text(sprintf('Refreshed file discovery - tracking %d output files', count($this->outputFiles)));
×
575
    }
576

577
    /**
578
     * Check if a directory should be excluded based on exclude patterns.
579
     */
580
    private function shouldExcludeDirectory(
581
        string $dirPath,
582
    ): bool {
583
        // Get relative path from project root for pattern matching
UNCOV
584
        $projectRoot = $this->parameterBag->get('kernel.project_dir');
×
UNCOV
585
        $relativePath = str_replace($projectRoot . DIRECTORY_SEPARATOR, '', $dirPath);
×
586

587
        // Use PathFilter to check if this directory should be excluded
UNCOV
588
        return $this->filter->shouldIgnoreDirectory($relativePath) || $this->filter->shouldIgnorePath($relativePath);
×
589
    }
590

591
    /**
592
     * Validate hot reload configuration before starting.
593
     */
594
    private function validateConfiguration(
595
        array $config,
596
    ): bool {
597
        $projectRoot = $this->parameterBag->get('kernel.project_dir');
4✔
598

599
        // Validate var/run directory exists and is writable
600
        $runDir = $projectRoot . '/var/run';
4✔
601
        $this->ensureDirectory($runDir);
4✔
602

603
        if (!is_writable($runDir)) {
4✔
604
            $this->io->error(sprintf('Run directory is not writable: %s', $runDir));
×
605

UNCOV
606
            return false;
×
607
        }
608

609
        // Validate watch directories exist
610
        foreach ($config['watch_dirs'] ?? [] as $dir) {
4✔
611
            $fullPath = $projectRoot . '/' . ltrim($dir, '/');
3✔
612

613
            if (!is_dir($fullPath)) {
3✔
614
                $this->io->warning(sprintf('Watch directory does not exist: %s', $fullPath));
3✔
615
            }
616
        }
617

618
        // Check if inotify extension is available
619
        if (!extension_loaded('inotify')) {
4✔
UNCOV
620
            $this->io->warning('PHP inotify extension is not available. File watching may not work optimally.');
×
621
        }
622

623
        return true;
4✔
624
    }
625

626
    /**
627
     * @param array<string,bool> $outputs
628
     */
629
    private function visitSources(
630
        string $path,
631
        string $pattern,
632
        string $outputPattern,
633
        array &$outputs,
634
    ): void {
635
        $handle = opendir($path);
×
636

UNCOV
637
        if (false === $handle) {
×
UNCOV
638
            return;
×
639
        }
640

641
        try {
642
            while (($entry = readdir($handle)) !== false) {
×
UNCOV
643
                if ('.' === $entry || '..' === $entry) {
×
UNCOV
644
                    continue;
×
645
                }
646

647
                $full = $path . DIRECTORY_SEPARATOR . $entry;
×
648

649
                if (is_dir($full)) {
×
UNCOV
650
                    if ($this->filter->shouldIgnoreDirectory($entry)) {
×
651
                        continue;
×
652
                    }
653
                    $this->visitSources($full, $pattern, $outputPattern, $outputs);
×
654

UNCOV
655
                    continue;
×
656
                }
657

UNCOV
658
                if (!$this->matchesPattern($full, $pattern)) {
×
UNCOV
659
                    continue;
×
660
                }
661

662
                // Generate output file path
UNCOV
663
                $relativePath = str_replace($this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR, '', $full);
×
664
                $pathInfo = pathinfo($relativePath);
×
665

666
                $outputFile = $this->generateOutputPath(
×
667
                    $this->parameterBag->get('kernel.project_dir'),
×
668
                    $pathInfo['dirname'] ?? '',
×
669
                    $pathInfo['filename'],
×
UNCOV
670
                    $outputPattern,
×
671
                );
×
672

UNCOV
673
                $outputs[$outputFile] = true;
×
674
            }
675
        } finally {
UNCOV
676
            closedir($handle);
×
677
        }
678
    }
679
}
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