ProcessExtendsVisitor::visitDirectiveOut()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 2
1
<?php
2
3
/*
4
 * This file is part of the ILess
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
namespace ILess\Visitor;
11
12
use ILess\Context;
13
use ILess\Exception\Exception;
14
use ILess\Exception\ParserException;
15
use ILess\Node\AttributeNode;
16
use ILess\Node\DirectiveNode;
17
use ILess\Node\ElementNode;
18
use ILess\Node\ExtendNode;
19
use ILess\Node\MediaNode;
20
use ILess\Node\MixinDefinitionNode;
21
use ILess\Node\SelectorNode;
22
use ILess\Node\RulesetNode;
23
use ILess\Node\RuleNode;
24
25
/**
26
 * Process extends visitor.
27
 */
28
class ProcessExtendsVisitor extends Visitor
29
{
30
    /**
31
     * Extends stack.
32
     *
33
     * @var array
34
     */
35
    public $allExtendsStack = [];
36
37
    /**
38
     * @var array
39
     */
40
    public $extendIndicies = [];
41
42
    /**
43
     * @var int
44
     */
45
    private $extendChainCount = 0;
46
47
    /**
48
     * {@inheritdoc}
49
     */
50
    public function run($root)
51
    {
52
        $this->extendIndicies = [];
53
54
        $finder = new ExtendFinderVisitor();
55
        $finder->run($root);
56
57
        if (!$finder->foundExtends) {
58
            return $root;
59
        }
60
61
        $root->allExtends = $this->doExtendChaining($root->allExtends, $root->allExtends);
62
        $this->allExtendsStack = [&$root->allExtends];
63
64
        $newRoot = $this->visit($root);
65
        $this->checkExtendsForNonMatched($root->allExtends);
66
67
        return $newRoot;
68
    }
69
70
    private function checkExtendsForNonMatched($extendList)
71
    {
72
        $process = [];
73
        foreach ($extendList as $extend) {
74
            if (!$extend->hasFoundMatches && count($extend->parentIds) === 1) {
75
                $process[] = $extend;
76
            }
77
        }
78
79
        $context = new Context();
80
        foreach ($process as $extend) {
81
            $selector = '_unknown_';
82
            /* @var $extend ExtendNode */
83
            try {
84
                $selector = $extend->selector->toCSS($context);
85
            } catch (Exception $e) {
86
            }
87
88
            if (!isset($this->extendIndicies[$extend->index . ' ' . $selector])) {
89
                $this->extendIndicies[$extend->index . ' ' . $selector] = true;
90
                // FIXME: less.js uses logger to warn here:
91
                // echo "extend '$selector' has no matches";
92
                // logger.warn("extend '" + selector + "' has no matches");
93
            }
94
        }
95
    }
96
97
    /**
98
     * @param array $extendsList
99
     * @param array $extendsListTarget
100
     * @param int $iterationCount
101
     *
102
     * @return array
103
     *
104
     * @throws ParserException
105
     */
106
    private function doExtendChaining(array $extendsList, array $extendsListTarget, $iterationCount = 0)
107
    {
108
        // chaining is different from normal extension.. if we extend an extend then we are not just copying, altering and pasting
109
        // the selector we would do normally, but we are also adding an extend with the same target selector
110
        // this means this new extend can then go and alter other extends
111
        //
112
        // this method deals with all the chaining work - without it, extend is flat and doesn't work on other extend selectors
113
        // this is also the most expensive.. and a match on one selector can cause an extension of a selector we had already processed if
114
        // we look at each selector at a time, as is done in visitRuleset
115
        $extendsToAdd = [];
116
        // loop through comparing every extend with every target extend.
117
        // a target extend is the one on the ruleset we are looking at copy/edit/pasting in place
118
        // e.g. .a:extend(.b) {} and .b:extend(.c) {} then the first extend extends the second one
119
        // and the second is the target.
120
        // the separation into two lists allows us to process a subset of chains with a bigger set, as is the
121
        // case when processing media queries
122
        for ($extendIndex = 0, $extendsListCount = count($extendsList); $extendIndex < $extendsListCount; ++$extendIndex) {
123
            for ($targetExtendIndex = 0; $targetExtendIndex < count($extendsListTarget); ++$targetExtendIndex) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
124
                $extend = $extendsList[$extendIndex];
125
                $targetExtend = $extendsListTarget[$targetExtendIndex];
126
127
                /* @var $extend ExtendNode */
128
                /* @var $targetExtend ExtendNode */
129
                // look for circular references
130
                if (in_array($targetExtend->objectId, $extend->parentIds)) {
131
                    continue;
132
                }
133
134
                // find a match in the target extends self selector (the bit before :extend)
135
                $selectorPath = [$targetExtend->selfSelectors[0]];
136
137
                $matches = $this->findMatch($extend, $selectorPath);
138
                if (count($matches)) {
139
                    $extend->hasFoundMatches = true;
140
                    // we found a match, so for each self selector..
141
                    foreach ($extend->selfSelectors as $selfSelector) {
142
                        // process the extend as usual
143
                        $newSelector = $this->extendSelector($matches, $selectorPath, $selfSelector);
144
                        // but now we create a new extend from it
145
                        $newExtend = new ExtendNode($targetExtend->selector, $targetExtend->option);
146
                        $newExtend->selfSelectors = $newSelector;
147
                        // add the extend onto the list of extends for that selector
148
                        end($newSelector)->extendList = [$newExtend];
149
                        // record that we need to add it.
150
                        $extendsToAdd[] = $newExtend;
151
                        $newExtend->ruleset = $targetExtend->ruleset;
152
153
                        // remember its parents for circular references
154
                        $newExtend->parentIds = array_merge($newExtend->parentIds, $targetExtend->parentIds,
155
                            $extend->parentIds);
156
157
                        // only process the selector once.. if we have :extend(.a,.b) then multiple
158
                        // extends will look at the same selector path, so when extending
159
                        // we know that any others will be duplicates in terms of what is added to the css
160
                        if ($targetExtend->firstExtendOnThisSelectorPath) {
161
                            $newExtend->firstExtendOnThisSelectorPath = true;
162
                            $targetExtend->ruleset->paths[] = $newSelector;
163
                        }
164
                    }
165
                }
166
            }
167
        }
168
169
        if ($extendsToAdd) {
170
            ++$this->extendChainCount;
171
            if ($iterationCount > 100) {
172
                $selectorOne = '{unable to calculate}';
173
                $selectorTwo = '{unable to calculate}';
174
                try {
175
                    $context = new Context();
176
                    $selectorOne = $extendsToAdd[0]->selfSelectors[0]->toCSS($context);
177
                    $selectorTwo = $extendsToAdd[0]->selector->toCSS($context);
178
                } catch (Exception $e) {
179
                    // cannot calculate
180
                }
181
                throw new ParserException(
182
                    sprintf('Extend circular reference detected. One of the circular extends is currently: %s:extend(%s).',
183
                        $selectorOne, $selectorTwo)
184
                );
185
            }
186
187
            // now process the new extends on the existing rules so that we can handle a extending b extending c extending d extending e...
188
            $extendsToAdd = $this->doExtendChaining($extendsToAdd, $extendsListTarget, $iterationCount + 1);
189
        }
190
191
        return array_merge($extendsList, $extendsToAdd);
192
    }
193
194
    /**
195
     * Visits a rule node.
196
     *
197
     * @param RuleNode $node The node
198
     * @param VisitorArguments $arguments The arguments
199
     *
200
     * @return array|RuleNode
201
     */
202
    public function visitRule(RuleNode $node, VisitorArguments $arguments)
203
    {
204
        $arguments->visitDeeper = false;
205
    }
206
207
    /**
208
     * Visits a mixin definition node.
209
     *
210
     * @param MixinDefinitionNode $node The node
211
     * @param VisitorArguments $arguments The arguments
212
     */
213
    public function visitMixinDefinition(MixinDefinitionNode $node, VisitorArguments $arguments)
214
    {
215
        $arguments->visitDeeper = false;
216
    }
217
218
    /**
219
     * Visits a selector node.
220
     *
221
     * @param SelectorNode $node The node
222
     * @param VisitorArguments $arguments The arguments
223
     */
224
    public function visitSelector(SelectorNode $node, VisitorArguments $arguments)
225
    {
226
        $arguments->visitDeeper = false;
227
    }
228
229
    /**
230
     * Visits a ruleset node.
231
     *
232
     * @param RulesetNode $node The node
233
     * @param VisitorArguments $arguments The arguments
234
     */
235
    public function visitRuleset(RulesetNode $node, VisitorArguments $arguments)
236
    {
237
        if ($node->root) {
238
            return;
239
        }
240
241
        $allExtends = $this->allExtendsStack[count($this->allExtendsStack) - 1];
242
        $pathsCount = count($node->paths);
243
        $selectorsToAdd = [];
244
245
        // look at each selector path in the ruleset, find any extend matches and then copy, find and replace
246
        for ($extendIndex = 0, $allExtendCount = count($allExtends); $extendIndex < $allExtendCount; ++$extendIndex) {
247
            for ($pathIndex = 0; $pathIndex < $pathsCount; ++$pathIndex) {
248
                $selectorPath = $node->paths[$pathIndex];
249
                // extending extends happens initially, before the main pass
250
                if ($node->extendOnEveryPath) {
251
                    continue;
252
                }
253
                if (end($selectorPath)->extendList) {
254
                    continue;
255
                }
256
                $matches = $this->findMatch($allExtends[$extendIndex], $selectorPath);
257
                if ($matches) {
258
                    $allExtends[$extendIndex]->hasFoundMatches = true;
259
                    foreach ($allExtends[$extendIndex]->selfSelectors as $selfSelector) {
260
                        $selectorsToAdd[] = $this->extendSelector($matches, $selectorPath, $selfSelector);
261
                    }
262
                }
263
            }
264
        }
265
266
        $node->paths = array_merge($node->paths, $selectorsToAdd);
267
    }
268
269
    /**
270
     * Visits a ruleset node.
271
     *
272
     * @param RulesetNode $node The node
273
     * @param VisitorArguments $arguments The arguments
274
     */
275
    public function visitRulesetOut(RulesetNode $node, VisitorArguments $arguments)
276
    {
277
    }
278
279
    /**
280
     * Visits a media node.
281
     *
282
     * @param RuleNode $node The node
283
     * @param VisitorArguments $arguments The arguments
284
     */
285
    public function visitMedia(MediaNode $node, VisitorArguments $arguments)
286
    {
287
        $newAllExtends = array_merge($node->allExtends, end($this->allExtendsStack));
288
        $this->allExtendsStack[] = $this->doExtendChaining($newAllExtends, $node->allExtends);
289
    }
290
291
    /**
292
     * Visits a media node (!again).
293
     *
294
     * @param RuleNode $node The node
295
     * @param VisitorArguments $arguments The arguments
296
     */
297
    public function visitMediaOut(MediaNode $node, VisitorArguments $arguments)
298
    {
299
        array_pop($this->allExtendsStack);
300
    }
301
302
    /**
303
     * Visits a directive node.
304
     *
305
     * @param RuleNode $node The node
306
     * @param VisitorArguments $arguments The arguments
307
     */
308
    public function visitDirective(DirectiveNode $node, VisitorArguments $arguments)
309
    {
310
        $newAllExtends = array_merge($node->allExtends, end($this->allExtendsStack));
311
        $this->allExtendsStack[] = $this->doExtendChaining($newAllExtends, $node->allExtends);
312
    }
313
314
    /**
315
     * Visits a directive node (!again).
316
     *
317
     * @param RuleNode $node The node
318
     * @param VisitorArguments $arguments The arguments
319
     */
320
    public function visitDirectiveOut(DirectiveNode $node, VisitorArguments $arguments)
321
    {
322
        array_pop($this->allExtendsStack);
323
    }
324
325
    /**
326
     * @param ExtendNode $extend
327
     * @param $haystackSelectorPath
328
     *
329
     * @return array
330
     */
331
    private function findMatch(ExtendNode $extend, $haystackSelectorPath)
332
    {
333
        // look through the haystack selector path to try and find the needle - extend.selector
334
        // returns an array of selector matches that can then be replaced
335
        $needleElements = $extend->selector->elements;
336
        $needleElementsCount = false;
337
        $potentialMatches = [];
338
        $potentialMatchesCount = 0;
339
        $potentialMatch = null;
340
        $matches = [];
341
342
        // loop through the haystack elements
343
        for ($haystackSelectorIndex = 0, $haystackPathCount = count($haystackSelectorPath); $haystackSelectorIndex < $haystackPathCount; ++$haystackSelectorIndex) {
344
            $hackstackSelector = $haystackSelectorPath[$haystackSelectorIndex];
345
            for ($hackstackElementIndex = 0, $haystackElementsCount = count($hackstackSelector->elements); $hackstackElementIndex < $haystackElementsCount; ++$hackstackElementIndex) {
346
                $haystackElement = $hackstackSelector->elements[$hackstackElementIndex];
347
                // if we allow elements before our match we can add a potential match every time. otherwise only at the first element.
348
                if ($extend->allowBefore || ($haystackSelectorIndex === 0 && $hackstackElementIndex === 0)) {
349
                    $potentialMatches[] = [
350
                        'pathIndex' => $haystackSelectorIndex,
351
                        'index' => $hackstackElementIndex,
352
                        'matched' => 0,
353
                        'initialCombinator' => $haystackElement->combinator,
354
                    ];
355
                    ++$potentialMatchesCount;
356
                }
357
358
                for ($i = 0; $i < $potentialMatchesCount; ++$i) {
359
                    $potentialMatch = &$potentialMatches[$i];
360
361
                    // selectors add " " onto the first element. When we use & it joins the selectors together, but if we don't
362
                    // then each selector in haystackSelectorPath has a space before it added in the toCSS phase. so we need to work out
363
                    // what the resulting combinator will be
364
                    $targetCombinator = $haystackElement->combinator->value;
365
                    if ($targetCombinator === '' && $hackstackElementIndex === 0) {
366
                        $targetCombinator = ' ';
367
                    }
368
369
                    // if we don't match, null our match to indicate failure
370
                    if (!$this->isElementValuesEqual($needleElements[$potentialMatch['matched']]->value,
371
                            $haystackElement->value) ||
372
                        ($potentialMatch['matched'] > 0 && $needleElements[$potentialMatch['matched']]->combinator->value !== $targetCombinator)
373
                    ) {
374
                        $potentialMatch = null;
375
                    } else {
376
                        ++$potentialMatch['matched'];
377
                    }
378
379
                    // if we are still valid and have finished, test whether we have elements after and whether these are allowed
380
                    if ($potentialMatch) {
381
                        if ($needleElementsCount === false) {
382
                            $needleElementsCount = count($needleElements);
383
                        }
384
385
                        $potentialMatch['finished'] = ($potentialMatch['matched'] === $needleElementsCount);
386
387
                        if ($potentialMatch['finished'] &&
388
                            (!$extend->allowAfter && ($hackstackElementIndex + 1 < count($hackstackSelector->elements) || $haystackSelectorIndex + 1 < $haystackPathCount))
389
                        ) {
390
                            $potentialMatch = null;
391
                        }
392
                    }
393
                    // if null we remove, if not, we are still valid, so either push as a valid match or continue
394
                    if ($potentialMatch) {
395
                        if ($potentialMatch['finished']) {
396
                            $potentialMatch['length'] = $needleElementsCount;
397
                            $potentialMatch['endPathIndex'] = $haystackSelectorIndex;
398
                            $potentialMatch['endPathElementIndex'] = $hackstackElementIndex + 1; // index after end of match
399
                            $potentialMatches = []; // we don't allow matches to overlap, so start matching again
400
                            $potentialMatchesCount = 0;
401
                            $matches[] = $potentialMatch;
402
                        }
403
                    } else {
404
                        array_splice($potentialMatches, $i, 1);
405
                        --$potentialMatchesCount;
406
                        --$i;
407
                    }
408
                }
409
            }
410
        }
411
412
        return $matches;
413
    }
414
415
    private function isElementValuesEqual($elementValue1, $elementValue2)
416
    {
417
        if (is_string($elementValue1) || is_string($elementValue2)) {
418
            return $elementValue1 === $elementValue2;
419
        }
420
421
        if ($elementValue1 instanceof AttributeNode) {
422
            if ($elementValue1->operator !== $elementValue2->operator || $elementValue1->key !== $elementValue2->key) {
423
                return false;
424
            }
425
426
            if (!$elementValue1->value || !$elementValue2->value) {
427
                if ($elementValue1->value || $elementValue2->value) {
428
                    return false;
429
                }
430
431
                return true;
432
            }
433
            $elementValue1 = ($elementValue1->value->value ? $elementValue1->value->value : $elementValue1->value);
434
            $elementValue2 = ($elementValue2->value->value ? $elementValue2->value->value : $elementValue2->value);
435
436
            return $elementValue1 === $elementValue2;
437
        }
438
439
        $elementValue1 = $elementValue1->value;
440
        $elementValue2 = $elementValue2->value;
441
442
        if ($elementValue1 instanceof SelectorNode) {
443
            if (!($elementValue2 instanceof SelectorNode) || count($elementValue1->elements) !== count($elementValue2->elements)) {
444
                return false;
445
            }
446
447
            for ($i = 0; $i < count($elementValue1->elements); ++$i) {
0 ignored issues
show
Performance Best Practice introduced by
It seems like you are calling the size function count() as part of the test condition. You might want to compute the size beforehand, and not on each iteration.

If the size of the collection does not change during the iteration, it is generally a good practice to compute it beforehand, and not on each iteration:

for ($i=0; $i<count($array); $i++) { // calls count() on each iteration
}

// Better
for ($i=0, $c=count($array); $i<$c; $i++) { // calls count() just once
}
Loading history...
448
                if ($elementValue1->elements[$i]->combinator->value !== $elementValue2->elements[$i]->combinator->value) {
449
                    if ($i !== 0 || ($elementValue1->elements[$i]->combinator->value ? $elementValue1->elements[$i]->combinator->value : ' ') !== ($elementValue2->elements[$i]->combinator->value ? $elementValue2->elements[$i]->combinator->value : ' ')) {
450
                        return false;
451
                    }
452
                }
453
                if (!$this->isElementValuesEqual($elementValue1->elements[$i]->value,
454
                    $elementValue2->elements[$i]->value)
455
                ) {
456
                    return false;
457
                }
458
            }
459
460
            return true;
461
        }
462
463
        return false;
464
    }
465
466
    public function extendSelector($matches, $selectorPath, $replacementSelector)
467
    {
468
        // for a set of matches, replace each match with the replacement selector
469
        $currentSelectorPathIndex = 0;
470
        $currentSelectorPathElementIndex = 0;
471
        $path = [];
472
        $selectorPathCount = count($selectorPath);
473
474
        for ($matchIndex = 0, $matchesCount = count($matches); $matchIndex < $matchesCount; ++$matchIndex) {
475
            $match = $matches[$matchIndex];
476
            $selector = $selectorPath[$match['pathIndex']];
477
            $firstElement = new ElementNode(
478
                $match['initialCombinator'],
479
                $replacementSelector->elements[0]->value,
480
                $replacementSelector->elements[0]->index,
481
                $replacementSelector->elements[0]->currentFileInfo
482
            );
483
484
            if ($match['pathIndex'] > $currentSelectorPathIndex && $currentSelectorPathElementIndex > 0) {
485
                $lastPath = end($path);
486
                $lastPath->elements = array_merge($lastPath->elements,
487
                    array_slice($selectorPath[$currentSelectorPathIndex]->elements, $currentSelectorPathElementIndex));
488
                $currentSelectorPathElementIndex = 0;
489
                ++$currentSelectorPathIndex;
490
            }
491
492
            $newElements = array_merge(
493
            // last parameter of array_slice is different than the last parameter of javascript's slice
494
                array_slice($selector->elements, $currentSelectorPathElementIndex,
495
                    ($match['index'] - $currentSelectorPathElementIndex)),
496
                [$firstElement],
497
                array_slice($replacementSelector->elements, 1)
498
            );
499
500
            if ($currentSelectorPathIndex === $match['pathIndex'] && $matchIndex > 0) {
501
                $lastKey = count($path) - 1;
502
                $path[$lastKey]->elements = array_merge($path[$lastKey]->elements, $newElements);
503
            } else {
504
                $path = array_merge($path, array_slice($selectorPath, $currentSelectorPathIndex, $match['pathIndex']));
505
                $path[] = new SelectorNode($newElements);
506
            }
507
508
            $currentSelectorPathIndex = $match['endPathIndex'];
509
            $currentSelectorPathElementIndex = $match['endPathElementIndex'];
510
            if ($currentSelectorPathElementIndex >= count($selectorPath[$currentSelectorPathIndex]->elements)) {
511
                $currentSelectorPathElementIndex = 0;
512
                ++$currentSelectorPathIndex;
513
            }
514
        }
515
516
        if ($currentSelectorPathIndex < $selectorPathCount && $currentSelectorPathElementIndex > 0) {
517
            $lastPath = end($path);
518
            $lastPath->elements = array_merge($lastPath->elements,
519
                array_slice($selectorPath[$currentSelectorPathIndex]->elements, $currentSelectorPathElementIndex));
520
            ++$currentSelectorPathIndex;
521
        }
522
523
        $sliceLength = count($selectorPath) - $currentSelectorPathIndex;
524
        $path = array_merge($path, array_slice($selectorPath, $currentSelectorPathIndex, $sliceLength));
525
526
        return $path;
527
    }
528
}
529