Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.83% covered (warning)
84.83%
179 / 211
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Less_Visitor_processExtends
84.83% covered (warning)
84.83%
179 / 211
61.11% covered (warning)
61.11%
11 / 18
116.63
0.00% covered (danger)
0.00%
0 / 1
 run
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 doExtendChaining
75.76% covered (warning)
75.76%
25 / 33
0.00% covered (danger)
0.00%
0 / 1
11.42
 visitDeclaration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 visitMixinDefinition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 visitSelector
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 visitRuleset
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 ExtendMatch
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 findMatch
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
15
 HasMatches
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 PotentialMatch
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 isElementValuesEqual
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
8.30
 isSelectorValuesEqual
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
110
 isAttributeValuesEqual
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
9.04
 extendSelector
86.00% covered (warning)
86.00%
43 / 50
0.00% covered (danger)
0.00%
0 / 1
9.22
 visitMedia
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 visitMediaOut
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 visitAtRule
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 visitAtRuleOut
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4/**
5 * @private
6 */
7class Less_Visitor_processExtends extends Less_Visitor {
8
9    /** @var Less_Tree_Extend[][] */
10    public $allExtendsStack;
11
12    /**
13     * @param Less_Tree_Ruleset $root
14     */
15    public function run( $root ) {
16        $extendFinder = new Less_Visitor_extendFinder();
17        $extendFinder->run( $root );
18        if ( !$extendFinder->foundExtends ) {
19            return $root;
20        }
21
22        $root->allExtends = $this->doExtendChaining( $root->allExtends, $root->allExtends );
23
24        $this->allExtendsStack = [];
25        $this->allExtendsStack[] = &$root->allExtends;
26
27        return $this->visitObj( $root );
28    }
29
30    private function doExtendChaining( $extendsList, $extendsListTarget, $iterationCount = 0 ) {
31        //
32        // chaining is different from normal extension.. if we extend an extend then we are not just copying, altering and pasting
33        // the selector we would do normally, but we are also adding an extend with the same target selector
34        // this means this new extend can then go and alter other extends
35        //
36        // this method deals with all the chaining work - without it, extend is flat and doesn't work on other extend selectors
37        // this is also the most expensive.. and a match on one selector can cause an extension of a selector we had already processed if
38        // we look at each selector at a time, as is done in visitRuleset
39
40        $extendsToAdd = [];
41
42        // loop through comparing every extend with every target extend.
43        // a target extend is the one on the ruleset we are looking at copy/edit/pasting in place
44        // e.g. .a:extend(.b) {} and .b:extend(.c) {} then the first extend extends the second one
45        // and the second is the target.
46        // the separation into two lists allows us to process a subset of chains with a bigger set, as is the
47        // case when processing media queries
48        for ( $extendIndex = 0, $extendsList_len = count( $extendsList ); $extendIndex < $extendsList_len; $extendIndex++ ) {
49            for ( $targetExtendIndex = 0; $targetExtendIndex < count( $extendsListTarget ); $targetExtendIndex++ ) {
50
51                $extend = $extendsList[$extendIndex];
52                $targetExtend = $extendsListTarget[$targetExtendIndex];
53
54                // Optimisation: Explicit reference, <https://github.com/wikimedia/less.php/pull/14>
55                if ( \array_key_exists( $targetExtend->object_id, $extend->parent_ids ) ) {
56                    // ignore circular references
57                    continue;
58                }
59
60                // find a match in the target extends self selector (the bit before :extend)
61                $selectorPath = [ $targetExtend->selfSelectors[0] ];
62                $matches = $this->findMatch( $extend, $selectorPath );
63
64                if ( $matches ) {
65
66                    // we found a match, so for each self selector..
67                    foreach ( $extend->selfSelectors as $selfSelector ) {
68
69                        // process the extend as usual
70                        $newSelector = $this->extendSelector( $matches, $selectorPath, $selfSelector );
71
72                        // but now we create a new extend from it
73                        $newExtend = new Less_Tree_Extend( $targetExtend->selector, $targetExtend->option, 0 );
74                        $newExtend->selfSelectors = $newSelector;
75
76                        // add the extend onto the list of extends for that selector
77                        end( $newSelector )->extendList = [ $newExtend ];
78                        // $newSelector[ count($newSelector)-1]->extendList = array($newExtend);
79
80                        // record that we need to add it.
81                        $extendsToAdd[] = $newExtend;
82                        $newExtend->ruleset = $targetExtend->ruleset;
83
84                        // remember its parents for circular references
85                        $newExtend->parent_ids = array_merge( $newExtend->parent_ids, $targetExtend->parent_ids, $extend->parent_ids );
86
87                        // only process the selector once.. if we have :extend(.a,.b) then multiple
88                        // extends will look at the same selector path, so when extending
89                        // we know that any others will be duplicates in terms of what is added to the css
90                        if ( $targetExtend->firstExtendOnThisSelectorPath ) {
91                            $newExtend->firstExtendOnThisSelectorPath = true;
92                            $targetExtend->ruleset->paths[] = $newSelector;
93                        }
94                    }
95                }
96            }
97        }
98
99        if ( $extendsToAdd ) {
100            // try to detect circular references to stop a stack overflow.
101            // may no longer be needed.            $this->extendChainCount++;
102            if ( $iterationCount > 100 ) {
103
104                try {
105                    $selectorOne = $extendsToAdd[0]->selfSelectors[0]->toCSS();
106                    $selectorTwo = $extendsToAdd[0]->selector->toCSS();
107                } catch ( Exception $e ) {
108                    $selectorOne = "{unable to calculate}";
109                    $selectorTwo = "{unable to calculate}";
110                }
111
112                throw new Less_Exception_Parser(
113                    "extend circular reference detected. One of the circular extends is currently:" . $selectorOne . ":extend(" . $selectorTwo . ")"
114                );
115            }
116
117            // now process the new extends on the existing rules so that we can handle a extending b extending c ectending d extending e...
118            $extendsToAdd = $this->doExtendChaining( $extendsToAdd, $extendsListTarget, $iterationCount + 1 );
119        }
120
121        return array_merge( $extendsList, $extendsToAdd );
122    }
123
124    protected function visitDeclaration( $declNode, &$visitDeeper ) {
125        $visitDeeper = false;
126    }
127
128    protected function visitMixinDefinition( $mixinDefinitionNode, &$visitDeeper ) {
129        $visitDeeper = false;
130    }
131
132    protected function visitSelector( $selectorNode, &$visitDeeper ) {
133        $visitDeeper = false;
134    }
135
136    protected function visitRuleset( $rulesetNode ) {
137        if ( $rulesetNode->root ) {
138            return;
139        }
140
141        $allExtends = end( $this->allExtendsStack );
142        $paths_len = count( $rulesetNode->paths );
143
144        // look at each selector path in the ruleset, find any extend matches and then copy, find and replace
145        foreach ( $allExtends as $allExtend ) {
146            for ( $pathIndex = 0; $pathIndex < $paths_len; $pathIndex++ ) {
147
148                // extending extends happens initially, before the main pass
149                if ( isset( $rulesetNode->extendOnEveryPath ) && $rulesetNode->extendOnEveryPath ) {
150                    continue;
151                }
152
153                $selectorPath = $rulesetNode->paths[$pathIndex];
154
155                if ( end( $selectorPath )->extendList ) {
156                    continue;
157                }
158
159                $this->ExtendMatch( $rulesetNode, $allExtend, $selectorPath );
160
161            }
162        }
163    }
164
165    private function ExtendMatch( $rulesetNode, $extend, $selectorPath ) {
166        $matches = $this->findMatch( $extend, $selectorPath );
167
168        if ( $matches ) {
169            foreach ( $extend->selfSelectors as $selfSelector ) {
170                $rulesetNode->paths[] = $this->extendSelector( $matches, $selectorPath, $selfSelector );
171            }
172        }
173    }
174
175    /**
176     * @param Less_Tree_Extend $extend
177     * @param Less_Tree_Selector[] $haystackSelectorPath
178     * @return false|array<array{index:int,initialCombinator:string}>
179     */
180    private function findMatch( $extend, $haystackSelectorPath ) {
181        if ( !$this->HasMatches( $extend, $haystackSelectorPath ) ) {
182            return false;
183        }
184
185        //
186        // look through the haystack selector path to try and find the needle - extend.selector
187        // returns an array of selector matches that can then be replaced
188        //
189        $needleElements = $extend->selector->elements;
190        $potentialMatches = [];
191        $potentialMatches_len = 0;
192        $potentialMatch = null;
193        $matches = [];
194
195        // loop through the haystack elements
196        $haystack_path_len = count( $haystackSelectorPath );
197        for ( $haystackSelectorIndex = 0; $haystackSelectorIndex < $haystack_path_len; $haystackSelectorIndex++ ) {
198            $hackstackSelector = $haystackSelectorPath[$haystackSelectorIndex];
199
200            $haystack_elements_len = count( $hackstackSelector->elements );
201            for ( $hackstackElementIndex = 0; $hackstackElementIndex < $haystack_elements_len; $hackstackElementIndex++ ) {
202
203                $haystackElement = $hackstackSelector->elements[$hackstackElementIndex];
204
205                // if we allow elements before our match we can add a potential match every time. otherwise only at the first element.
206                if ( $extend->allowBefore || ( $haystackSelectorIndex === 0 && $hackstackElementIndex === 0 ) ) {
207                    $potentialMatches[] = [
208                        'pathIndex' => $haystackSelectorIndex,
209                        'index' => $hackstackElementIndex,
210                        'matched' => 0,
211                        'initialCombinator' => $haystackElement->combinator
212                    ];
213                    $potentialMatches_len++;
214                }
215
216                for ( $i = 0; $i < $potentialMatches_len; $i++ ) {
217
218                    $potentialMatch = &$potentialMatches[$i];
219                    $potentialMatch = $this->PotentialMatch( $potentialMatch, $needleElements, $haystackElement, $hackstackElementIndex );
220
221                    // if we are still valid and have finished, test whether we have elements after and whether these are allowed
222                    if ( $potentialMatch && $potentialMatch['matched'] === $extend->selector->elements_len ) {
223                        $potentialMatch['finished'] = true;
224
225                        if ( !$extend->allowAfter &&
226                            ( $hackstackElementIndex + 1 < $haystack_elements_len || $haystackSelectorIndex + 1 < $haystack_path_len )
227                        ) {
228                            $potentialMatch = null;
229                        }
230                    }
231
232                    // if null we remove, if not, we are still valid, so either push as a valid match or continue
233                    if ( $potentialMatch ) {
234                        if ( $potentialMatch['finished'] ) {
235                            $potentialMatch['length'] = $extend->selector->elements_len;
236                            $potentialMatch['endPathIndex'] = $haystackSelectorIndex;
237                            $potentialMatch['endPathElementIndex'] = $hackstackElementIndex + 1; // index after end of match
238                            $potentialMatches = []; // we don't allow matches to overlap, so start matching again
239                            $potentialMatches_len = 0;
240                            $matches[] = $potentialMatch;
241                        }
242                        continue;
243                    }
244
245                    array_splice( $potentialMatches, $i, 1 );
246                    $potentialMatches_len--;
247                    $i--;
248                }
249            }
250        }
251
252        return $matches;
253    }
254
255    // Before going through all the nested loops, lets check to see if a match is possible
256    // Reduces Bootstrap 3.1 compile time from ~6.5s to ~5.6s
257    private function HasMatches( $extend, $haystackSelectorPath ) {
258        if ( !$extend->selector->cacheable ) {
259            return true;
260        }
261
262        $first_el = $extend->selector->_oelements[0];
263
264        foreach ( $haystackSelectorPath as $hackstackSelector ) {
265            if ( !$hackstackSelector->cacheable ) {
266                return true;
267            }
268
269            // Optimisation: Explicit reference, <https://github.com/wikimedia/less.php/pull/14>
270            if ( \array_key_exists( $first_el, $hackstackSelector->_oelements_assoc ) ) {
271                return true;
272            }
273        }
274
275        return false;
276    }
277
278    /**
279     * @param array $potentialMatch
280     * @param Less_Tree_Element[] $needleElements
281     * @param Less_Tree_Element $haystackElement
282     * @param int $hackstackElementIndex
283     */
284    private function PotentialMatch( $potentialMatch, $needleElements, $haystackElement, $hackstackElementIndex ) {
285        if ( $potentialMatch['matched'] > 0 ) {
286
287            // selectors add " " onto the first element. When we use & it joins the selectors together, but if we don't
288            // then each selector in haystackSelectorPath has a space before it added in the toCSS phase. so we need to work out
289            // what the resulting combinator will be
290            $targetCombinator = $haystackElement->combinator;
291            if ( $targetCombinator === '' && $hackstackElementIndex === 0 ) {
292                $targetCombinator = ' ';
293            }
294
295            if ( $needleElements[ $potentialMatch['matched'] ]->combinator !== $targetCombinator ) {
296                return null;
297            }
298        }
299
300        // if we don't match, null our match to indicate failure
301        if ( !$this->isElementValuesEqual( $needleElements[$potentialMatch['matched'] ]->value, $haystackElement->value ) ) {
302            return null;
303        }
304
305        $potentialMatch['finished'] = false;
306        $potentialMatch['matched']++;
307
308        return $potentialMatch;
309    }
310
311    /**
312     * @param string|Less_Tree_Attribute|Less_Tree_Dimension|Less_Tree_Keyword $elementValue1
313     * @param string|Less_Tree_Attribute|Less_Tree_Dimension|Less_Tree_Keyword $elementValue2
314     * @return bool
315     */
316    private function isElementValuesEqual( $elementValue1, $elementValue2 ) {
317        if ( $elementValue1 === $elementValue2 ) {
318            return true;
319        }
320
321        if ( is_string( $elementValue1 ) || is_string( $elementValue2 ) ) {
322            return false;
323        }
324
325        if ( $elementValue1 instanceof Less_Tree_Attribute ) {
326            return $this->isAttributeValuesEqual( $elementValue1, $elementValue2 );
327        }
328
329        $elementValue1 = $elementValue1->value;
330        if ( $elementValue1 instanceof Less_Tree_Selector ) {
331            return $this->isSelectorValuesEqual( $elementValue1, $elementValue2 );
332        }
333
334        return false;
335    }
336
337    /**
338     * @param Less_Tree_Selector $elementValue1
339     */
340    private function isSelectorValuesEqual( $elementValue1, $elementValue2 ) {
341        $elementValue2 = $elementValue2->value;
342        if ( !( $elementValue2 instanceof Less_Tree_Selector ) || $elementValue1->elements_len !== $elementValue2->elements_len ) {
343            return false;
344        }
345
346        for ( $i = 0; $i < $elementValue1->elements_len; $i++ ) {
347
348            if ( $elementValue1->elements[$i]->combinator !== $elementValue2->elements[$i]->combinator ) {
349                if ( $i !== 0 || ( $elementValue1->elements[$i]->combinator || ' ' ) !== ( $elementValue2->elements[$i]->combinator || ' ' ) ) {
350                    return false;
351                }
352            }
353
354            if ( !$this->isElementValuesEqual( $elementValue1->elements[$i]->value, $elementValue2->elements[$i]->value ) ) {
355                return false;
356            }
357        }
358
359        return true;
360    }
361
362    /**
363     * @param Less_Tree_Attribute $elementValue1
364     */
365    private function isAttributeValuesEqual( $elementValue1, $elementValue2 ) {
366        if ( $elementValue1->op !== $elementValue2->op || $elementValue1->key !== $elementValue2->key ) {
367            return false;
368        }
369
370        if ( !$elementValue1->value || !$elementValue2->value ) {
371            if ( $elementValue1->value || $elementValue2->value ) {
372                return false;
373            }
374            return true;
375        }
376
377        $elementValue1 = $elementValue1->value;
378
379        if ( $elementValue1 instanceof Less_Tree_Quoted ) {
380            $elementValue1 = $elementValue1->value;
381        }
382
383        $elementValue2 = $elementValue2->value;
384
385        if ( $elementValue2 instanceof Less_Tree_Quoted ) {
386            $elementValue2 = $elementValue2->value;
387        }
388
389        return $elementValue1 === $elementValue2;
390    }
391
392    private function extendSelector( $matches, $selectorPath, $replacementSelector ) {
393        // for a set of matches, replace each match with the replacement selector
394
395        $currentSelectorPathIndex = 0;
396        $currentSelectorPathElementIndex = 0;
397        $path = [];
398        $selectorPath_len = count( $selectorPath );
399
400        for ( $matchIndex = 0, $matches_len = count( $matches ); $matchIndex < $matches_len; $matchIndex++ ) {
401
402            $match = $matches[$matchIndex];
403            $selector = $selectorPath[ $match['pathIndex'] ];
404
405            $firstElement = new Less_Tree_Element(
406                $match['initialCombinator'],
407                $replacementSelector->elements[0]->value,
408                $replacementSelector->elements[0]->index,
409                $replacementSelector->elements[0]->currentFileInfo
410            );
411
412            if ( $match['pathIndex'] > $currentSelectorPathIndex && $currentSelectorPathElementIndex > 0 ) {
413                $last_path = end( $path );
414                $last_path->elements = array_merge(
415                    $last_path->elements,
416                    array_slice( $selectorPath[$currentSelectorPathIndex]->elements, $currentSelectorPathElementIndex )
417                );
418                $currentSelectorPathElementIndex = 0;
419                $currentSelectorPathIndex++;
420            }
421
422            $newElements = array_merge(
423                array_slice(
424                    $selector->elements,
425                    $currentSelectorPathElementIndex,
426                    // last parameter of array_slice is different than the last parameter of javascript's slice
427                    $match['index'] - $currentSelectorPathElementIndex
428                ),
429                [ $firstElement ],
430                array_slice( $replacementSelector->elements, 1 )
431            );
432
433            if ( $currentSelectorPathIndex === $match['pathIndex'] && $matchIndex > 0 ) {
434                $last_key = count( $path ) - 1;
435                $path[$last_key]->elements = array_merge( $path[$last_key]->elements, $newElements );
436            } else {
437                $path = array_merge( $path, array_slice( $selectorPath, $currentSelectorPathIndex, $match['pathIndex'] ) );
438                $path[] = new Less_Tree_Selector( $newElements );
439            }
440
441            $currentSelectorPathIndex = $match['endPathIndex'];
442            $currentSelectorPathElementIndex = $match['endPathElementIndex'];
443            if ( $currentSelectorPathElementIndex >= count( $selectorPath[$currentSelectorPathIndex]->elements ) ) {
444                $currentSelectorPathElementIndex = 0;
445                $currentSelectorPathIndex++;
446            }
447        }
448
449        if ( $currentSelectorPathIndex < $selectorPath_len && $currentSelectorPathElementIndex > 0 ) {
450            $last_path = end( $path );
451            $last_path->elements = array_merge(
452                $last_path->elements,
453                array_slice( $selectorPath[$currentSelectorPathIndex]->elements, $currentSelectorPathElementIndex )
454            );
455            $currentSelectorPathIndex++;
456        }
457
458        $slice_len = $selectorPath_len - $currentSelectorPathIndex;
459        $path = array_merge( $path, array_slice( $selectorPath, $currentSelectorPathIndex, $slice_len ) );
460
461        return $path;
462    }
463
464    protected function visitMedia( $mediaNode ) {
465        $newAllExtends = array_merge( $mediaNode->allExtends, end( $this->allExtendsStack ) );
466        $this->allExtendsStack[] = $this->doExtendChaining( $newAllExtends, $mediaNode->allExtends );
467    }
468
469    protected function visitMediaOut() {
470        array_pop( $this->allExtendsStack );
471    }
472
473    protected function visitAtRule( $atRuleNode ) {
474        $newAllExtends = array_merge( $atRuleNode->allExtends, end( $this->allExtendsStack ) );
475        $this->allExtendsStack[] = $this->doExtendChaining( $newAllExtends, $atRuleNode->allExtends );
476    }
477
478    protected function visitAtRuleOut() {
479        array_pop( $this->allExtendsStack );
480    }
481
482}