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