• 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

2.8
/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 ReflectionClass;
18
use RuntimeException;
19
use Symfony\Component\Console\Command\Command;
20
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
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 microtime;
36
use function opendir;
37
use function pathinfo;
38
use function preg_match;
39
use function preg_quote;
40
use function readdir;
41
use function sprintf;
42
use function str_ends_with;
43
use function str_replace;
44
use function str_starts_with;
45
use function stream_select;
46
use function strtolower;
47
use function usleep;
48

49
use const DIRECTORY_SEPARATOR;
50
use const PATHINFO_EXTENSION;
51

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

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

79
    /**
80
     * Path filter for ignoring directories and files during file watching.
81
     */
82
    private PathFilter $filter;
83

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

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

100
    public function __construct(
101
        ParameterBagInterface $bag,
102
    ) {
103
        parent::__construct($bag);
13✔
104
        $this->filter = PathFilter::createDefault($this->projectDir);
13✔
105
    }
106

107
    public function reload(): void
108
    {
109
        // Hot reload service doesn't support manual reload
110
        $this->io->note('Hot reload service reload requested (no action needed)');
1✔
111
    }
112

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

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

124
        // Create custom path filter with user exclusions
UNCOV
125
        $excludePatterns = $hotReloadConfig['exclude'] ?? [];
×
UNCOV
126
        $this->filter = $this->createPathFilterWithExclusions($excludePatterns);
×
127

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

UNCOV
133
        $watchDirs = $hotReloadConfig['watch_dirs'] ?? [];
×
134

135
        // Early return if no watch directories configured - nothing to monitor
UNCOV
136
        if (empty($watchDirs)) {
×
137
            $this->io->warning('No watch directories configured. Hot reload disabled.');
×
138

UNCOV
139
            return Command::SUCCESS;
×
140
        }
141

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

155
        $this->fileTransformations = $fileTransformations;
×
156
        // Lazy load output files only when needed, not during startup
UNCOV
157
        $this->outputFiles = [];
×
158

159
        try {
160
            $watcher = new RecursiveInotifyWatcher($this->filter, function (string $path) use ($extendedSuffixes, $debounceDelay, $extendedExtensions): void {
×
UNCOV
161
                $this->handleFilesystemChange($path, $extendedSuffixes, $extendedExtensions, $debounceDelay);
×
162
            });
×
163

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

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

174
            // Continue without watcher
175
            $watcher = null;
×
176
        }
177

178
        $watchTargets = $this->collectWatchTargets($this->parameterBag->get('kernel.project_dir'), $watchDirs);
×
179

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

187
            $this->io->note(sprintf('Watching %d directories for changes', count($watchTargets)));
×
188
        }
189

190
        $this->running = true;
×
191

UNCOV
192
        $this->io->success('Hot reload service started');
×
193
        $this->io->text(sprintf('[RESOURCE] Watching %d directories | Memory: %.2f MB', count($watchTargets), memory_get_usage(true) / 1024 / 1024));
×
194

UNCOV
195
        while ($this->running) {
×
196
            if ($watcher) {
×
197
                $read = [$watcher->getStream()];
×
UNCOV
198
                $write = null;
×
199
                $except = null;
×
200

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

207
                // Add error handling around stream_select
208
                try {
UNCOV
209
                    $ready = @stream_select($read, $write, $except, $seconds, $microseconds);
×
210

211
                    if (false === $ready) {
×
212
                        // stream_select failed, likely due to signal interruption
213
                        $this->io->text('Stream select interrupted, continuing...');
×
UNCOV
214
                        usleep(100000); // Wait 100ms before retry
×
215

216
                        continue;
×
217
                    }
218

UNCOV
219
                    if (!empty($read)) {
×
UNCOV
220
                        $watcher->poll();
×
221
                    }
222

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

UNCOV
234
        return 0;
×
235
    }
236

237
    public function stop(): void
238
    {
239
        $this->running = false;
1✔
240
    }
241

242
    public static function getServiceName(): string
243
    {
244
        return 'hot-reload';
2✔
245
    }
246

247
    private function calculateSelectTimeout(): float
248
    {
UNCOV
249
        $now = microtime(true);
×
250

251
        if ($this->debounceDeadline > 0) {
×
252
            return max(0.05, $this->debounceDeadline - $now);
×
253
        }
254

UNCOV
255
        return 1.0;
×
256
    }
257

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

272
            return;
×
273
        }
274

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

UNCOV
279
            return;
×
280
        }
281

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

UNCOV
286
            return;
×
287
        }
288

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

293
            return;
×
294
        }
295

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

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

309
        foreach ($watchDirs as $dir) {
×
UNCOV
310
            $fullPath = $projectRoot . DIRECTORY_SEPARATOR . ltrim($dir, '/');
×
311

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

317
        return $targets;
×
318
    }
319

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

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

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

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

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

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

UNCOV
352
        return new PathFilter($mergedDirs, $mergedGlobs, $mergedFilenames, $mergedExtensions, $this->projectDir);
×
353
    }
354

355
    private function determineReloadDelay(
356
        string $path,
357
        array $extendedExtensions,
358
        array $extendedSuffixes,
359
        float $debounceDelay,
360
    ): float {
361
        // Priority 1: Check if this is a source file that matches transformation patterns
362
        // Source files get their configured debounce delay (typically longer for compiled files)
UNCOV
363
        foreach ($this->fileTransformations as $pattern => $config) {
×
UNCOV
364
            if ($this->matchesPattern($path, $pattern)) {
×
365
                return $config['debounce_delay'] ?? $debounceDelay;
×
366
            }
367
        }
368

369
        // Priority 2: Check extended suffixes (legacy support for specific file patterns)
370
        // Provides backward compatibility with older configuration formats
371
        foreach ($extendedSuffixes as $suffix => $delay) {
×
UNCOV
372
            if (str_ends_with($path, $suffix)) {
×
373
                return $delay;
×
374
            }
375
        }
376

377
        // Priority 3: Check file extensions for general file type handling
UNCOV
378
        $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
×
379

UNCOV
380
        if ('' !== $extension && array_key_exists($extension, $extendedExtensions)) {
×
381
            return $extendedExtensions[$extension];
×
382
        }
383

384
        // Priority 4: Use default debounce delay for all other files
UNCOV
385
        return $debounceDelay;
×
386
    }
387

388
    /**
389
     * @param array<string,array<string,mixed>> $transformations
390
     *
391
     * @return array<string,bool>
392
     */
393
    private function discoverOutputFiles(
394
        string $projectRoot,
395
        array $transformations,
396
    ): array {
UNCOV
397
        $outputs = [];
×
398

399
        foreach ($transformations as $pattern => $config) {
×
UNCOV
400
            if (!($config['track_output'] ?? true)) {
×
401
                continue;
×
402
            }
403

404
            $roots = $config['watch_dirs'] ?? [
×
405
                $projectRoot . $this->parameterBag->get('valksor.project.apps_dir'),
×
UNCOV
406
                $projectRoot . $this->parameterBag->get('valksor.project.infrastructure_dir'),
×
407
            ];
408

UNCOV
409
            foreach ($roots as $root) {
×
410
                $root = $projectRoot . DIRECTORY_SEPARATOR . ltrim($root, '/');
×
411

UNCOV
412
                if (!is_dir($root)) {
×
UNCOV
413
                    continue;
×
414
                }
415

UNCOV
416
                $this->visitSources($root, $pattern, $config['output_pattern'], $outputs);
×
417
            }
418
        }
419

UNCOV
420
        return $outputs;
×
421
    }
422

423
    /**
424
     * Extract default exclusions from PathFilter using reflection.
425
     */
426
    private function extractDefaultExclusions(
427
        PathFilter $filter,
428
    ): array {
429
        $reflection = new ReflectionClass($filter);
×
430

UNCOV
431
        $directories = $reflection->getProperty('ignoredDirectories')->getValue($filter);
×
UNCOV
432
        $globs = $reflection->getProperty('ignoredGlobs')->getValue($filter);
×
433
        $filenames = $reflection->getProperty('ignoredFilenames')->getValue($filter);
×
UNCOV
434
        $extensions = $reflection->getProperty('ignoredExtensions')->getValue($filter);
×
435

UNCOV
436
        return [
×
UNCOV
437
            'directories' => $directories,
×
UNCOV
438
            'globs' => $globs,
×
UNCOV
439
            'filenames' => $filenames,
×
UNCOV
440
            'extensions' => $extensions,
×
UNCOV
441
        ];
×
442
    }
443

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

UNCOV
453
        $files = array_keys($this->pendingChanges);
×
454
        $this->pendingChanges = [];
×
455
        $this->debounceDeadline = 0.0;
×
456

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

461
        try {
UNCOV
462
            $result = file_put_contents($signalFile, $signalData);
×
463

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

UNCOV
470
            return;
×
471
        }
472

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

475
        // Refresh file list after sending signal to handle new/deleted files
476
        $this->refreshWatchTargets();
×
477
    }
478

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

UNCOV
493
        $outputPath = $outputPattern;
×
494

495
        foreach ($placeholders as $placeholder => $value) {
×
496
            $outputPath = str_replace($placeholder, $value, $outputPath);
×
497
        }
498

499
        return $projectRoot . DIRECTORY_SEPARATOR . $outputPath;
×
500
    }
501

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

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

UNCOV
518
        $now = microtime(true);
×
519

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

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

527
        // Add this file to the pending changes queue
528
        $this->pendingChanges[$path] = true;
×
529

UNCOV
530
        $this->io->text(sprintf('File changed: %s', $path));
×
531
    }
532

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

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

559
        return 1 === preg_match($pattern, $path);
×
560
    }
561

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

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

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

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

588
    /**
589
     * Validate hot reload configuration before starting.
590
     */
591
    private function validateConfiguration(
592
        array $config,
593
    ): bool {
UNCOV
594
        $projectRoot = $this->parameterBag->get('kernel.project_dir');
×
595

596
        // Validate var/run directory exists and is writable
UNCOV
597
        $runDir = $projectRoot . '/var/run';
×
598
        $this->ensureDirectory($runDir);
×
599

UNCOV
600
        if (!is_writable($runDir)) {
×
UNCOV
601
            $this->io->error(sprintf('Run directory is not writable: %s', $runDir));
×
602

603
            return false;
×
604
        }
605

606
        // Validate watch directories exist
607
        foreach ($config['watch_dirs'] ?? [] as $dir) {
×
608
            $fullPath = $projectRoot . '/' . ltrim($dir, '/');
×
609

610
            if (!is_dir($fullPath)) {
×
611
                $this->io->warning(sprintf('Watch directory does not exist: %s', $fullPath));
×
612
            }
613
        }
614

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

UNCOV
620
        return true;
×
621
    }
622

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

UNCOV
634
        if (false === $handle) {
×
UNCOV
635
            return;
×
636
        }
637

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

UNCOV
644
                $full = $path . DIRECTORY_SEPARATOR . $entry;
×
645

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

UNCOV
652
                    continue;
×
653
                }
654

UNCOV
655
                if (!$this->matchesPattern($full, $pattern)) {
×
UNCOV
656
                    continue;
×
657
                }
658

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

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

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