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

dragomano / scss-php / 23729255505

30 Mar 2026 05:22AM UTC coverage: 92.664% (+0.02%) from 92.642%
23729255505

Pull #53

github

web-flow
Merge 2b462036a into 100339342
Pull Request #53: Fix multiple minor issues

83 of 84 new or added lines in 5 files covered. (98.81%)

11406 of 12309 relevant lines covered (92.66%)

89.44 hits per line

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

93.43
/src/Handlers/Block/DeferredChunkManager.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Bugo\SCSS\Handlers\Block;
6

7
use Bugo\SCSS\NodeDispatcherInterface;
8
use Bugo\SCSS\Nodes\AstNode;
9
use Bugo\SCSS\Nodes\AtRootNode;
10
use Bugo\SCSS\Nodes\DirectiveNode;
11
use Bugo\SCSS\Nodes\RuleNode;
12
use Bugo\SCSS\Nodes\StatementNode;
13
use Bugo\SCSS\Nodes\SupportsNode;
14
use Bugo\SCSS\Nodes\Visitable;
15
use Bugo\SCSS\Runtime\Scope;
16
use Bugo\SCSS\Runtime\TraversalContext;
17
use Bugo\SCSS\Services\Context;
18
use Bugo\SCSS\Services\Evaluator;
19
use Bugo\SCSS\Services\Render;
20
use Bugo\SCSS\Services\Selector;
21
use Bugo\SCSS\Style;
22

23
use function array_splice;
24
use function array_values;
25
use function count;
26
use function implode;
27
use function max;
28
use function str_contains;
29
use function strtolower;
30
use function trim;
31

32
/**
33
 * @phpstan-type AtRuleStackEntry array{
34
 *     type: string,
35
 *     name?: string,
36
 *     prelude?: string,
37
 *     condition?: string
38
 * }
39
 */
40
final readonly class DeferredChunkManager
41
{
42
    public function __construct(
43
        private NodeDispatcherInterface $dispatcher,
44
        private Context $context,
45
        private Evaluator $evaluation,
46
        private Render $render,
47
        private Selector $selector
48
    ) {}
586✔
49

50
    /**
51
     * @param array<int, string> $leadingRootChunks
52
     * @param array<int, string> $trailingRootChunks
53
     */
54
    public function buildRuleResult(
55
        string $output,
56
        array $leadingRootChunks,
57
        array $trailingRootChunks
58
    ): string {
59
        $segmentSeparator = $this->render->outputSeparator();
563✔
60

61
        $result = '';
563✔
62

63
        if ($leadingRootChunks !== []) {
563✔
64
            $result = implode("\n", $leadingRootChunks);
2✔
65
        }
66

67
        if ($output !== '') {
563✔
68
            $ruleOutput = $this->render->trimTrailingNewlines($output);
562✔
69

70
            if ($ruleOutput !== '') {
562✔
71
                if ($result !== '') {
562✔
72
                    $result .= $segmentSeparator;
1✔
73
                }
74

75
                $result .= $ruleOutput;
562✔
76
            }
77
        }
78

79
        if ($trailingRootChunks !== []) {
563✔
80
            $trailingOutput = implode("\n", $trailingRootChunks);
21✔
81

82
            if ($result !== '') {
21✔
83
                $result .= $segmentSeparator;
9✔
84
            }
85

86
            $result .= $trailingOutput;
21✔
87
        }
88

89
        if ($this->context->options()->style === Style::EXPANDED) {
563✔
90
            return $result === '' ? '' : $result . "\n";
549✔
91
        }
92

93
        return $result;
14✔
94
    }
95

96
    public function appendDeferredAtRuleChunk(int $levels, string $chunk): bool
97
    {
98
        $atRuleStackIndex = count($this->render->outputState()->deferredAtRuleStack) - 1;
8✔
99

100
        if ($atRuleStackIndex < 0) {
8✔
101
            return false;
5✔
102
        }
103

104
        $this->render->outputState()->deferredAtRuleStack[$atRuleStackIndex][] = [
3✔
105
            'levels' => $levels,
3✔
106
            'chunk'  => $chunk,
3✔
107
        ];
3✔
108

109
        return true;
3✔
110
    }
111

112
    /**
113
     * @param array<int, string> $leadingRootChunks
114
     * @param array<int, string> $trailingRootChunks
115
     */
116
    public function collectRuleBubblingChunk(
117
        array &$leadingRootChunks,
118
        array &$trailingRootChunks,
119
        bool $hasRenderedChildren,
120
        string $selector,
121
        Scope $scope,
122
        AstNode $child,
123
        TraversalContext $ctx
124
    ): void {
125
        /** @var StatementNode $child */
126
        $bubblingNode = $this->evaluation->normalizeBubblingNodeForSelector($child, $selector);
10✔
127

128
        if ($child instanceof DirectiveNode && strtolower($child->name) === 'media') {
10✔
129
            $atRuleStack        = $this->selector->getCurrentAtRuleStack($ctx->env);
8✔
130
            $parentMediaPrelude = $this->findLastMediaPrelude($atRuleStack);
8✔
131

132
            if ($parentMediaPrelude !== null && $bubblingNode instanceof DirectiveNode) {
8✔
133
                $mergedPrelude = $this->selector->combineMediaQueryPreludes(
2✔
134
                    $parentMediaPrelude,
2✔
135
                    $this->selector->resolveDirectivePrelude($child->prelude, $ctx->env)
2✔
136
                );
2✔
137

138
                $bubblingNode = new DirectiveNode('media', $mergedPrelude, $bubblingNode->body, true);
2✔
139

140
                $stackWithoutLastMedia = $this->removeLastMediaEntryFromAtRuleStack($atRuleStack);
2✔
141

142
                $scope->setVariableLocal('__at_rule_stack', $stackWithoutLastMedia);
2✔
143

144
                $outerCtx = new TraversalContext($ctx->env, max(0, $ctx->indent - 1));
2✔
145

146
                $chunk = $this->render->trimTrailingNewlines(
2✔
147
                    $this->dispatcher->compileWithContext($bubblingNode, $outerCtx)
2✔
148
                );
2✔
149

150
                $scope->setVariableLocal('__at_rule_stack', $atRuleStack);
2✔
151

152
                if ($chunk !== '') {
2✔
153
                    if ($this->appendDeferredAtRuleChunk(1, $chunk)) {
2✔
154
                        return;
×
155
                    }
156

157
                    if ($hasRenderedChildren) {
2✔
158
                        $trailingRootChunks[] = $chunk;
1✔
159
                    } else {
160
                        $leadingRootChunks[] = $chunk;
1✔
161
                    }
162
                }
163

164
                return;
2✔
165
            }
166
        }
167

168
        $chunk = $this->render->trimTrailingNewlines(
8✔
169
            $this->dispatcher->compileWithContext($bubblingNode, $ctx)
8✔
170
        );
8✔
171

172
        if ($chunk !== '') {
8✔
173
            $trailingRootChunks[] = $chunk;
8✔
174
        }
175
    }
176

177
    /**
178
     * @param array<int, string> $trailingRootChunks
179
     */
180
    public function collectRuleAtRootChunk(array &$trailingRootChunks, AtRootNode $child, TraversalContext $ctx): void
181
    {
182
        $atRootResult = $this->selector->compileAtRootBody($child, $ctx->env);
8✔
183
        $rootChunk    = $atRootResult['chunk'];
8✔
184

185
        if ($rootChunk === '') {
8✔
186
            return;
1✔
187
        }
188

189
        if ($atRootResult['escapeLevels'] > 0) {
7✔
190
            if (! $this->appendDeferredAtRuleChunk($atRootResult['escapeLevels'], $rootChunk)) {
4✔
191
                $trailingRootChunks[] = $rootChunk;
1✔
192
            }
193

194
            return;
4✔
195
        }
196

197
        $trailingRootChunks[] = $rootChunk;
3✔
198
    }
199

200
    /**
201
     * @param array<int, string> $trailingRootChunks
202
     */
203
    public function collectDeferredIncludeRootChunks(array &$trailingRootChunks, int $deferredAtRootCount): void
204
    {
205
        $outputState      = $this->render->outputState();
32✔
206
        $atRootStackIndex = count($outputState->deferredAtRootStack) - 1;
32✔
207

208
        if ($atRootStackIndex < 0) {
32✔
209
            return;
1✔
210
        }
211

212
        $deferred      = $outputState->deferredAtRootStack[$atRootStackIndex];
31✔
213
        $deferredCount = count($deferred);
31✔
214

215
        if ($deferredCount <= $deferredAtRootCount) {
31✔
216
            return;
21✔
217
        }
218

219
        $newChunks = array_splice($deferred, $deferredAtRootCount);
10✔
220

221
        $outputState->deferredAtRootStack[$atRootStackIndex] = $deferred;
10✔
222

223
        foreach ($newChunks as $chunk) {
10✔
224
            $trailingRootChunks[] = $chunk;
10✔
225
        }
226
    }
227

228
    public function appendIncludeAtRootChunk(
229
        string &$output,
230
        bool &$first,
231
        AtRootNode $child,
232
        TraversalContext $ctx
233
    ): void {
234
        $atRootResult = $this->selector->compileAtRootBody($child, $ctx->env);
5✔
235
        $chunk        = $atRootResult['chunk'];
5✔
236

237
        if ($chunk === '') {
5✔
238
            return;
1✔
239
        }
240

241
        if ($atRootResult['escapeLevels'] > 0) {
4✔
242
            if (! $this->appendDeferredAtRuleChunk($atRootResult['escapeLevels'], $chunk)
1✔
243
                && ! $ctx->env->getCurrentScope()->hasVariable('__parent_selector')
1✔
244
            ) {
245
                $this->appendOutputChunk($output, $first, $chunk, $child);
1✔
246
            }
247

248
            return;
1✔
249
        }
250

251
        if ($this->appendDeferredRootChunk($chunk)) {
3✔
252
            return;
2✔
253
        }
254

255
        if (! $ctx->env->getCurrentScope()->hasVariable('__parent_selector')) {
1✔
256
            $this->appendOutputChunk($output, $first, $chunk, $child);
1✔
257
        }
258
    }
259

260
    public function appendIncludeBubblingChunk(
261
        string &$output,
262
        bool &$first,
263
        AstNode $child,
264
        TraversalContext $ctx
265
    ): void {
266
        /** @var StatementNode $child */
267
        $parentSelector = $this->selector->getCurrentParentSelector($ctx->env);
5✔
268

269
        $bubblingNode = $parentSelector === null || $parentSelector === ''
5✔
270
            ? $child
×
271
            : $this->evaluation->normalizeBubblingNodeForSelector($child, $parentSelector);
5✔
272

273
        $outerCtx = new TraversalContext($ctx->env, max(0, $ctx->indent - 1));
5✔
274

275
        $chunk = $this->render->trimTrailingNewlines(
5✔
276
            $this->dispatcher->compileWithContext($bubblingNode, $outerCtx)
5✔
277
        );
5✔
278

279
        if ($chunk === '') {
5✔
280
            return;
×
281
        }
282

283
        $stackIndex = count($this->render->outputState()->deferredBubblingStack) - 1;
5✔
284

285
        if ($stackIndex >= 0) {
5✔
286
            if ($this->shouldDeferBubblingChunkToTrailingRoot($child)) {
5✔
287
                $atRootStackIndex = count($this->render->outputState()->deferredAtRootStack) - 1;
4✔
288

289
                if ($atRootStackIndex >= 0) {
4✔
290
                    $this->render->outputState()->deferredAtRootStack[$atRootStackIndex][] = $chunk;
4✔
291
                } else {
292
                    $this->appendDeferredBubblingChunk($chunk);
×
293
                }
294
            } else {
295
                $this->appendDeferredBubblingChunk($chunk);
1✔
296
            }
297

298
            return;
5✔
299
        }
300

301
        $this->appendOutputChunk($output, $first, $chunk, $child);
×
302
    }
303

304
    public function appendIncludedRuleChunk(
305
        string &$output,
306
        bool &$first,
307
        RuleNode $child,
308
        TraversalContext $ctx
309
    ): void {
310
        $parentSelector = $this->selector->getCurrentParentSelector($ctx->env);
6✔
311
        $childSelector  = $this->resolveRuleSelector($child, $ctx);
6✔
312

313
        $compiled = $this->compileNestedPropertyBlock($child, $childSelector, $ctx, $ctx->indent);
6✔
314

315
        if ($compiled !== null && $compiled !== '') {
6✔
316
            $this->appendOutputChunk($output, $first, $compiled, $child);
×
317

318
            return;
×
319
        }
320

321
        $ruleNode = $child;
6✔
322

323
        if ($parentSelector !== null && $parentSelector !== '') {
6✔
324
            $resolvedSelector = str_contains($childSelector, '&')
5✔
325
                ? $this->selector->resolveNestedSelector($childSelector, $parentSelector)
4✔
326
                : $this->selector->combineNestedSelectorWithParent($childSelector, $parentSelector);
1✔
327

328
            $ruleNode = new RuleNode(
5✔
329
                $resolvedSelector,
5✔
330
                $child->children,
5✔
331
                $child->line,
5✔
332
                $child->column
5✔
333
            );
5✔
334
        }
335

336
        $outerCtx = new TraversalContext($ctx->env, max(0, $ctx->indent - 1));
6✔
337

338
        $chunk = $this->render->trimTrailingNewlines(
6✔
339
            $this->dispatcher->compileWithContext($ruleNode, $outerCtx)
6✔
340
        );
6✔
341

342
        if ($chunk === '') {
5✔
343
            return;
×
344
        }
345

346
        if ($this->appendDeferredRootChunk($chunk)) {
5✔
347
            return;
4✔
348
        }
349

350
        $this->appendOutputChunk($output, $first, $chunk, $child);
2✔
351
    }
352

353
    public function appendOutputChunk(string &$output, bool &$first, string $chunk, Visitable $origin): void
354
    {
355
        if (! $first) {
4✔
356
            $this->render->appendChunk($output, "\n");
×
357
        }
358

359
        $this->render->appendChunk($output, $chunk, $origin);
4✔
360

361
        $first = false;
4✔
362
    }
363

364
    public function appendDeferredRootChunk(string $chunk): bool
365
    {
366
        $stackIndex = count($this->render->outputState()->deferredAtRootStack) - 1;
8✔
367

368
        if ($stackIndex < 0) {
8✔
369
            return false;
3✔
370
        }
371

372
        $this->render->outputState()->deferredAtRootStack[$stackIndex][] = $chunk;
6✔
373

374
        return true;
6✔
375
    }
376

377
    public function appendDeferredBubblingChunk(string $chunk): bool
378
    {
379
        $stackIndex = count($this->render->outputState()->deferredBubblingStack) - 1;
2✔
380

381
        if ($stackIndex < 0) {
2✔
382
            return false;
×
383
        }
384

385
        $this->render->outputState()->deferredBubblingStack[$stackIndex][] = $chunk;
2✔
386

387
        return true;
2✔
388
    }
389

390
    public function resolveRuleSelector(RuleNode $node, TraversalContext $ctx): string
391
    {
392
        return str_contains($node->selector, '#{')
27✔
393
            ? $this->evaluation->interpolateText($node->selector, $ctx->env)
2✔
394
            : $node->selector;
27✔
395
    }
396

397
    public function compileNestedPropertyBlock(
398
        RuleNode $node,
399
        string $selector,
400
        TraversalContext $ctx,
401
        int $indent
402
    ): ?string {
403
        $nestedProperty = $this->selector->parseNestedPropertyBlockSelector($selector);
579✔
404

405
        if ($nestedProperty === null) {
579✔
406
            return null;
579✔
407
        }
408

409
        return $this->selector->compileNestedPropertyBlockChildren(
4✔
410
            $node->children,
4✔
411
            $ctx->env,
4✔
412
            $indent,
4✔
413
            $nestedProperty['property'],
4✔
414
            $nestedProperty['value']
4✔
415
        );
4✔
416
    }
417

418
    /**
419
     * @param array<int, string> $trailingRootChunks
420
     */
421
    public function collectNestedRuleChunk(
422
        string &$output,
423
        bool &$hasRenderedChildren,
424
        string $prefix,
425
        string $selector,
426
        RuleNode $node,
427
        RuleNode $child,
428
        bool &$containsStandaloneNestedRuleChunks,
429
        array &$trailingRootChunks,
430
        TraversalContext $ctx
431
    ): void {
432
        $childSelector = $this->resolveRuleSelector($child, $ctx);
23✔
433

434
        $compiled = $this->compileNestedPropertyBlock($child, $childSelector, $ctx, $ctx->indent + 1);
23✔
435

436
        if ($compiled !== null && $compiled !== '') {
23✔
437
            if (! $hasRenderedChildren) {
4✔
438
                $this->render->appendChunk($output, $prefix . $selector . ' {', $node);
2✔
439
                $hasRenderedChildren = true;
2✔
440
            }
441

442
            $this->render->appendChunk($output, "\n");
4✔
443
            $this->render->appendChunk($output, $compiled, $child);
4✔
444

445
            return;
4✔
446
        }
447

448
        $resolvedNestedSelector = str_contains($childSelector, '&')
21✔
449
            ? $this->selector->resolveNestedSelector($childSelector, $selector)
12✔
450
            : $this->selector->combineNestedSelectorWithParent($childSelector, $selector);
12✔
451

452
        $nestedRuleNode = new RuleNode(
21✔
453
            $resolvedNestedSelector,
21✔
454
            $child->children,
21✔
455
            $child->line,
21✔
456
            $child->column
21✔
457
        );
21✔
458

459
        $chunk = $this->render->trimTrailingNewlines(
21✔
460
            $this->dispatcher->compileWithContext($nestedRuleNode, $ctx)
21✔
461
        );
21✔
462

463
        if ($chunk !== '') {
21✔
464
            if ($hasRenderedChildren) {
21✔
465
                $output = $this->render->trimTrailingNewlines($output);
7✔
466

467
                $this->render->appendChunk($output, "\n" . $prefix . '}');
7✔
468

469
                $hasRenderedChildren = false;
7✔
470
            }
471

472
            if ($trailingRootChunks !== []) {
21✔
473
                if ($output !== '') {
1✔
NEW
474
                    $this->render->appendChunk($output, "\n");
×
475
                }
476

477
                $this->render->appendChunk($output, implode("\n", $trailingRootChunks));
1✔
478

479
                $trailingRootChunks = [];
1✔
480
            }
481

482
            if ($output !== '') {
21✔
483
                $this->render->appendChunk($output, "\n");
10✔
484
            }
485

486
            $this->render->appendChunk($output, $chunk, $child);
21✔
487

488
            $containsStandaloneNestedRuleChunks = true;
21✔
489
        }
490
    }
491

492
    /**
493
     * @param array<int, AtRuleStackEntry> $stack
494
     */
495
    private function findLastMediaPrelude(array $stack): ?string
496
    {
497
        for ($index = count($stack) - 1; $index >= 0; $index--) {
8✔
498
            $entry = $stack[$index];
2✔
499

500
            if ($entry['type'] !== 'directive' || ($entry['name'] ?? '') !== 'media') {
2✔
501
                continue;
×
502
            }
503

504
            return trim($entry['prelude'] ?? '');
2✔
505
        }
506

507
        return null;
6✔
508
    }
509

510
    /**
511
     * @param array<int, AtRuleStackEntry> $stack
512
     * @return array<int, AtRuleStackEntry>
513
     */
514
    private function removeLastMediaEntryFromAtRuleStack(array $stack): array
515
    {
516
        for ($index = count($stack) - 1; $index >= 0; $index--) {
2✔
517
            $entry = $stack[$index];
2✔
518

519
            if ($entry['type'] === 'directive' && isset($entry['name']) && $entry['name'] === 'media') {
2✔
520
                unset($stack[$index]);
2✔
521

522
                return array_values($stack);
2✔
523
            }
524
        }
525

526
        return $stack;
×
527
    }
528

529
    private function shouldDeferBubblingChunkToTrailingRoot(AstNode $node): bool
530
    {
531
        if ($node instanceof SupportsNode) {
5✔
532
            return true;
×
533
        }
534

535
        return $node instanceof DirectiveNode && strtolower($node->name) === 'media';
5✔
536
    }
537
}
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