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

dragomano / scss-php / 23675604391

28 Mar 2026 02:28AM UTC coverage: 92.642% (+8.1%) from 84.536%
23675604391

push

github

dragomano
Update README

11357 of 12259 relevant lines covered (92.64%)

87.82 hits per line

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

92.75
/src/Services/ExtendsResolver.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Bugo\SCSS\Services;
6

7
use Bugo\SCSS\CompilerContext;
8
use Bugo\SCSS\Exceptions\InvalidLoopBoundaryException;
9
use Bugo\SCSS\Exceptions\MaxIterationsExceededException;
10
use Bugo\SCSS\Exceptions\SassErrorException;
11
use Bugo\SCSS\Nodes\AstNode;
12
use Bugo\SCSS\Nodes\AtRootNode;
13
use Bugo\SCSS\Nodes\DirectiveNode;
14
use Bugo\SCSS\Nodes\EachNode;
15
use Bugo\SCSS\Nodes\ExtendNode;
16
use Bugo\SCSS\Nodes\ForNode;
17
use Bugo\SCSS\Nodes\IfNode;
18
use Bugo\SCSS\Nodes\NumberNode;
19
use Bugo\SCSS\Nodes\RootNode;
20
use Bugo\SCSS\Nodes\RuleNode;
21
use Bugo\SCSS\Nodes\StringNode;
22
use Bugo\SCSS\Nodes\SupportsNode;
23
use Bugo\SCSS\Nodes\WhileNode;
24
use Bugo\SCSS\Runtime\Environment;
25
use Bugo\SCSS\Utils\SelectorHelper;
26
use Bugo\SCSS\Utils\SelectorTokenizer;
27
use Closure;
28

29
use function array_flip;
30
use function array_keys;
31
use function array_reverse;
32
use function array_slice;
33
use function array_unique;
34
use function array_values;
35
use function count;
36
use function ctype_alnum;
37
use function implode;
38
use function is_numeric;
39
use function str_contains;
40
use function str_starts_with;
41
use function strlen;
42
use function strpos;
43
use function strtolower;
44
use function substr;
45
use function trim;
46

47
final readonly class ExtendsResolver
48
{
49
    /**
50
     * @param Closure(string): array<int, string> $splitTopLevelSelectorList
51
     * @param Closure(string, string): string $resolveNestedSelector
52
     * @param Closure(AstNode, Environment): AstNode $evaluateValue
53
     * @param Closure(string, Environment): bool $evaluateFunctionCondition
54
     * @param Closure(AstNode, Environment): bool $applyVariableDeclaration
55
     * @param Closure(AstNode): array<int, AstNode> $eachIterableItems
56
     * @param Closure(array<int, string>, AstNode, Environment): void $assignEachVariables
57
     * @param Closure(AstNode, Environment): string $format
58
     */
59
    public function __construct(
60
        private CompilerContext $ctx,
61
        private Text $text,
62
        private SelectorTokenizer $tokenizer,
63
        private Closure $splitTopLevelSelectorList,
64
        private Closure $resolveNestedSelector,
65
        private Closure $evaluateValue,
66
        private Closure $evaluateFunctionCondition,
67
        private Closure $applyVariableDeclaration,
68
        private Closure $eachIterableItems,
69
        private Closure $assignEachVariables,
70
        private Closure $format
71
    ) {}
717✔
72

73
    public function collectExtends(AstNode $node, Environment $env): void
74
    {
75
        if ($node instanceof RootNode) {
612✔
76
            $this->collectChildren($node->children, $env);
612✔
77

78
            return;
610✔
79
        }
80

81
        if ($node instanceof RuleNode) {
611✔
82
            $selector = $this->text->interpolateText($node->selector, $env);
554✔
83

84
            $parentSelectorNode = $env->getCurrentScope()->getStringVariable('__parent_selector');
554✔
85
            $parentSelector     = $parentSelectorNode?->value;
554✔
86

87
            if (
88
                $parentSelector !== null
554✔
89
                && str_contains($selector, '&')
554✔
90
                && ! str_contains($parentSelector, '%')
554✔
91
            ) {
92
                $selector = ($this->resolveNestedSelector)($selector, $parentSelector);
10✔
93
            }
94

95
            $currentContext = $this->getCurrentExtendDirectiveContext($env);
554✔
96
            $outputState    = $this->ctx->outputState;
554✔
97

98
            foreach (($this->splitTopLevelSelectorList)($selector) as $selectorPart) {
554✔
99
                if ($selectorPart === '') {
554✔
100
                    continue;
×
101
                }
102

103
                $outputState->selectorContexts[$selectorPart] ??= [];
554✔
104
                $outputState->selectorContexts[$selectorPart][$currentContext] = true;
554✔
105
            }
106

107
            $env->enterScope();
554✔
108
            $env->getCurrentScope()->setVariableLocal('__parent_selector', new StringNode($selector));
554✔
109
            $this->collectChildren($node->children, $env, $selector, $currentContext);
554✔
110

111
            $env->exitScope();
554✔
112

113
            return;
554✔
114
        }
115

116
        if ($node instanceof SupportsNode) {
611✔
117
            $condition      = trim($node->condition);
22✔
118
            $contextSegment = '@supports' . ($condition !== '' ? ' ' . $condition : '');
22✔
119

120
            $this->collectExtendsInDirectiveContext($node->body, $contextSegment, $env);
22✔
121

122
            return;
22✔
123
        }
124

125
        if ($node instanceof DirectiveNode) {
611✔
126
            if (! $node->hasBlock) {
15✔
127
                return;
1✔
128
            }
129

130
            $name           = strtolower(trim($node->name));
14✔
131
            $prelude        = trim($node->prelude);
14✔
132
            $contextSegment = '@' . $name . ($prelude !== '' ? ' ' . $prelude : '');
14✔
133
            /** @var array<int, AstNode> $body */
134
            $body = $node->body;
14✔
135

136
            $this->collectExtendsInDirectiveContext($body, $contextSegment, $env);
14✔
137

138
            return;
14✔
139
        }
140

141
        if ($node instanceof IfNode) {
610✔
142
            $branch = $this->resolveIfBranch($node, $env);
2✔
143

144
            $this->collectChildren($branch, $env, applyDeclarations: true);
2✔
145

146
            return;
2✔
147
        }
148

149
        if ($node instanceof EachNode) {
610✔
150
            $iterableValue = ($this->evaluateValue)($node->list, $env);
1✔
151
            /** @var array<int, AstNode> $items */
152
            $items = ($this->eachIterableItems)($iterableValue);
1✔
153

154
            $env->enterScope();
1✔
155

156
            foreach ($items as $item) {
1✔
157
                ($this->assignEachVariables)($node->variables, $item, $env);
1✔
158
                $this->collectChildren($node->body, $env, applyDeclarations: true);
1✔
159
            }
160

161
            $env->exitScope();
1✔
162

163
            return;
1✔
164
        }
165

166
        if ($node instanceof ForNode) {
610✔
167
            $from = (int) $this->toLoopNumber($node->from, $env);
3✔
168
            $to   = (int) $this->toLoopNumber($node->to, $env);
3✔
169

170
            if (! $node->inclusive) {
3✔
171
                $to += $from <= $to ? -1 : 1;
×
172
            }
173

174
            $step          = $from <= $to ? 1 : -1;
3✔
175
            $iterations    = 0;
3✔
176
            $maxIterations = 10000;
3✔
177

178
            $env->enterScope();
3✔
179

180
            for ($i = $from; $step > 0 ? $i <= $to : $i >= $to; $i += $step) {
3✔
181
                $iterations++;
3✔
182

183
                if ($iterations > $maxIterations) {
3✔
184
                    throw new MaxIterationsExceededException('@for');
×
185
                }
186

187
                $env->getCurrentScope()->setVariable($node->variable, new NumberNode($i));
3✔
188
                $this->collectChildren($node->body, $env, applyDeclarations: true);
3✔
189
            }
190

191
            $env->exitScope();
3✔
192

193
            return;
3✔
194
        }
195

196
        if ($node instanceof WhileNode) {
610✔
197
            $iterations    = 0;
1✔
198
            $maxIterations = 10000;
1✔
199

200
            while (($this->evaluateFunctionCondition)($node->condition, $env)) {
1✔
201
                $iterations++;
1✔
202

203
                if ($iterations > $maxIterations) {
1✔
204
                    throw new MaxIterationsExceededException('@while');
×
205
                }
206

207
                $this->collectChildren($node->body, $env, applyDeclarations: true);
1✔
208
            }
209

210
            return;
1✔
211
        }
212

213
        if (! $node instanceof AtRootNode) {
610✔
214
            return;
610✔
215
        }
216

217
        $this->collectChildren($node->body, $env);
6✔
218
    }
219

220
    public function finalizeCollectedExtends(): void
221
    {
222
        $outputState = $this->ctx->outputState;
610✔
223

224
        foreach ($outputState->pendingExtends as [
610✔
225
            'target'  => $target,
610✔
226
            'source'  => $source,
610✔
227
            'context' => $sourceContext,
610✔
228
        ]) {
610✔
229
            $this->assertExtendContextIsCompatible($target, $sourceContext);
18✔
230
            $this->registerExtend($target, $source);
17✔
231
        }
232
    }
233

234
    public function registerExtend(string $target, string $source): void
235
    {
236
        $target = trim($target);
17✔
237
        $source = trim($source);
17✔
238

239
        if ($target === '' || $source === '') {
17✔
240
            return;
×
241
        }
242

243
        $state = $this->ctx->outputState;
17✔
244

245
        $state->extendMap[$target] ??= [];
17✔
246
        $state->extendMap[$target][] = $source;
17✔
247
    }
248

249
    /**
250
     * @return array<int, string>
251
     */
252
    public function extractSimpleExtendTargetSelectors(string $target): array
253
    {
254
        $target = trim($target);
20✔
255

256
        if ($target === '') {
20✔
257
            return [];
×
258
        }
259

260
        $targets = ($this->splitTopLevelSelectorList)($target);
20✔
261

262
        foreach ($targets as $item) {
20✔
263
            $this->assertSimpleExtendTargetSelector($item);
20✔
264
        }
265

266
        return $targets;
18✔
267
    }
268

269
    public function applyExtendsToSelector(string $selector): string
270
    {
271
        $parts  = SelectorHelper::splitList($selector, false);
575✔
272
        $result = [];
575✔
273
        $exact  = [];
575✔
274

275
        foreach ($parts as $part) {
575✔
276
            if ($part === '') {
575✔
277
                continue;
×
278
            }
279

280
            if (! str_starts_with($part, '%')) {
575✔
281
                $result[] = $part;
575✔
282
                $exact[]  = $part;
575✔
283
            }
284

285
            $extenders = $this->collectTransitiveExactExtenders($part);
575✔
286

287
            array_push($result, ...$extenders);
575✔
288
            array_push($exact, ...$extenders);
575✔
289
            array_push($result, ...$this->collectReplacementVariants($part));
575✔
290
        }
291

292
        $unique      = array_values(array_unique($result));
575✔
293
        $uniqueExact = array_values(array_unique($exact));
575✔
294

295
        return implode(', ', $this->trimRedundantSelectors($unique, $uniqueExact));
575✔
296
    }
297

298
    /**
299
     * @return array<int, string>
300
     */
301
    private function collectTransitiveExactExtenders(string $part): array
302
    {
303
        $result  = [];
575✔
304
        $pending = array_reverse($this->ctx->outputState->extendMap[$part] ?? []);
575✔
305
        $seen    = [];
575✔
306
        $index   = 0;
575✔
307

308
        while ($index < count($pending)) {
575✔
309
            $extender = $pending[$index++];
9✔
310

311
            if (isset($seen[$extender])) {
9✔
312
                continue;
×
313
            }
314

315
            $seen[$extender] = true;
9✔
316

317
            $result[] = $extender;
9✔
318

319
            foreach (array_reverse($this->ctx->outputState->extendMap[$extender] ?? []) as $nestedExtender) {
9✔
320
                if ($nestedExtender === '' || isset($seen[$nestedExtender])) {
3✔
321
                    continue;
×
322
                }
323

324
                $pending[] = $nestedExtender;
3✔
325
            }
326
        }
327

328
        return $result;
575✔
329
    }
330

331
    /**
332
     * @return array<int, string>
333
     */
334
    private function collectReplacementVariants(string $part): array
335
    {
336
        $result  = [];
575✔
337
        $pending = [$part];
575✔
338
        $seen    = [$part => true];
575✔
339
        $index   = 0;
575✔
340

341
        while ($index < count($pending)) {
575✔
342
            $currentPart = $pending[$index++];
575✔
343

344
            foreach ($this->getOrderedReplacementTargets($currentPart) as $target) {
575✔
345
                foreach ($this->generateExtendedVariants($currentPart, $target) as $extendedPart) {
10✔
346
                    if ($extendedPart === '' || isset($seen[$extendedPart])) {
10✔
347
                        continue;
5✔
348
                    }
349

350
                    $seen[$extendedPart] = true;
9✔
351
                    $result[]  = $extendedPart;
9✔
352
                    $pending[] = $extendedPart;
9✔
353
                }
354
            }
355
        }
356

357
        return $result;
575✔
358
    }
359

360
    /**
361
     * @return array<int, string>
362
     */
363
    private function generateExtendedVariants(string $part, string $target): array
364
    {
365
        $variants = [];
10✔
366

367
        foreach (array_reverse($this->ctx->outputState->extendMap[$target] ?? []) as $extender) {
10✔
368
            array_push($variants, ...$this->replaceExtendTargetInSelectorPart($part, $target, $extender));
10✔
369
        }
370

371
        return $variants;
10✔
372
    }
373

374
    /**
375
     * @return array<int, string>
376
     */
377
    private function getOrderedReplacementTargets(string $part): array
378
    {
379
        $targets = [];
575✔
380
        $seen    = [];
575✔
381
        $parts   = $this->splitSelectorCompoundsByDescendant($part);
575✔
382

383
        foreach (array_reverse($parts) as $compound) {
575✔
384
            foreach (array_reverse($this->tokenizeSelectorCompound($compound)) as $target) {
575✔
385
                if (
386
                    $target === ''
575✔
387
                    || $target === $part
575✔
388
                    || isset($seen[$target])
48✔
389
                    || ! isset($this->ctx->outputState->extendMap[$target])
48✔
390
                    || $this->ctx->outputState->extendMap[$target] === []
575✔
391
                ) {
392
                    continue;
575✔
393
                }
394

395
                $seen[$target] = true;
10✔
396
                $targets[]     = $target;
10✔
397
            }
398
        }
399

400
        return $targets;
575✔
401
    }
402

403
    /**
404
     * @param array<int, string> $selectors
405
     * @param array<int, string> $protectedSelectors
406
     * @return array<int, string>
407
     */
408
    private function trimRedundantSelectors(array $selectors, array $protectedSelectors = []): array
409
    {
410
        $protectedLookup = array_flip($protectedSelectors);
575✔
411

412
        $trimmed = [];
575✔
413
        foreach ($selectors as $index => $selector) {
575✔
414
            if (isset($protectedLookup[$selector])) {
575✔
415
                $trimmed[] = $selector;
575✔
416

417
                continue;
575✔
418
            }
419

420
            $isRedundant = false;
9✔
421

422
            foreach ($selectors as $otherIndex => $otherSelector) {
9✔
423
                if ($index === $otherIndex || $selector === $otherSelector) {
9✔
424
                    continue;
9✔
425
                }
426

427
                if ($this->selectorsAreEquivalent($otherSelector, $selector)) {
9✔
428
                    if ($otherIndex > $index) {
1✔
429
                        $isRedundant = true;
1✔
430

431
                        break;
1✔
432
                    }
433

434
                    continue;
1✔
435
                }
436

437
                if ($this->isSuperselectorOf($otherSelector, $selector)) {
9✔
438
                    $isRedundant = true;
1✔
439

440
                    break;
1✔
441
                }
442
            }
443

444
            if (! $isRedundant) {
9✔
445
                $trimmed[] = $selector;
9✔
446
            }
447
        }
448

449
        return $trimmed;
575✔
450
    }
451

452
    private function selectorsAreEquivalent(string $left, string $right): bool
453
    {
454
        if (
455
            $left === ''
9✔
456
            || $right === ''
9✔
457
            || $this->tokenizer->hasUnsupportedTopLevelCombinator($left)
9✔
458
            || $this->tokenizer->hasUnsupportedTopLevelCombinator($right)
9✔
459
        ) {
460
            return $left === $right;
2✔
461
        }
462

463
        $leftCompounds  = $this->splitSelectorCompoundsByDescendant($left);
7✔
464
        $rightCompounds = $this->splitSelectorCompoundsByDescendant($right);
7✔
465

466
        if (count($leftCompounds) !== count($rightCompounds)) {
7✔
467
            return false;
2✔
468
        }
469

470
        foreach ($leftCompounds as $i => $leftCompound) {
7✔
471
            if (
472
                ! $this->tokenizer->doesCompoundSatisfy($leftCompound, $rightCompounds[$i])
7✔
473
                || ! $this->tokenizer->doesCompoundSatisfy($rightCompounds[$i], $leftCompound)
7✔
474
            ) {
475
                return false;
7✔
476
            }
477
        }
478

479
        return true;
1✔
480
    }
481

482
    private function isSuperselectorOf(string $superselector, string $selector): bool
483
    {
484
        if (
485
            $superselector === ''
9✔
486
            || $selector === ''
9✔
487
            || $superselector === $selector
9✔
488
            || $this->tokenizer->hasUnsupportedTopLevelCombinator($superselector)
9✔
489
            || $this->tokenizer->hasUnsupportedTopLevelCombinator($selector)
9✔
490
        ) {
491
            return false;
2✔
492
        }
493

494
        $superselectorCompounds = $this->splitSelectorCompoundsByDescendant($superselector);
7✔
495
        $selectorCompounds      = $this->splitSelectorCompoundsByDescendant($selector);
7✔
496

497
        if (count($superselectorCompounds) !== count($selectorCompounds)) {
7✔
498
            return false;
2✔
499
        }
500

501
        $isStrict = false;
7✔
502

503
        foreach ($selectorCompounds as $i => $selectorCompound) {
7✔
504
            if (! $this->tokenizer->doesCompoundSatisfy($selectorCompound, $superselectorCompounds[$i])) {
7✔
505
                return false;
7✔
506
            }
507

508
            if (! $this->tokenizer->doesCompoundSatisfy($superselectorCompounds[$i], $selectorCompound)) {
4✔
509
                $isStrict = true;
1✔
510
            }
511
        }
512

513
        return $isStrict;
1✔
514
    }
515

516
    /**
517
     * @param array<int, AstNode> $children
518
     */
519
    private function collectExtendsInDirectiveContext(array $children, string $contextSegment, Environment $env): void
520
    {
521
        $parentContext = $this->getCurrentExtendDirectiveContext($env);
36✔
522

523
        $context = $parentContext === '' ? $contextSegment : $parentContext . '|' . $contextSegment;
36✔
524

525
        $env->enterScope();
36✔
526
        $env->getCurrentScope()->setVariableLocal('__extend_directive_context', new StringNode($context));
36✔
527

528
        $this->collectChildren($children, $env);
36✔
529

530
        $env->exitScope();
36✔
531
    }
532

533
    /**
534
     * @param array<int, AstNode> $children
535
     */
536
    private function collectChildren(
537
        array $children,
538
        Environment $env,
539
        ?string $selector = null,
540
        string $currentContext = '',
541
        bool $applyDeclarations = false
542
    ): void {
543
        foreach ($children as $child) {
612✔
544
            if ($child instanceof ExtendNode && $selector !== null) {
611✔
545
                foreach ($this->extractSimpleExtendTargetSelectors($child->selector) as $extendTarget) {
20✔
546
                    $this->ctx->outputState->pendingExtends[] = [
18✔
547
                        'target'  => $extendTarget,
18✔
548
                        'source'  => $selector,
18✔
549
                        'context' => $currentContext,
18✔
550
                    ];
18✔
551
                }
552

553
                continue;
18✔
554
            }
555

556
            if ($applyDeclarations && ($this->applyVariableDeclaration)($child, $env)) {
611✔
557
                continue;
1✔
558
            }
559

560
            $this->collectExtends($child, $env);
611✔
561
        }
562
    }
563

564
    /**
565
     * @return array<int, AstNode>
566
     */
567
    private function resolveIfBranch(IfNode $node, Environment $env): array
568
    {
569
        if (($this->evaluateFunctionCondition)($node->condition, $env)) {
2✔
570
            return $node->body;
2✔
571
        }
572

573
        foreach ($node->elseIfBranches as $elseIfBranch) {
2✔
574
            if (($this->evaluateFunctionCondition)($elseIfBranch->condition, $env)) {
×
575
                return $elseIfBranch->body;
×
576
            }
577
        }
578

579
        return $node->elseBody;
2✔
580
    }
581

582
    private function toLoopNumber(AstNode $node, Environment $env): float
583
    {
584
        $resolved = ($this->evaluateValue)($node, $env);
3✔
585

586
        if ($resolved instanceof NumberNode) {
3✔
587
            return (float) $resolved->value;
3✔
588
        }
589

590
        $formatted = ($this->format)($resolved, $env);
×
591

592
        if (! is_numeric($formatted)) {
×
593
            throw new InvalidLoopBoundaryException($formatted);
×
594
        }
595

596
        return (float) $formatted;
×
597
    }
598

599
    private function getCurrentExtendDirectiveContext(Environment $env): string
600
    {
601
        $node = $env->getCurrentScope()->getStringVariable('__extend_directive_context');
555✔
602

603
        return $node !== null ? $node->value : '';
555✔
604
    }
605

606
    private function assertSimpleExtendTargetSelector(string $target): void
607
    {
608
        $target = trim($target);
20✔
609

610
        if ($target === '') {
20✔
611
            return;
×
612
        }
613

614
        if ($this->tokenizer->hasUnsupportedTopLevelCombinator($target)) {
20✔
615
            throw new SassErrorException(
×
616
                'Complex selectors may not be extended. Use a simple selector target in @extend.'
×
617
            );
×
618
        }
619

620
        $compounds = $this->splitSelectorCompoundsByDescendant($target);
20✔
621

622
        if (count($compounds) !== 1) {
20✔
623
            throw new SassErrorException(
1✔
624
                'Complex selectors may not be extended. Use a simple selector target in @extend.'
1✔
625
            );
1✔
626
        }
627

628
        $tokens = $this->tokenizeSelectorCompound($compounds[0]);
19✔
629

630
        if (count($tokens) !== 1) {
19✔
631
            throw new SassErrorException(
1✔
632
                'Compound selectors may not be extended. Use separate @extend directives for each simple selector.'
1✔
633
            );
1✔
634
        }
635
    }
636

637
    /**
638
     * @return array<int, string>
639
     */
640
    private function splitSelectorCompoundsByDescendant(string $selector): array
641
    {
642
        return $this->tokenizer->splitAtTopLevel($selector, [' '], handleQuotes: true);
578✔
643
    }
644

645
    /**
646
     * @return array<int, string>
647
     */
648
    private function tokenizeSelectorCompound(string $compound): array
649
    {
650
        return $this->tokenizer->tokenizeCompound($compound);
577✔
651
    }
652

653
    /**
654
     * @return array<int, string>
655
     */
656
    private function replaceExtendTargetInSelectorPart(string $part, string $target, string $extender): array
657
    {
658
        $structured = $this->replaceExtendTargetInStructuredSelectorPart($part, $target, $extender);
10✔
659

660
        if ($structured !== null) {
10✔
661
            return $structured;
8✔
662
        }
663

664
        $fallback = $this->replaceExtendTargetInSelectorPartFallback($part, $target, $extender);
2✔
665

666
        return $fallback === null ? [] : [$fallback];
2✔
667
    }
668

669
    /**
670
     * @return array<int, string>|null
671
     */
672
    private function replaceExtendTargetInStructuredSelectorPart(string $part, string $target, string $extender): ?array
673
    {
674
        if (
675
            $this->tokenizer->hasUnsupportedTopLevelCombinator($part)
10✔
676
            || $this->tokenizer->hasUnsupportedTopLevelCombinator($target)
8✔
677
            || $this->tokenizer->hasUnsupportedTopLevelCombinator($extender)
10✔
678
        ) {
679
            return null;
2✔
680
        }
681

682
        $targetTokens = $this->tokenizeSelectorCompound($target);
8✔
683

684
        if ($targetTokens === []) {
8✔
685
            return null;
×
686
        }
687

688
        $partCompounds     = $this->splitSelectorCompoundsByDescendant($part);
8✔
689
        $extenderCompounds = $this->splitSelectorCompoundsByDescendant($extender);
8✔
690

691
        return $this->tokenizer->replaceExtendTargetInStructuredSelector(
8✔
692
            $partCompounds,
8✔
693
            $targetTokens,
8✔
694
            $extenderCompounds
8✔
695
        );
8✔
696
    }
697

698
    private function replaceExtendTargetInSelectorPartFallback(string $part, string $target, string $extender): ?string
699
    {
700
        $partLength   = strlen($part);
2✔
701
        $targetLength = strlen($target);
2✔
702

703
        if ($targetLength === 0 || $partLength < $targetLength) {
2✔
704
            return null;
×
705
        }
706

707
        $position = strpos($part, $target);
2✔
708

709
        while ($position !== false) {
2✔
710
            $start      = $position;
2✔
711
            $end        = $start + $targetLength;
2✔
712
            $beforeChar = $start > 0 ? $part[$start - 1] : '';
2✔
713
            $afterChar  = $end < $partLength ? $part[$end] : '';
2✔
714

715
            if ($this->isValidSelectorTokenBoundary($target, $beforeChar, $afterChar)) {
2✔
716
                $woven = $this->weaveFallbackExtendedSelector(
2✔
717
                    trim(substr($part, 0, $start)),
2✔
718
                    trim(substr($part, $end)),
2✔
719
                    $extender
2✔
720
                );
2✔
721

722
                if ($woven !== null) {
2✔
723
                    return $woven;
1✔
724
                }
725

726
                return substr($part, 0, $start) . $extender . substr($part, $end);
1✔
727
            }
728

729
            $position = strpos($part, $target, $start + 1);
×
730
        }
731

732
        return null;
×
733
    }
734

735
    private function weaveFallbackExtendedSelector(string $before, string $after, string $extender): ?string
736
    {
737
        if ($before === '' || $this->tokenizer->hasUnsupportedTopLevelCombinator($extender)) {
2✔
738
            return null;
1✔
739
        }
740

741
        if (! str_contains($before, '>') && ! str_contains($before, '+') && ! str_contains($before, '~')) {
2✔
742
            return null;
×
743
        }
744

745
        $extenderCompounds = $this->splitSelectorCompoundsByDescendant($extender);
2✔
746

747
        if (count($extenderCompounds) < 2) {
2✔
748
            return null;
1✔
749
        }
750

751
        $last     = $extenderCompounds[count($extenderCompounds) - 1];
1✔
752
        $segments = [...array_slice($extenderCompounds, 0, -1), $before, $last];
1✔
753

754
        if ($after !== '') {
1✔
755
            $segments[] = $after;
1✔
756
        }
757

758
        return implode(' ', $segments);
1✔
759
    }
760

761
    private function isValidSelectorTokenBoundary(string $target, string $beforeChar, string $afterChar): bool
762
    {
763
        $firstChar         = $target[0];
2✔
764
        $startsWithSpecial = $firstChar === '.' || $firstChar === '#' || $firstChar === '%';
2✔
765

766
        $leftBoundary = $beforeChar === ''
2✔
767
            || $startsWithSpecial
2✔
768
            || ! ctype_alnum($beforeChar) && $beforeChar !== '-' && $beforeChar !== '_';
2✔
769

770
        $rightBoundary = $afterChar === '' || ! ctype_alnum($afterChar) && $afterChar !== '-' && $afterChar !== '_';
2✔
771

772
        return $leftBoundary && $rightBoundary;
2✔
773
    }
774

775
    private function assertExtendContextIsCompatible(string $target, string $sourceContext): void
776
    {
777
        foreach (array_keys($this->ctx->outputState->selectorContexts[$target] ?? []) as $targetContext) {
18✔
778
            if ($targetContext !== $sourceContext) {
10✔
779
                throw new SassErrorException('You may not @extend selectors across media queries.');
1✔
780
            }
781
        }
782
    }
783
}
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