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