• 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

54.92
/Service/TailwindService.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 RuntimeException;
16
use Symfony\Component\Console\Command\Command;
17
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
18
use Symfony\Component\Process\Exception\ProcessFailedException;
19
use Symfony\Component\Process\Process;
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 array_unique;
27
use function array_values;
28
use function closedir;
29
use function count;
30
use function dirname;
31
use function function_exists;
32
use function is_array;
33
use function is_dir;
34
use function is_executable;
35
use function is_file;
36
use function microtime;
37
use function opendir;
38
use function pcntl_async_signals;
39
use function pcntl_signal;
40
use function preg_match;
41
use function preg_replace;
42
use function readdir;
43
use function sprintf;
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 strlen;
49
use function substr;
50
use function trim;
51
use function usort;
52

53
use const DIRECTORY_SEPARATOR;
54
use const SIGHUP;
55
use const SIGINT;
56
use const SIGTERM;
57

58
/**
59
 * Tailwind CSS build service for compiling and watching Tailwind stylesheets.
60
 *
61
 * This service handles:
62
 * - Building individual Tailwind CSS files
63
 * - Watch mode with file system monitoring
64
 * - Multi-app project structure support
65
 * - Integration with the Valksor build system
66
 */
67
final class TailwindService extends AbstractService
68
{
69
    /**
70
     * Debounce delay for watch mode to prevent excessive rebuilds
71
     * when multiple files change rapidly (e.g., during git operations).
72
     */
73
    private const float WATCH_DEBOUNCE_SECONDS = 0.25;
74

75
    /**
76
     * Current active app ID for single-app mode.
77
     * When null, operates in multi-app mode watching all applications.
78
     */
79
    private ?string $activeAppId = null;
80

81
    /**
82
     * Path filter for ignoring directories and files during source discovery.
83
     */
84
    private PathFilter $filter;
85

86
    /**
87
     * Base Tailwind CLI command configuration.
88
     * Contains the executable path and basic command arguments.
89
     *
90
     * @var array<int,string>
91
     */
92
    private array $tailwindCommandBase = [];
93

94
    public function __construct(
95
        ParameterBagInterface $bag,
96
    ) {
97
        parent::__construct($bag);
20✔
98
        $this->filter = PathFilter::createDefault($this->projectDir);
20✔
99
    }
100

101
    /**
102
     * Set the active application ID for single-app mode.
103
     *
104
     * When set, the service will only process Tailwind files within
105
     * the specified application directory. When null, operates in
106
     * multi-app mode watching all applications.
107
     *
108
     * @param string|null $appId The application ID or null for multi-app mode
109
     */
110
    public function setActiveAppId(
111
        ?string $appId,
112
    ): void {
113
        $this->activeAppId = $appId;
6✔
114
    }
115

116
    /**
117
     * @param array<string,mixed> $config Configuration: ['watch' => bool, 'minify' => bool]
118
     */
119
    public function start(
120
        array $config = [],
121
    ): int {
122
        $watchMode = $config['watch'];
6✔
123
        $minify = $config['minify'];
6✔
124

125
        // Resolve Tailwind CLI command and configuration
126
        $commandBase = $this->resolveTailwindCommandBase();
6✔
127
        $this->tailwindCommandBase = $commandBase['command'];
1✔
128
        $tailwindCommandDisplay = $commandBase['display'];
1✔
129

130
        // Discover all Tailwind CSS source files in the project
131
        $sources = $this->collectTailwindSources((bool) $watchMode);
1✔
132

133
        // Exit gracefully if no Tailwind sources are found
134
        if ([] === $sources) {
1✔
135
            $this->io->warning('No *.tailwind.css sources found.');
×
136

137
            return Command::SUCCESS;
×
138
        }
139

140
        // Sort sources by app label first, then by input file path
141
        // This ensures consistent processing order across different environments
142
        usort($sources, static function (array $left, array $right): int {
1✔
143
            $labelComparison = $left['label'] <=> $right['label'];
×
144

145
            if (0 !== $labelComparison) {
×
146
                return $labelComparison;
×
147
            }
148

149
            return $left['relative_input'] <=> $right['relative_input'];
×
150
        });
1✔
151

152
        $this->io->note(sprintf('Using Tailwind command: %s', $tailwindCommandDisplay));
1✔
153

154
        if (Command::SUCCESS !== $this->buildSources($sources, $minify)) {
1✔
155
            return Command::FAILURE;
1✔
156
        }
157

UNCOV
158
        if (!$watchMode) {
×
UNCOV
159
            return Command::SUCCESS;
×
160
        }
161

162
        if (!function_exists('pcntl_async_signals')) {
×
163
            $this->io->error('Watch mode requires the pcntl extension.');
×
164

165
            return Command::FAILURE;
×
166
        }
167

168
        return $this->watchSources($sources, $minify);
×
169
    }
170

171
    /**
172
     * Get the service name for identification in the build system.
173
     *
174
     * @return string The service identifier 'tailwind'
175
     */
176
    public static function getServiceName(): string
177
    {
178
        return 'tailwind';
2✔
179
    }
180

181
    /**
182
     * @param array{input:string,output:string,relative_input:string,relative_output:string,label:string,watchRoots:array<int,string>} $source
183
     */
184
    private function buildSingleSource(
185
        array $source,
186
        bool $minify,
187
    ): int {
188
        $outputPath = $source['output'];
1✔
189
        $relativeInput = $source['relative_input'];
1✔
190
        $relativeOutput = $source['relative_output'];
1✔
191
        $label = $source['label'];
1✔
192

193
        $this->ensureDirectory(dirname($outputPath));
1✔
194

195
        $arguments = array_merge($this->tailwindCommandBase, ['--input', $relativeInput, '--output', $relativeOutput]);
1✔
196

197
        if ($minify) {
1✔
UNCOV
198
            $arguments[] = '--minify';
×
199
        }
200

201
        // Configure Tailwind process with environment variables
202
        // These variables disable various Tailwind features that can cause issues
203
        // in development environments and ensure consistent behavior
204
        $process = new Process($arguments, $this->parameterBag->get('kernel.project_dir'), [
1✔
205
            'TAILWIND_DISABLE_NATIVE' => '1',           // Disable native CSS compiler for compatibility
1✔
206
            'TAILWIND_DISABLE_WATCHMAN' => '1',         // Disable Facebook Watchman for better cross-platform support
1✔
207
            'TAILWIND_DISABLE_WATCHER' => '1',          // Disable Tailwind's built-in file watcher (we use our own)
1✔
208
            'TAILWIND_DISABLE_FILE_DEPENDENCY_SCAN' => '1', // Disable automatic file dependency scanning
1✔
209
            'TMPDIR' => $this->ensureTempDir(),         // Use project-specific temp directory for isolation
1✔
210
        ]);
1✔
211
        $process->setTimeout(null);
1✔
212

213
        $this->io->text(sprintf('• %s', $label));
1✔
214

215
        try {
216
            $process->mustRun(function ($type, $buffer) use ($label): void {
1✔
UNCOV
217
                if ($this->io->isVeryVerbose()) {
×
218
                    $prefix = sprintf('[tailwind:%s] ', $label);
×
219
                    $this->io->write($prefix . $buffer);
×
220
                }
221
            });
1✔
222
        } catch (ProcessFailedException $exception) {
1✔
223
            $this->io->error(sprintf('Tailwind build failed for %s: %s', $label, $exception->getProcess()->getErrorOutput() ?: $exception->getMessage()));
1✔
224

225
            return Command::FAILURE;
1✔
226
        }
227

UNCOV
228
        return Command::SUCCESS;
×
229
    }
230

231
    /**
232
     * @param array<int,array{input:string,output:string,relative_input:string,relative_output:string,label:string,watchRoots:array<int,string>}> $sources
233
     */
234
    private function buildSources(
235
        array $sources,
236
        bool $minify,
237
    ): int {
238
        $this->io->section(sprintf('Building Tailwind CSS for %d source%s', count($sources), 1 === count($sources) ? '' : 's'));
1✔
239

240
        foreach ($sources as $source) {
1✔
241
            $result = $this->buildSingleSource($source, $minify);
1✔
242

243
            if (Command::SUCCESS !== $result) {
1✔
244
                return $result;
1✔
245
            }
246
        }
247

UNCOV
248
        $this->io->success('Tailwind build completed.');
×
249

UNCOV
250
        return Command::SUCCESS;
×
251
    }
252

253
    /**
254
     * @return array<int,array{input:string,output:string,relative_input:string,relative_output:string,label:string,watchRoots:array<int,string>}>
255
     */
256
    private function collectTailwindSources(
257
        bool $includeAllApps,
258
    ): array {
259
        $sources = [];
2✔
260

261
        // Multi-app project structure discovery
262
        if ($includeAllApps) {
2✔
263
            // In watch mode, scan all application directories for Tailwind sources
264
            $appsDir = $this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR . $this->parameterBag->get('valksor.project.apps_dir');
1✔
265

266
            if (is_dir($appsDir)) {
1✔
267
                $handle = opendir($appsDir);
1✔
268

269
                if (false !== $handle) {
1✔
270
                    try {
271
                        while (($entry = readdir($handle)) !== false) {
1✔
272
                            if ('.' === $entry || '..' === $entry) {
1✔
273
                                continue;
1✔
274
                            }
275

276
                            // Skip ignored directories (e.g., node_modules, vendor, .git)
277
                            if ($this->filter->shouldIgnoreDirectory($entry)) {
1✔
278
                                continue;
×
279
                            }
280

281
                            $appRoot = $appsDir . DIRECTORY_SEPARATOR . $entry;
1✔
282

283
                            if (!is_dir($appRoot)) {
1✔
284
                                continue;
×
285
                            }
286

287
                            // Recursively discover Tailwind CSS files in this app
288
                            $this->discoverSources($appRoot, $sources);
1✔
289
                        }
290
                    } finally {
291
                        closedir($handle);
1✔
292
                    }
293
                }
294
            }
295
        } elseif (null !== $this->activeAppId) {
1✔
296
            // In single-app mode, only scan the specified application directory
297
            $appRoot = $this->parameterBag->get('kernel.project_dir') . DIRECTORY_SEPARATOR . $this->parameterBag->get('valksor.project.apps_dir') . '/' . $this->activeAppId;
1✔
298

299
            if (is_dir($appRoot)) {
1✔
300
                $this->discoverSources($appRoot, $sources);
1✔
301
            }
302
        }
303

304
        return $sources;
2✔
305
    }
306

307
    /**
308
     * @return array{input:string,output:string,relative_input:string,relative_output:string,label:string,watchRoots:array<int,string>}
309
     */
310
    private function createSourceDefinition(
311
        string $inputPath,
312
    ): array {
313
        // Convert absolute paths to relative paths from project root
314
        $relativeInput = trim(str_replace('\\', '/', substr($inputPath, strlen($this->parameterBag->get('kernel.project_dir')))), '/');
2✔
315

316
        // Generate output file path by replacing .tailwind.css with .css
317
        $outputPath = preg_replace('/\.tailwind\.css$/', '.css', $inputPath);
2✔
318
        $relativeOutput = trim(str_replace('\\', '/', substr($outputPath, strlen($this->parameterBag->get('kernel.project_dir')))), '/');
2✔
319

320
        $label = $relativeInput;
2✔
321
        $watchRoots = [];
2✔
322

323
        // Multi-app project structure: determine watch roots based on file location
324
        if (1 === preg_match('#^' . $this->parameterBag->get('valksor.project.apps_dir') . '/([^/]+)/#', $relativeInput, $matches)) {
2✔
325
            // File is within an app directory - watch the entire app and shared infrastructure
326
            $appName = $matches[1];
2✔
327
            $label = $appName;
2✔
328
            $watchRoots[] = $this->parameterBag->get('kernel.project_dir') . '/' . $this->parameterBag->get('valksor.project.apps_dir') . '/' . $appName;
2✔
329

330
            // Include shared infrastructure directory if it exists (common utilities, shared components)
331
            if (is_dir($this->parameterBag->get('kernel.project_dir') . '/' . $this->parameterBag->get('valksor.project.infrastructure_dir'))) {
2✔
332
                $watchRoots[] = $this->parameterBag->get('kernel.project_dir') . '/' . $this->parameterBag->get('valksor.project.infrastructure_dir');
×
333
            }
334
        } elseif (str_starts_with($relativeInput, $this->parameterBag->get('valksor.project.infrastructure_dir') . '/')) {
×
335
            // File is in shared infrastructure - watch only the infrastructure directory
336
            $label = $this->parameterBag->get('valksor.project.infrastructure_dir');
×
337
            $watchRoots[] = $this->parameterBag->get('kernel.project_dir') . '/' . $this->parameterBag->get('valksor.project.infrastructure_dir');
×
338
        } else {
339
            // File is outside the standard structure - watch its parent directory
340
            $watchRoots[] = dirname($inputPath);
×
341
        }
342

343
        return [
2✔
344
            'input' => $inputPath,
2✔
345
            'output' => $outputPath,
2✔
346
            'relative_input' => $relativeInput,
2✔
347
            'relative_output' => $relativeOutput,
2✔
348
            'label' => $label,
2✔
349
            'watchRoots' => array_values(array_unique($watchRoots)),
2✔
350
        ];
2✔
351
    }
352

353
    /**
354
     * @param array<int,array{input:string,output:string,relative_input:string,relative_output:string,label:string,watchRoots:array<int,string>}> $sources
355
     */
356
    private function discoverSources(
357
        string $directory,
358
        array &$sources,
359
    ): void {
360
        if (!is_dir($directory)) {
2✔
361
            return;
×
362
        }
363

364
        $handle = opendir($directory);
2✔
365

366
        if (false === $handle) {
2✔
367
            return;
×
368
        }
369

370
        try {
371
            while (($entry = readdir($handle)) !== false) {
2✔
372
                if ('.' === $entry || '..' === $entry) {
2✔
373
                    continue;
2✔
374
                }
375

376
                $full = $directory . DIRECTORY_SEPARATOR . $entry;
2✔
377

378
                if (is_dir($full)) {
2✔
379
                    if ($this->filter->shouldIgnoreDirectory($entry)) {
2✔
380
                        continue;
×
381
                    }
382

383
                    $this->discoverSources($full, $sources);
2✔
384

385
                    continue;
2✔
386
                }
387

388
                if (!str_ends_with($entry, '.tailwind.css')) {
2✔
389
                    continue;
1✔
390
                }
391

392
                $sources[] = $this->createSourceDefinition($full);
2✔
393
            }
394
        } finally {
395
            closedir($handle);
2✔
396
        }
397
    }
398

399
    private function ensureTempDir(): string
400
    {
401
        $tmpDir = $this->parameterBag->get('kernel.project_dir') . '/var/tmp/tailwind';
1✔
402

403
        $this->ensureDirectory($tmpDir);
1✔
404

405
        return $tmpDir;
1✔
406
    }
407

408
    /**
409
     * Get search roots for single-app projects
410
     * Can be customized via ASSET_ROOTS environment variable or injected watch directories.
411
     *
412
     * @return array<int,string>
413
     */
414
    private function resolveTailwindCommandBase(): array
415
    {
416
        $binary = $this->resolveTailwindExecutable();
6✔
417

418
        return [
1✔
419
            'display' => $binary,
1✔
420
            'command' => [$binary],
1✔
421
        ];
1✔
422
    }
423

424
    private function resolveTailwindExecutable(): string
425
    {
426
        // Use project-local Tailwind binary downloaded via valksor:binary command
427
        $tailwindBinary = $this->parameterBag->get('kernel.project_dir') . '/var/tailwindlabs-tailwindcss/tailwindcss';
6✔
428

429
        if (!is_file($tailwindBinary) || !is_executable($tailwindBinary)) {
6✔
430
            throw new RuntimeException('Tailwind executable not found at ' . $tailwindBinary . '. Run "bin/console valksor:binary tailwindcss" to download it.');
5✔
431
        }
432

433
        return $tailwindBinary;
1✔
434
    }
435

436
    /**
437
     * @param array<int,array{input:string,output:string,relative_input:string,relative_output:string,label:string,watchRoots:array<int,string>}> $sources
438
     */
439
    private function watchSources(
440
        array $sources,
441
        bool $minify,
442
    ): int {
443
        $this->io->section('Entering watch mode. Press CTRL+C to stop.');
×
444

445
        $this->running = true;
×
446
        $this->shouldReload = false;
×
447
        $this->shouldShutdown = false;
×
448

449
        $pending = [];
×
450
        $debounceDeadline = 0.0;
×
451
        $outputPaths = [];
×
452
        $rootToSources = [];
×
453

454
        foreach ($sources as $source) {
×
455
            $outputPaths[$source['output']] = true;
×
456

457
            foreach ($source['watchRoots'] as $root) {
×
458
                $rootToSources[$root][$source['input']] = $source;
×
459
            }
460
        }
461

NEW
462
        $watcher = new RecursiveInotifyWatcher($this->filter, static function (string $path) use (&$pending, &$debounceDeadline, $outputPaths, $rootToSources): void {
×
463
            if (is_array($outputPaths) ? array_key_exists($path, $outputPaths) : isset($outputPaths[$path])) {
×
464
                return;
×
465
            }
466

467
            foreach ($rootToSources as $root => $sourcesForRoot) {
×
468
                if (!str_starts_with($path, $root)) {
×
469
                    continue;
×
470
                }
471

472
                foreach ($sourcesForRoot as $source) {
×
473
                    $pending[$source['input']] = $source;
×
474
                    $debounceDeadline = microtime(true) + self::WATCH_DEBOUNCE_SECONDS;
×
475
                }
476
            }
477
        });
×
478

479
        foreach (array_keys($rootToSources) as $root) {
×
480
            $watcher->addRoot($root);
×
481
        }
482

483
        pcntl_async_signals(true);
×
484
        pcntl_signal(SIGINT, function (): void {
×
485
            $this->stop();
×
486
        });
×
487
        pcntl_signal(SIGTERM, function (): void {
×
488
            $this->stop();
×
489
        });
×
490
        pcntl_signal(SIGHUP, function (): void {
×
491
            $this->reload();
×
492
        });
×
493

494
        while ($this->running && !$this->shouldShutdown) {
×
495
            $stream = $watcher->getStream();
×
496
            $read = [$stream];
×
497
            $write = null;
×
498
            $except = null;
×
499
            $ready = @stream_select($read, $write, $except, 0, 250_000);
×
500

501
            if (false === $ready) {
×
502
                continue;
×
503
            }
504
            $watcher->poll();
×
505

506
            if ($this->shouldReload) {
×
507
                $this->io->newLine();
×
508
                $this->io->section('Reloading Tailwind build...');
×
509
                $this->shouldReload = false;
×
510

511
                // Rebuild all sources
512
                foreach ($sources as $source) {
×
513
                    $this->buildSingleSource($source, $minify);
×
514
                }
515

516
                $this->io->success('Tailwind reloaded.');
×
517
            }
518

519
            if ([] !== $pending && microtime(true) >= $debounceDeadline) {
×
520
                $snapshot = $pending;
×
521
                $pending = [];
×
522

523
                foreach ($snapshot as $source) {
×
524
                    $this->buildSingleSource($source, $minify);
×
525
                }
526
            }
527
        }
528

529
        $this->io->newLine();
×
530
        $this->io->success('Tailwind watch terminated.');
×
531

532
        return Command::SUCCESS;
×
533
    }
534
}
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