Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.95% covered (success)
90.95%
1307 / 1437
62.73% covered (warning)
62.73%
69 / 110
CRAP
0.00% covered (danger)
0.00%
0 / 1
Less_Parser
90.95% covered (success)
90.95%
1307 / 1437
62.73% covered (warning)
62.73%
69 / 110
927.73
0.00% covered (danger)
0.00%
0 / 1
 __construct
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 Reset
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 SetOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 SetOption
96.30% covered (success)
96.30%
26 / 27
0.00% covered (danger)
0.00%
0 / 1
13
 registerFunction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unregisterFunction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getCss
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
6.01
 findValueOf
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 getVariables
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 findVarByName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 getVariableValue
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
26.22
 rgb2html
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 PreVisitors
25.00% covered (danger)
25.00%
1 / 4
0.00% covered (danger)
0.00%
0 / 1
10.75
 PostVisitors
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
15.38
 parse
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 parseFile
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 ModifyVars
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 SetFileInfo
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 SetCacheDir
n/a
0 / 0
n/a
0 / 0
5
 SetImportDirs
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 _parse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 GetRules
74.29% covered (warning)
74.29%
26 / 35
0.00% covered (danger)
0.00%
0 / 1
15.87
 setInput
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 unsetInput
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cacheFile
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getParsedFiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 restore
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 forget
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWhitespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 matchChar
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 matchReg
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 matchStr
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parseQuoted
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
10.08
 parseUntil
90.36% covered (success)
90.36%
75 / 83
0.00% covered (danger)
0.00%
0 / 1
23.47
 peekReg
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 peekChar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 skipWhitespace
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
11
 expect
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 expectChar
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 parserInputStart
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parseNode
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
8.16
 parsePrimary
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
9
 parseComment
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 parseEntitiesMixinLookup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseEntitiesQuoted
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 parseEntitiesKeyword
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 parseEntitiesCall
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 parseEntitiesArguments
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 parseEntitiesLiteral
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseEntitiesAssignment
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 parseEntitiesUrl
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
7
 parseEntitiesVariable
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 parseEntitiesVariableCurly
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 parseEntitiesProperty
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parseEntitiesPropertyCurly
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 parseEntitiesColor
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parseEntitiesDimension
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 parseUnicodeDescriptor
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 parseEntitiesJavascript
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
7.33
 parseVariable
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parseVariableCall
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
11.09
 parseExtend
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 parseMixinCall
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
17
 parseMixinCallElements
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 parseMixinArgs
87.65% covered (warning)
87.65%
71 / 81
0.00% covered (danger)
0.00%
0 / 1
35.05
 parseMixinRuleLookups
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 parseLookupValue
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 parseMixinDefinition
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 parseEntity
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 parseEnd
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 parseAlpha
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 parseElement
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 parseCombinator
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
13.03
 parseLessSelector
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseSelector
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
17
 parseAttribute
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 parseBlock
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 parseBlockRuleset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseDetachedRuleset
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseRuleset
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
9.07
 parseNameValue
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 parseDeclaration
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
23
 parseAnonymousValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 parsePermissiveValue
92.59% covered (success)
92.59%
50 / 54
0.00% covered (danger)
0.00%
0 / 1
15.09
 parseImport
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 parseImportOptions
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
6.12
 parseImportOption
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseMediaFeature
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 parseMediaFeatures
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
6.44
 parseMedia
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 parseAtRule
95.59% covered (success)
95.59%
65 / 68
0.00% covered (danger)
0.00%
0 / 1
29
 parseValue
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 parseImportant
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 parseSub
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 parseMultiplication
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 parseAddition
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 parseConditions
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 parseCondition
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 parseOperand
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 parseExpression
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
7.01
 parseProperty
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseRuleProperty
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
10
 rulePropertyMatch
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 serializeVars
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 is_method
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 round
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 Error
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 WinPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 AbsPath
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 CacheEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Parse and compile Less files into CSS
5 */
6class Less_Parser {
7
8    /**
9     * Default parser options
10     * @var array<string,mixed>
11     */
12    public static $default_options = [
13        'compress'                => false, // option - whether to compress
14        'strictUnits'            => false, // whether units need to evaluate correctly
15        /* How to process math
16         *   always           - eagerly try to solve all operations
17         *   parens-division  - require parens for division "/"
18         *   parens | strict  - require parens for all operations
19         */
20        // NOTE: We use the default of Less.js 4.0 (parens-division)
21        //       instead of Less.js 3.13 (always).
22        'math'                    => 'parens-division',
23        'relativeUrls'            => true, // option - whether to adjust URL's to be relative
24        'urlArgs'                => '', // whether to add args into url tokens
25        'numPrecision'            => 8,
26
27        'import_dirs'            => [],
28
29        'cache_dir'                => null,
30        'cache_method'            => 'serialize', // false, 'serialize', 'callback';
31        'cache_callback_get'    => null,
32        'cache_callback_set'    => null,
33
34        'sourceMap'                => false, // whether to output a source map
35        'sourceMapBasepath'        => null,
36        'sourceMapWriteTo'        => null,
37        'sourceMapURL'            => null,
38
39        'indentation'             => '  ',
40
41        'plugins'                => [],
42        'functions'             => [],
43
44    ];
45
46    /** @var array{compress:bool,strictUnits:bool,relativeUrls:bool,urlArgs:string,numPrecision:int,import_dirs:array,indentation:string} */
47    public static $options = [];
48
49    private $input;                    // Less input string
50    private $input_len;                // input string length
51    private $pos;                    // current index in `input`
52    private $saveStack = [];    // holds state for backtracking
53    private $furthest;
54    private $mb_internal_encoding = ''; // for remember exists value of mbstring.internal_encoding
55
56    private $autoCommentAbsorb = true;
57    /**
58     * @var array<array{index:int,text:string,isLineComment:bool}>
59     */
60    private $commentStore = [];
61
62    /**
63     * @var Less_Environment
64     */
65    private $env;
66
67    protected $rules = [];
68
69    /**
70     * Evaluated ruleset created by `getCss()`. Stored for potential use in `getVariables()`
71     * @var Less_Tree[]|null
72     */
73    private $cachedEvaldRules;
74
75    public static $has_extends = false;
76
77    public static $next_id = 0;
78
79    /**
80     * Filename to contents of all parsed the files
81     *
82     * @var array
83     */
84    public static $contentsMap = [];
85
86    /**
87     * @param Less_Environment|array|null $env
88     */
89    public function __construct( $env = null ) {
90        // Top parser on an import tree must be sure there is one "env"
91        // which will then be passed around by reference.
92        if ( $env instanceof Less_Environment ) {
93            $this->env = $env;
94        } else {
95            $this->Reset( $env );
96        }
97
98        // mbstring.func_overload > 1 bugfix
99        // The encoding value must be set for each source file,
100        // therefore, to conserve resources and improve the speed of this design is taken here
101        if ( ini_get( 'mbstring.func_overload' ) ) {
102            $this->mb_internal_encoding = ini_get( 'mbstring.internal_encoding' );
103            @ini_set( 'mbstring.internal_encoding', 'ascii' );
104        }
105        Less_Tree::$parse = $this;
106    }
107
108    /**
109     * Reset the parser state completely
110     */
111    public function Reset( $options = null ) {
112        $this->rules = [];
113        $this->cachedEvaldRules = null;
114        self::$has_extends = false;
115        self::$contentsMap = [];
116
117        $this->env = new Less_Environment();
118
119        // set new options
120        $this->SetOptions( self::$default_options );
121        if ( is_array( $options ) ) {
122            $this->SetOptions( $options );
123        }
124
125        $this->env->Init();
126    }
127
128    /**
129     * Set one or more compiler options
130     *  options: import_dirs, cache_dir, cache_method
131     */
132    public function SetOptions( $options ) {
133        foreach ( $options as $option => $value ) {
134            $this->SetOption( $option, $value );
135        }
136    }
137
138    /**
139     * Set one compiler option
140     */
141    public function SetOption( $option, $value ) {
142        switch ( $option ) {
143            case 'strictMath':
144                if ( $value ) {
145                    $this->env->math = Less_Environment::MATH_PARENS;
146                } else {
147                    $this->env->math = Less_Environment::MATH_ALWAYS;
148                }
149                break;
150
151            case 'math':
152                $value = strtolower( $value );
153                if ( $value === 'always' ) {
154                    $this->env->math = Less_Environment::MATH_ALWAYS;
155                } elseif ( $value === 'parens-division' ) {
156                    $this->env->math = Less_Environment::MATH_PARENS_DIVISION;
157                } elseif ( $value === 'parens' || $value === 'strict' ) {
158                    $this->env->math = Less_Environment::MATH_PARENS;
159                }
160                return;
161
162            case 'import_dirs':
163                $this->SetImportDirs( $value );
164                return;
165
166            case 'cache_dir':
167                if ( is_string( $value ) ) {
168                    Less_Cache::SetCacheDir( $value );
169                    Less_Cache::CheckCacheDir();
170                }
171                return;
172            case 'functions':
173                foreach ( $value as $key => $function ) {
174                    $this->registerFunction( $key, $function );
175                }
176                return;
177        }
178
179        self::$options[$option] = $value;
180    }
181
182    /**
183     * Registers a new custom function
184     *
185     * @param string $name function name
186     * @param callable $callback callback
187     */
188    public function registerFunction( $name, $callback ) {
189        $this->env->functions[$name] = $callback;
190    }
191
192    /**
193     * Removed an already registered function
194     *
195     * @param string $name function name
196     */
197    public function unregisterFunction( $name ) {
198        if ( isset( $this->env->functions[$name] ) ) {
199            unset( $this->env->functions[$name] );
200        }
201    }
202
203    /**
204     * Get the current css buffer
205     *
206     * @return string
207     */
208    public function getCss() {
209        $precision = ini_get( 'precision' );
210        @ini_set( 'precision', '16' );
211        $locale = setlocale( LC_NUMERIC, 0 );
212        setlocale( LC_NUMERIC, "C" );
213
214        try {
215            $root = new Less_Tree_Ruleset( null, $this->rules );
216            $root->root = true;
217            $root->firstRoot = true;
218
219            $importVisitor = new Less_ImportVisitor( $this->env );
220            $importVisitor->run( $root );
221
222            $this->PreVisitors( $root );
223
224            self::$has_extends = false;
225            $evaldRoot = $root->compile( $this->env );
226
227            $this->cachedEvaldRules = $evaldRoot->rules;
228
229            $this->PostVisitors( $evaldRoot );
230
231            if ( self::$options['sourceMap'] ) {
232                $generator = new Less_SourceMap_Generator( $evaldRoot, self::$contentsMap, self::$options );
233                // will also save file
234                // FIXME: should happen somewhere else?
235                $css = $generator->generateCSS();
236            } else {
237                $css = $evaldRoot->toCSS();
238            }
239
240            if ( self::$options['compress'] ) {
241                $css = preg_replace( '/(^(\s)+)|((\s)+$)/', '', $css );
242            }
243
244        } catch ( Exception $exc ) {
245            // Intentional fall-through so we can reset environment
246        }
247
248        // reset php settings
249        @ini_set( 'precision', $precision );
250        setlocale( LC_NUMERIC, $locale );
251
252        // If you previously defined $this->mb_internal_encoding
253        // is required to return the encoding as it was before
254        if ( $this->mb_internal_encoding != '' ) {
255            @ini_set( "mbstring.internal_encoding", $this->mb_internal_encoding );
256            $this->mb_internal_encoding = '';
257        }
258
259        // Rethrow exception after we handled resetting the environment
260        if ( !empty( $exc ) ) {
261            throw $exc;
262        }
263
264        return $css;
265    }
266
267    public function findValueOf( $varName ) {
268        $rules = $this->cachedEvaldRules ?? $this->rules;
269
270        foreach ( $rules as $rule ) {
271            if ( isset( $rule->variable ) && ( $rule->variable == true ) && ( str_replace( "@", "", $rule->name ) == $varName ) ) {
272                return $this->getVariableValue( $rule );
273            }
274        }
275        return null;
276    }
277
278    /**
279     * Get an array of the found variables in the parsed input.
280     *
281     * @return array
282     * @phan-return array<string,string|float|array>
283     */
284    public function getVariables() {
285        $variables = [];
286
287        $rules = $this->cachedEvaldRules ?? $this->rules;
288        foreach ( $rules as $key => $rule ) {
289            if ( $rule instanceof Less_Tree_Declaration && $rule->variable ) {
290                $variables[$rule->name] = $this->getVariableValue( $rule );
291            }
292        }
293        return $variables;
294    }
295
296    public function findVarByName( $var_name ) {
297        $rules = $this->cachedEvaldRules ?? $this->rules;
298
299        foreach ( $rules as $rule ) {
300            if ( isset( $rule->variable ) && ( $rule->variable == true ) ) {
301                if ( $rule->name == $var_name ) {
302                    return $this->getVariableValue( $rule );
303                }
304            }
305        }
306        return null;
307    }
308
309    /**
310     * This method gets the value of the less variable from the rules object.
311     * Since the objects vary here we add the logic for extracting the css/less value.
312     *
313     * @param Less_Tree $var
314     * @return mixed
315     * @phan-return string|float|array<string|float>
316     */
317    private function getVariableValue( Less_Tree $var ) {
318        switch ( get_class( $var ) ) {
319            case Less_Tree_Color::class:
320                return $this->rgb2html( $var->rgb );
321            case Less_Tree_Variable::class:
322                return $this->findVarByName( $var->name );
323            case Less_Tree_Keyword::class:
324                return $var->value;
325            case Less_Tree_Anonymous::class:
326                $return = [];
327                if ( is_array( $var->value ) ) {
328                    foreach ( $var->value as $value ) {
329                        /** @var Less_Tree $value */
330                        // in compilation phase, Less_Tree_Anonymous::$val can be a Less_Tree[]
331                        // @phan-suppress-next-line PhanTypeExpectedObjectPropAccess,PhanTypeMismatchArgument
332                        $return[ $value->name ] = $this->getVariableValue( $value );
333                    }
334                }
335                return count( $return ) === 1 ? $return[0] : $return;
336            case Less_Tree_Url::class:
337                // Based on Less_Tree_Url::genCSS()
338                // Recurse to serialize the Less_Tree_Quoted value
339                return 'url(' . $this->getVariableValue( $var->value ) . ')';
340            case Less_Tree_Declaration::class:
341                if ( $var->value instanceof Less_Tree_Anonymous ) {
342                    $nodes = $this->parseNode( $var->value->value, [ 'value', 'important' ], 0, [] );
343                    return $this->getVariableValue( $nodes[1][0] );
344                }
345                return $this->getVariableValue( $var->value );
346            case Less_Tree_Value::class:
347                $values = [];
348                foreach ( $var->value as $sub_value ) {
349                    $values[] = $this->getVariableValue( $sub_value );
350                }
351                return count( $values ) === 1 ? $values[0] : $values;
352            case Less_Tree_Quoted::class:
353                return $var->quote . $var->value . $var->quote;
354            case Less_Tree_Dimension::class:
355                $value = $var->value;
356                if ( $var->unit && $var->unit->numerator ) {
357                    $value .= $var->unit->numerator[0];
358                }
359                return $value;
360            case Less_Tree_Expression::class:
361                $values = [];
362                foreach ( $var->value as $item ) {
363                    $values[] = $this->getVariableValue( $item );
364                }
365                return implode( ' ', $values );
366            case Less_Tree_Operation::class:
367                throw new Exception( 'getVariables() require Less to be compiled. please use $parser->getCss() before calling getVariables()' );
368            case Less_Tree_Unit::class:
369            case Less_Tree_Comment::class:
370            case Less_Tree_Import::class:
371            case Less_Tree_Ruleset::class:
372            default:
373                throw new Exception( "type missing in switch/case getVariableValue for " . get_class( $var ) );
374        }
375    }
376
377    private function rgb2html( $r, $g = -1, $b = -1 ) {
378        if ( is_array( $r ) && count( $r ) == 3 ) {
379            [ $r, $g, $b ] = $r;
380        }
381
382        return sprintf( '#%02x%02x%02x', $r, $g, $b );
383    }
384
385    /**
386     * Run pre-compile visitors
387     */
388    private function PreVisitors( $root ) {
389        if ( self::$options['plugins'] ) {
390            foreach ( self::$options['plugins'] as $plugin ) {
391                if ( !empty( $plugin->isPreEvalVisitor ) ) {
392                    $plugin->run( $root );
393                }
394            }
395        }
396    }
397
398    /**
399     * Run post-compile visitors
400     */
401    private function PostVisitors( $evaldRoot ) {
402        $visitors = [];
403        $visitors[] = new Less_Visitor_joinSelector();
404        if ( self::$has_extends ) {
405            $visitors[] = new Less_Visitor_processExtends();
406        }
407        $visitors[] = new Less_Visitor_toCSS();
408
409        if ( self::$options['plugins'] ) {
410            foreach ( self::$options['plugins'] as $plugin ) {
411                if ( property_exists( $plugin, 'isPreEvalVisitor' ) && $plugin->isPreEvalVisitor ) {
412                    continue;
413                }
414
415                if ( property_exists( $plugin, 'isPreVisitor' ) && $plugin->isPreVisitor ) {
416                    array_unshift( $visitors, $plugin );
417                } else {
418                    $visitors[] = $plugin;
419                }
420            }
421        }
422
423        for ( $i = 0; $i < count( $visitors ); $i++ ) {
424            $visitors[$i]->run( $evaldRoot );
425        }
426    }
427
428    /**
429     * Parse a Less string
430     *
431     * @throws Less_Exception_Parser If the compiler encounters invalid syntax
432     * @param string $str The string to convert
433     * @param string|null $file_uri The url of the file
434     * @return $this
435     */
436    public function parse( $str, $file_uri = null ) {
437        if ( !$file_uri ) {
438            $uri_root = '';
439            $filename = 'anonymous-file-' . self::$next_id++ . '.less';
440        } else {
441            $file_uri = self::WinPath( $file_uri );
442            $filename = $file_uri;
443            $uri_root = dirname( $file_uri );
444        }
445
446        $previousFileInfo = $this->env->currentFileInfo;
447        $uri_root = self::WinPath( $uri_root );
448        $this->SetFileInfo( $filename, $uri_root );
449
450        $this->input = $str;
451        $this->_parse();
452
453        if ( $previousFileInfo ) {
454            $this->env->currentFileInfo = $previousFileInfo;
455        }
456
457        return $this;
458    }
459
460    /**
461     * Parse a Less string from a given file
462     *
463     * @throws Less_Exception_Parser If the compiler encounters invalid syntax
464     * @param string $filename The file to parse
465     * @param string $uri_root The url of the file
466     * @param bool $returnRoot Indicates whether the return value should be a css string a root node
467     * @return Less_Tree_Ruleset|$this
468     */
469    public function parseFile( $filename, $uri_root = '', $returnRoot = false ) {
470        if ( !file_exists( $filename ) ) {
471            $this->Error( sprintf( 'File `%s` not found.', $filename ) );
472        }
473
474        // fix uri_root?
475        // Instead of The mixture of file path for the first argument and directory path for the second argument has bee
476        if ( !$returnRoot && !empty( $uri_root ) && basename( $uri_root ) == basename( $filename ) ) {
477            $uri_root = dirname( $uri_root );
478        }
479
480        $previousFileInfo = $this->env->currentFileInfo;
481
482        if ( $filename ) {
483            $filename = self::AbsPath( $filename, true );
484        }
485        $uri_root = self::WinPath( $uri_root );
486
487        $this->SetFileInfo( $filename, $uri_root );
488
489        $this->env->addParsedFile( $filename );
490
491        if ( $returnRoot ) {
492            $rules = $this->GetRules( $filename );
493            $return = new Less_Tree_Ruleset( null, $rules );
494        } else {
495            $this->_parse( $filename );
496            $return = $this;
497        }
498
499        if ( $previousFileInfo ) {
500            $this->env->currentFileInfo = $previousFileInfo;
501        }
502
503        return $return;
504    }
505
506    /**
507     * Allows a user to set variables values
508     * @param array $vars
509     * @return $this
510     */
511    public function ModifyVars( $vars ) {
512        $this->input = self::serializeVars( $vars );
513        $this->_parse();
514
515        return $this;
516    }
517
518    /**
519     * @param string $filename
520     * @param string $uri_root
521     */
522    public function SetFileInfo( $filename, $uri_root = '' ) {
523        $filename = Less_Environment::normalizePath( $filename );
524        $dirname = preg_replace( '/[^\/\\\\]*$/', '', $filename );
525
526        if ( !empty( $uri_root ) ) {
527            $uri_root = rtrim( $uri_root, '/' ) . '/';
528        }
529
530        $currentFileInfo = [];
531
532        // entry info
533        if ( isset( $this->env->currentFileInfo ) ) {
534            $currentFileInfo['entryPath'] = $this->env->currentFileInfo['entryPath'];
535            $currentFileInfo['entryUri'] = $this->env->currentFileInfo['entryUri'];
536            $currentFileInfo['rootpath'] = $this->env->currentFileInfo['rootpath'];
537
538        } else {
539            $currentFileInfo['entryPath'] = $dirname;
540            $currentFileInfo['entryUri'] = $uri_root;
541            $currentFileInfo['rootpath'] = $dirname;
542        }
543
544        $currentFileInfo['currentDirectory'] = $dirname;
545        $currentFileInfo['currentUri'] = $uri_root . basename( $filename );
546        $currentFileInfo['filename'] = $filename;
547        $currentFileInfo['uri_root'] = $uri_root;
548
549        // inherit reference
550        if ( isset( $this->env->currentFileInfo['reference'] ) && $this->env->currentFileInfo['reference'] ) {
551            $currentFileInfo['reference'] = true;
552        }
553
554        $this->env->currentFileInfo = $currentFileInfo;
555    }
556
557    /**
558     * @deprecated 1.5.1.2
559     */
560    public function SetCacheDir( $dir ) {
561        if ( !file_exists( $dir ) ) {
562            if ( mkdir( $dir ) ) {
563                return true;
564            }
565            throw new Less_Exception_Parser( 'Less.php cache directory couldn\'t be created: ' . $dir );
566
567        } elseif ( !is_dir( $dir ) ) {
568            throw new Less_Exception_Parser( 'Less.php cache directory doesn\'t exist: ' . $dir );
569
570        } elseif ( !is_writable( $dir ) ) {
571            throw new Less_Exception_Parser( 'Less.php cache directory isn\'t writable: ' . $dir );
572
573        } else {
574            $dir = self::WinPath( $dir );
575            Less_Cache::$cache_dir = rtrim( $dir, '/' ) . '/';
576            return true;
577        }
578    }
579
580    /**
581     * Set a list of directories or callbacks the parser should use for determining import paths
582     *
583     * Import closures are called with a single `$path` argument containing the unquoted `@import`
584     * string an input LESS file. The string is unchanged, except for a statically appended ".less"
585     * suffix if the basename does not yet contain a dot. If a dot is present in the filename, you
586     * are responsible for choosing whether to expand "foo.bar" to "foo.bar.less". If your callback
587     * can handle this import statement, return an array with an absolute file path and an optional
588     * URI path, or return void/null to indicate that your callback does not handle this import
589     * statement.
590     *
591     * Example:
592     *
593     *     function ( $path ) {
594     *         if ( $path === 'virtual/something.less' ) {
595     *             return [ '/srv/elsewhere/thing.less', null ];
596     *         }
597     *     }
598     *
599     * @param array $dirs The key should be a server directory from which LESS
600     * files may be imported. The value is an optional public URL or URL base path that corresponds to
601     * the same directory (use empty string otherwise). The value may also be a closure, in
602     * which case the key is ignored.
603     * @phan-param array<string,string|callable> $dirs
604     */
605    public function SetImportDirs( $dirs ) {
606        self::$options['import_dirs'] = [];
607
608        foreach ( $dirs as $path => $uri_root ) {
609
610            $path = self::WinPath( $path );
611            if ( !empty( $path ) ) {
612                $path = rtrim( $path, '/' ) . '/';
613            }
614
615            if ( !is_callable( $uri_root ) ) {
616                $uri_root = self::WinPath( $uri_root );
617                if ( !empty( $uri_root ) ) {
618                    $uri_root = rtrim( $uri_root, '/' ) . '/';
619                }
620            }
621
622            self::$options['import_dirs'][$path] = $uri_root;
623        }
624    }
625
626    /**
627     * @param string|null $file_path
628     */
629    private function _parse( $file_path = null ) {
630        $this->rules = array_merge( $this->rules, $this->GetRules( $file_path ) );
631    }
632
633    /**
634     * Return the results of parsePrimary for $file_path
635     * Use cache and save cached results if possible
636     *
637     * @param string|null $file_path
638     */
639    private function GetRules( $file_path ) {
640        $this->setInput( $file_path );
641
642        $cache_file = $this->cacheFile( $file_path );
643        if ( $cache_file ) {
644            if ( self::$options['cache_method'] == 'callback' ) {
645                $callback = self::$options['cache_callback_get'];
646                if ( is_callable( $callback ) ) {
647                    $cache = $callback( $this, $file_path, $cache_file );
648
649                    if ( $cache ) {
650                        $this->unsetInput();
651                        return $cache;
652                    }
653                }
654
655            } elseif ( file_exists( $cache_file ) ) {
656                switch ( self::$options['cache_method'] ) {
657
658                    // Using serialize
659                    case 'serialize':
660                        $cache = unserialize( file_get_contents( $cache_file ) );
661                        if ( $cache ) {
662                            touch( $cache_file );
663                            $this->unsetInput();
664                            return $cache;
665                        }
666                        break;
667                }
668            }
669        }
670        $this->skipWhitespace( 0 );
671        $rules = $this->parsePrimary();
672
673        if ( $this->pos < $this->input_len ) {
674            throw new Less_Exception_Chunk( $this->input, null, $this->furthest, $this->env->currentFileInfo );
675        }
676
677        $this->unsetInput();
678
679        // save the cache
680        if ( $cache_file ) {
681            if ( self::$options['cache_method'] == 'callback' ) {
682                $callback = self::$options['cache_callback_set'];
683                if ( is_callable( $callback ) ) {
684                    $callback( $this, $file_path, $cache_file, $rules );
685                }
686            } else {
687                switch ( self::$options['cache_method'] ) {
688                    case 'serialize':
689                        file_put_contents( $cache_file, serialize( $rules ) );
690                        break;
691                }
692
693                Less_Cache::CleanCache();
694            }
695        }
696
697        return $rules;
698    }
699
700    /**
701     * @internal since 4.3.0 No longer a public API.
702     */
703    private function setInput( $file_path ) {
704        // Set up the input buffer
705        if ( $file_path ) {
706            $this->input = file_get_contents( $file_path );
707        }
708
709        $this->pos = $this->furthest = 0;
710
711        // Remove potential UTF Byte Order Mark
712        $this->input = preg_replace( '/\\G\xEF\xBB\xBF/', '', $this->input );
713        $this->input_len = strlen( $this->input );
714
715        if ( self::$options['sourceMap'] && $this->env->currentFileInfo ) {
716            $uri = $this->env->currentFileInfo['currentUri'];
717            self::$contentsMap[$uri] = $this->input;
718        }
719    }
720
721    /**
722     * @internal since 4.3.0 No longer a public API.
723     */
724    private function unsetInput() {
725        // Free up some memory
726        $this->input = $this->pos = $this->input_len = $this->furthest = null;
727        $this->saveStack = [];
728    }
729
730    /**
731     * @internal since 4.3.0 Use Less_Cache instead.
732     */
733    private function cacheFile( $file_path ) {
734        if ( $file_path && $this->CacheEnabled() ) {
735
736            $env = get_object_vars( $this->env );
737            unset( $env['frames'] );
738
739            $parts = [];
740            $parts[] = $file_path;
741            $parts[] = filesize( $file_path );
742            $parts[] = filemtime( $file_path );
743            $parts[] = $env;
744            $parts[] = Less_Version::cache_version;
745            $parts[] = self::$options['cache_method'];
746            return Less_Cache::$cache_dir . Less_Cache::$prefix . base_convert( sha1( json_encode( $parts ) ), 16, 36 ) . '.lesscache';
747        }
748    }
749
750    /**
751     * @since 4.3.0
752     * @return string[]
753     */
754    public function getParsedFiles() {
755        return $this->env->imports;
756    }
757
758    /**
759     * @internal since 4.3.0 No longer a public API.
760     */
761    private function save() {
762        $this->saveStack[] = $this->pos;
763    }
764
765    private function restore() {
766        if ( $this->pos > $this->furthest ) {
767            $this->furthest = $this->pos;
768        }
769        $this->pos = array_pop( $this->saveStack );
770    }
771
772    private function forget() {
773        array_pop( $this->saveStack );
774    }
775
776    /**
777     * Determine if the character at the specified offset from the current position is a white space.
778     *
779     * @param int $offset
780     * @return bool
781     */
782    private function isWhitespace( $offset = 0 ) {
783        // @phan-suppress-next-line PhanParamSuspiciousOrder False positive
784        return strpos( " \t\n\r\v\f", $this->input[$this->pos + $offset] ) !== false;
785    }
786
787    /**
788     * Match a single character in the input.
789     *
790     * @param string $tok
791     * @return string|null
792     * @see less-2.5.3.js#parserInput.$char
793     */
794    private function matchChar( $tok ) {
795        if ( ( $this->pos < $this->input_len ) && ( $this->input[$this->pos] === $tok ) ) {
796            $this->skipWhitespace( 1 );
797            return $tok;
798        }
799    }
800
801    /**
802     * Match a regexp from the current start point
803     *
804     * @return string|array|null
805     * @see less-2.5.3.js#parserInput.$re
806     */
807    private function matchReg( $tok ) {
808        if ( preg_match( $tok, $this->input, $match, 0, $this->pos ) ) {
809            $this->skipWhitespace( strlen( $match[0] ) );
810            return count( $match ) === 1 ? $match[0] : $match;
811        }
812    }
813
814    /**
815     * Match an exact string of characters.
816     *
817     * @param string $tok
818     * @return string|null
819     * @see less-2.5.3.js#parserInput.$str
820     */
821    private function matchStr( $tok ) {
822        $tokLength = strlen( $tok );
823        if (
824            ( $this->pos < $this->input_len ) &&
825            substr( $this->input, $this->pos, $tokLength ) === $tok
826        ) {
827            $this->skipWhitespace( $tokLength );
828            return $tok;
829        }
830    }
831
832    /**
833     * @param int|null $loc
834     * @return array|string|void|null
835     * @see less-3.13.1.js#parserInput.$quoted
836     */
837    private function parseQuoted( $loc = null ) {
838        $pos = $loc ?? $this->pos;
839        $startChar = $this->input[ $pos ] ?? '';
840        if ( $startChar !== '\'' && $startChar !== '"' ) {
841            return;
842        }
843        $currentPos = $pos;
844        $i = 1;
845        while ( $currentPos + $i < $this->input_len ) {
846            // Optimization: Skip over irrelevant chars without slow loop
847            $i += strcspn( $this->input, "\n\r$startChar\\", $currentPos + $i );
848            switch ( $this->input[$currentPos + $i++] ) {
849                case "\\":
850                    $i++;
851                    break;
852                case "\r":
853                case "\n":
854                    break;
855                case $startChar:
856                    // NOTE: Our optimization means we look ahead instead of behind,
857                    // so no +1s here.
858                    $str = substr( $this->input, $currentPos, $i );
859                    if ( !$loc && $loc !== 0 ) {
860                        $this->skipWhitespace( $i );
861                        return $str;
862                    }
863                    return [ $startChar, $str ];
864            }
865        }
866        return null;
867    }
868
869    /**
870     * Permissive parsing. Ignores everything except matching {} [] () and quotes
871     * until matching token (outside of blocks)
872     * @see less-3.13.1.js#parserInput.$parseUntil
873     */
874    private function parseUntil( $tok ) {
875        $quote = '';
876        $returnVal = null;
877        $inComment = false;
878        $blockDepth = 0;
879        $blockStack = [];
880        $parseGroups = [];
881        $startPos = $this->pos;
882        $lastPos = $this->pos;
883        $i = $this->pos;
884        $loop = true;
885        if ( is_string( $tok ) ) {
886            $testChar = static function ( $char ) use ( $tok ) {
887                return $tok === $char;
888            };
889        } else {
890            $testChar = static function ( $char ) use ( $tok ) {
891                return in_array( $char, $tok );
892            };
893        }
894        do {
895            $nextChar = $this->input[$i];
896            if ( $blockDepth === 0 && $testChar( $nextChar ) ) {
897                $returnVal = substr( $this->input, $lastPos, $i - $lastPos );
898                if ( $returnVal ) {
899                    $parseGroups[] = $returnVal;
900                } else {
901                    $parseGroups[] = ' ';
902                }
903                $returnVal = $parseGroups;
904                $this->skipWhitespace( $i - $startPos );
905                $loop = false;
906            } else {
907                if ( $inComment ) {
908                    if ( $nextChar === '*' && ( $this->input[$i + 1] ?? '' ) === '/' ) {
909                        $i++;
910                        $blockDepth--;
911                        $inComment = false;
912                    }
913                    $i++;
914                    continue;
915                }
916                switch ( $nextChar ) {
917                    case '\\':
918                        $i++;
919                        $nextChar = $this->input[$i] ?? '';
920                        $parseGroups[] = substr( $this->input, $lastPos, $i - $lastPos + 1 );
921                        $lastPos = $i + 1;
922                        break;
923                    case '/':
924                        if ( ( $this->input[$i + 1] ?? '' ) === '*' ) {
925                            $i++;
926                            $inComment = true;
927                            $blockDepth++;
928                        }
929                        break;
930                    case '\'':
931                    case '"':
932                        $quote = $this->parseQuoted( $i );
933                        if ( $quote ) {
934                            $parseGroups[] = substr( $this->input, $lastPos, $i - $lastPos );
935                            $parseGroups[] = $quote;
936                            $i += strlen( $quote[1] ) - 1;
937                            $lastPos = $i + 1;
938                        } else {
939                            $this->skipWhitespace( $i - $startPos );
940                            $returnVal = $nextChar;
941                            $loop = false;
942                        }
943                        break;
944                    case '{':
945                        $blockStack[] = '}';
946                        $blockDepth++;
947                        break;
948                    case '(':
949                        $blockStack[] = ')';
950                        $blockDepth++;
951                        break;
952                    case '[':
953                        $blockStack[] = ']';
954                        $blockDepth++;
955                        break;
956                    case '}':
957                    case ')':
958                    case ']':
959                        $expected = array_pop( $blockStack );
960                        if ( $nextChar === $expected ) {
961                            $blockDepth--;
962                        } else {
963                            // move the parser to the error and return expected;
964                            $this->skipWhitespace( $i - $startPos );
965                            $returnVal = $expected;
966                            $loop = false;
967                        }
968                }
969                $i++;
970                if ( $i > $this->input_len ) {
971                    $loop = false;
972                }
973            }
974        } while ( $loop );
975
976        return $returnVal ?: null;
977    }
978
979    /**
980     * Same as match(), but don't change the state of the parser,
981     * just return the match.
982     *
983     * @param string $tok
984     * @return int|false
985     */
986    private function peekReg( $tok ) {
987        return preg_match( $tok, $this->input, $match, 0, $this->pos );
988    }
989
990    /**
991     * @param string $tok
992     */
993    private function peekChar( $tok ) {
994        return ( $this->pos < $this->input_len ) && ( $this->input[$this->pos] === $tok );
995    }
996
997    /**
998     * @param int $length
999     * @see less-2.5.3.js#skipWhitespace
1000     */
1001    private function skipWhitespace( $length ) {
1002        $this->pos += $length;
1003
1004        for ( ; $this->pos < $this->input_len; $this->pos++ ) {
1005            $currentChar = $this->input[$this->pos];
1006
1007            if ( $this->autoCommentAbsorb && $currentChar === '/' ) {
1008                $nextChar = $this->input[$this->pos + 1] ?? '';
1009                if ( $nextChar === '/' ) {
1010                    $comment = [ 'index' => $this->pos, 'isLineComment' => true ];
1011                    $nextNewLine = strpos( $this->input, "\n", $this->pos + 2 );
1012                    if ( $nextNewLine === false ) {
1013                        $nextNewLine = $this->input_len ?? 0;
1014                    }
1015                    $this->pos = $nextNewLine;
1016                    $comment['text'] = substr( $this->input, $this->pos, $nextNewLine - $this->pos );
1017                    $this->commentStore[] = $comment;
1018                    continue;
1019                } elseif ( $nextChar === '*' ) {
1020                    $nextStarSlash = strpos( $this->input, "*/", $this->pos + 2 );
1021                    if ( $nextStarSlash !== false ) {
1022                        $comment = [
1023                            'index' => $this->pos,
1024                            'text' => substr( $this->input, $this->pos, $nextStarSlash + 2 -
1025                                $this->pos ),
1026                            'isLineComment' => false,
1027                        ];
1028                        $this->pos += strlen( $comment['text'] ) - 1;
1029                        $this->commentStore[] = $comment;
1030                        continue;
1031                    }
1032                }
1033                break;
1034            }
1035
1036            // Optimization: Skip over irrelevant chars without slow loop
1037            $skip = strspn( $this->input, " \n\t\r", $this->pos );
1038            if ( $skip ) {
1039                $this->pos += $skip - 1;
1040            }
1041            if ( !$skip && $this->pos < $this->input_len ) {
1042                break;
1043            }
1044        }
1045    }
1046
1047    /**
1048     * Parse a token from a regexp or method name string
1049     *
1050     * @param string $tok
1051     * @param string|null $msg
1052     * @see less-2.5.3.js#Parser.expect
1053     */
1054    private function expect( $tok, $msg = null ) {
1055        if ( $tok[0] === '/' ) {
1056            $result = $this->matchReg( $tok );
1057        } else {
1058            $result = $this->$tok();
1059        }
1060        if ( $result !== null ) {
1061            return $result;
1062        }
1063        $this->Error( $msg ? "Expected '" . $tok . "' got '" . $this->input[$this->pos] . "'" : $msg );
1064    }
1065
1066    /**
1067     * @param string $tok
1068     * @param string|null $msg
1069     */
1070    private function expectChar( $tok, $msg = null ) {
1071        $result = $this->matchChar( $tok );
1072        if ( !$result ) {
1073            $msg = $msg ?: "Expected '" . $tok . "' got '" . $this->input[$this->pos] . "'";
1074            $this->Error( $msg );
1075        } else {
1076            return $result;
1077        }
1078    }
1079
1080    /**
1081     * @param string $str
1082     * @see less-3.13.1.js#ParserInput.start
1083     */
1084    private function parserInputStart( $str ) {
1085        $this->pos = $this->furthest = 0;
1086        $this->input = $str;
1087        $this->input_len = strlen( $str );
1088        $this->skipWhitespace( 0 );
1089    }
1090
1091    /**
1092     *  Used after initial parsing to create nodes on the fly
1093     *
1094     * @param string $str string to parse
1095     * @param string[] $parseList array of parsers to run input through e.g. ["value", "important"]
1096     * @param int $currentIndex start number to begin indexing
1097     * @param array $fileInfo fileInfo to attach to created nodes
1098     * @return array
1099     * @see less-3.13.1.js#Parser.parseNode
1100     */
1101    public function parseNode( $str, array $parseList, $currentIndex, $fileInfo ) {
1102        $returnNodes = [];
1103        try {
1104            $this->parserInputStart( $str );
1105            foreach ( $parseList as $p ) {
1106                $i = $this->pos;
1107                $method = 'parse' . ucfirst( $p );
1108                if ( !method_exists( $this, $method ) ) {
1109                    throw new CompileError( 'Unknown parser ' . $p );
1110                }
1111                $result = $this->$method();
1112                if ( $result ) {
1113                    $result->index = $i + $currentIndex;
1114                    $result->currentFileInfo = $fileInfo;
1115                    $returnNodes[] = $result;
1116                } else {
1117                    $returnNodes[] = null;
1118                }
1119            }
1120            if ( $this->pos >= $this->input_len ) {
1121                return [ null, $returnNodes ];
1122            } else {
1123                return [ true, null ];
1124            }
1125        } catch ( Less_Exception_Parser $e ) {
1126            throw new Less_Exception_Parser(
1127                $e->getMessage(),
1128                $e,
1129                ( $e->index ?? 0 ) + $currentIndex,
1130                $fileInfo
1131            );
1132        }
1133    }
1134
1135    //
1136    // Here in, the parsing rules/functions
1137    //
1138    // The basic structure of the syntax tree generated is as follows:
1139    //
1140    //   Ruleset ->  Declaration -> Value -> Expression -> Entity
1141    //
1142    // Here's some LESS code:
1143    //
1144    //    .class {
1145    //      color: #fff;
1146    //      border: 1px solid #000;
1147    //      width: @w + 4px;
1148    //      > .child {...}
1149    //    }
1150    //
1151    // And here's what the parse tree might look like:
1152    //
1153    //     Ruleset (Selector '.class', [
1154    //         Declaration ("color",  Value ([Expression [Color #fff]]))
1155    //         Declaration ("border", Value ([Expression [Dimension 1px][Keyword "solid"][Color #000]]))
1156    //         Declaration ("width",  Value ([Expression [Operation "+" [Variable "@w"][Dimension 4px]]]))
1157    //         Ruleset (Selector [Element '>', '.child'], [...])
1158    //     ])
1159    //
1160    //  In general, most rules will try to parse a token with the `$()` function, and if the return
1161    //  value is truly, will return a new node, of the relevant type. Sometimes, we need to check
1162    //  first, before parsing, that's when we use `peek()`.
1163    //
1164
1165    //
1166    // The `primary` rule is the *entry* and *exit* point of the parser.
1167    // The rules here can appear at any level of the parse tree.
1168    //
1169    // The recursive nature of the grammar is an interplay between the `block`
1170    // rule, which represents `{ ... }`, the `ruleset` rule, and this `primary` rule,
1171    // as represented by this simplified grammar:
1172    //
1173    //     primary  â†’  (ruleset | declaration )+
1174    //     ruleset  â†’  selector+ block
1175    //     block    â†’  '{' primary '}'
1176    //
1177    // Only at one point is the primary rule not called from the
1178    // block rule: at the root level.
1179    //
1180    // @see less-2.5.3.js#parsers.primary
1181    private function parsePrimary() {
1182        $root = [];
1183
1184        while ( true ) {
1185
1186            while ( true ) {
1187                $node = $this->parseComment();
1188                if ( !$node ) {
1189                    break;
1190                }
1191                $root[] = $node;
1192            }
1193
1194            // always process comments before deciding if finished
1195            if ( $this->pos >= $this->input_len ) {
1196                break;
1197            }
1198
1199            if ( $this->peekChar( '}' ) ) {
1200                break;
1201            }
1202
1203            $node = $this->parseExtend( true );
1204            if ( $node ) {
1205                $root = array_merge( $root, $node );
1206                continue;
1207            }
1208
1209            $node = $this->parseMixinDefinition()
1210                // Optimisation: NameValue is specific to less.php
1211                /**
1212                 * TODO enabling $this->parseNameValue causes property-accessors to fail with
1213                 *
1214                 * 'error evaluating function `lighten` The first argument to lighten must be a
1215                 * color index: 146 in property-accessors.less on line 9,
1216                 *
1217                 * note: the Less_Tree_NameValue specifies that it may break color keyword
1218                 * interpretation
1219                 */
1220                // ?? $this->parseNameValue()
1221                ?? $this->parseDeclaration()
1222                ?? $this->parseRuleset()
1223                ?? $this->parseMixinCall( false, false )
1224                ?? $this->parseVariableCall()
1225                ?? $this->parseAtRule();
1226
1227            if ( $node ) {
1228                $root[] = $node;
1229            } elseif ( !$this->matchReg( '/\\G[\s\n;]+/' ) ) {
1230                break;
1231            }
1232
1233        }
1234
1235        return $root;
1236    }
1237
1238    /**
1239     * comments are collected by the main parsing mechanism and then assigned to nodes
1240     * where the current structure allows it
1241     *
1242     * @return Less_Tree_Comment|void
1243     * @see less-2.5.3.js#parsers.comment
1244     */
1245    private function parseComment() {
1246        $comment = array_shift( $this->commentStore );
1247        if ( $comment ) {
1248            return new Less_Tree_Comment(
1249                $comment['text'],
1250                $comment['isLineComment'],
1251                $comment['index'],
1252                $this->env->currentFileInfo
1253            );
1254        }
1255    }
1256
1257    /**
1258     * @see less-3.13.1.js#parsers.entities.mixinLookup
1259     */
1260    private function parseEntitiesMixinLookup() {
1261        return $this->parseMixinCall( true, true );
1262    }
1263
1264    /**
1265     * A string, which supports escaping " and '
1266     *
1267     *     "milky way" 'he\'s the one!'
1268     *
1269     * @return Less_Tree_Quoted|null
1270     * @see less-3.13.1.js#entities.quoted
1271     */
1272    private function parseEntitiesQuoted( $forceEscaped = false ) {
1273        // Optimization: Inline matchChar() here, with its skipWhitespace(1) call below
1274        $isEscaped = ( $this->input[ $this->pos ] ?? null ) === '~';
1275        $index = $this->pos;
1276        if ( $forceEscaped && !$isEscaped ) {
1277            return;
1278        }
1279        // Optimization: Move save() down to avoid save()+restore()
1280        // overhead during the early return above which is a hot code path.
1281        $this->save();
1282        if ( $isEscaped ) {
1283            $this->skipWhitespace( 1 );
1284        }
1285
1286        $str = $this->parseQuoted();
1287        if ( !$str ) {
1288            $this->restore();
1289            return;
1290        }
1291        $this->forget();
1292        return new Less_Tree_Quoted( $str[0], substr( $str, 1, -1 ), $isEscaped,
1293            $index, $this->env->currentFileInfo );
1294    }
1295
1296    /**
1297     * A catch-all word, such as:
1298     *
1299     *     black border-collapse
1300     *
1301     * @return Less_Tree_Keyword|Less_Tree_Color|null
1302     */
1303    private function parseEntitiesKeyword() {
1304        // $k = $this->matchReg('/\\G\\[?(?:[\\w-]|\\\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+\\]?/');
1305        $k = $this->matchReg( '/\\G%|\\G\\[?(?:[\\w-]|\\\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+\\]?/' );
1306        if ( $k ) {
1307            $color = Less_Tree_Color::fromKeyword( $k );
1308            if ( $color ) {
1309                return $color;
1310            }
1311            return new Less_Tree_Keyword( $k );
1312        }
1313    }
1314
1315    //
1316    // A function call
1317    //
1318    //     rgb(255, 0, 255)
1319    //
1320    // We also try to catch IE's `alpha()`, but let the `alpha` parser
1321    // deal with the details.
1322    //
1323    // The arguments are parsed with the `entities.arguments` parser.
1324    //
1325    // @see less-2.5.3.js#parsers.entities.call
1326    private function parseEntitiesCall() {
1327        $index = $this->pos;
1328
1329        if ( $this->peekReg( '/\\Gurl\(/i' ) ) {
1330            return;
1331        }
1332
1333        $this->save();
1334
1335        $name = $this->matchReg( '/\\G([\w-]+|%|progid:[\w\.]+)\(/' );
1336        if ( !$name ) {
1337            $this->forget();
1338            return;
1339        }
1340
1341        $name = $name[1];
1342        $nameLC = strtolower( $name );
1343
1344        if ( $nameLC === 'alpha' ) {
1345            $alpha_ret = $this->parseAlpha();
1346            if ( $alpha_ret ) {
1347                return $alpha_ret;
1348            }
1349        }
1350
1351        $args = $this->parseEntitiesArguments();
1352
1353        if ( !$this->matchChar( ')' ) ) {
1354            $this->restore();
1355            return;
1356        }
1357
1358        $this->forget();
1359        return new Less_Tree_Call( $name, $args, $index, $this->env->currentFileInfo );
1360    }
1361
1362    /**
1363     * Parse a list of arguments
1364     *
1365     * @return array<Less_Tree_Assignment|Less_Tree_Expression>
1366     */
1367    private function parseEntitiesArguments() {
1368        $args = [];
1369        while ( true ) {
1370            $arg = $this->parseEntitiesAssignment() ?? $this->parseExpression();
1371            if ( !$arg ) {
1372                break;
1373            }
1374
1375            $args[] = $arg;
1376            if ( !$this->matchChar( ',' ) ) {
1377                break;
1378            }
1379        }
1380        return $args;
1381    }
1382
1383    /** @return Less_Tree_Dimension|Less_Tree_Color|Less_Tree_Quoted|Less_Tree_UnicodeDescriptor|null */
1384    private function parseEntitiesLiteral() {
1385        return $this->parseEntitiesDimension() ?? $this->parseEntitiesColor() ?? $this->parseEntitiesQuoted() ?? $this->parseUnicodeDescriptor();
1386    }
1387
1388    /**
1389     * Assignments are argument entities for calls.
1390     *
1391     * They are present in IE filter properties as shown below.
1392     *
1393     *     filter: progid:DXImageTransform.Microsoft.Alpha( *opacity=50* )
1394     *
1395     * @return Less_Tree_Assignment|null
1396     * @see less-2.5.3.js#parsers.entities.assignment
1397     */
1398    private function parseEntitiesAssignment() {
1399        $key = $this->matchReg( '/\\G\w+(?=\s?=)/' );
1400        if ( !$key ) {
1401            return;
1402        }
1403
1404        if ( !$this->matchChar( '=' ) ) {
1405            return;
1406        }
1407
1408        $value = $this->parseEntity();
1409        if ( $value ) {
1410            return new Less_Tree_Assignment( $key, $value );
1411        }
1412    }
1413
1414    //
1415    // Parse url() tokens
1416    //
1417    // We use a specific rule for urls, because they don't really behave like
1418    // standard function calls. The difference is that the argument doesn't have
1419    // to be enclosed within a string, so it can't be parsed as an Expression.
1420    //
1421    private function parseEntitiesUrl() {
1422        $char = $this->input[$this->pos] ?? null;
1423
1424        $this->autoCommentAbsorb = false;
1425        // Optimisation: 'u' check is specific to less.php
1426        if ( $char !== 'u' || !$this->matchReg( '/\\Gurl\(/' ) ) {
1427            $this->autoCommentAbsorb = true;
1428            return;
1429        }
1430
1431        $value = $this->parseEntitiesQuoted()
1432            ?? $this->parseEntitiesVariable()
1433            ?? $this->parseEntitiesProperty()
1434            ?? $this->matchReg( '/\\Gdata\:.*?[^\)]+/' ) // TODO less doesn't handle this
1435            ?? $this->matchReg( '/\\G(?:(?:\\\\[\(\)\'"])|[^\(\)\'"])+/' )
1436            ?? null;
1437
1438        if ( !$value ) {
1439            $value = '';
1440        }
1441        $this->autoCommentAbsorb = true;
1442        $this->expectChar( ')' );
1443
1444        if ( $value instanceof Less_Tree_Quoted
1445            || $value instanceof Less_Tree_Variable
1446            || $value instanceof Less_Tree_Property ) {
1447            return new Less_Tree_Url( $value, $this->env->currentFileInfo );
1448        }
1449
1450        return new Less_Tree_Url( new Less_Tree_Anonymous( $value ), $this->env->currentFileInfo );
1451    }
1452
1453    /**
1454     * A Variable entity, such as `@fink`, in
1455     *
1456     *     width: @fink + 2px
1457     *
1458     * We use a different parser for variable definitions,
1459     * see `parsers.variable`.
1460     *
1461     * @return Less_Tree_Variable|Less_Tree_VariableCall|Less_Tree_NamespaceValue|null
1462     * @see less-3.13.1.js#parsers.entities.variable
1463     */
1464    private function parseEntitiesVariable() {
1465        $index = $this->pos;
1466        $this->save();
1467
1468        if ( $this->peekChar( '@' ) ) {
1469            $name = $this->matchReg( '/\\G@@?[\w-]+/' );
1470            if ( $name ) {
1471                $ch = $this->input[ $this->pos ] ?? '';
1472                $prevChar = $this->input[ $this->pos - 1 ] ?? '';
1473                if ( $ch === '(' || ( $ch === '[' && !preg_match( '/\s/', $prevChar, $match ) ) ) {
1474                    // this may be a VariableCall lookup
1475                    $result = $this->parseVariableCall( $name );
1476                    if ( $result ) {
1477                        $this->forget();
1478                        return $result;
1479                    }
1480                }
1481                $this->forget();
1482                return new Less_Tree_Variable( $name, $index, $this->env->currentFileInfo );
1483            }
1484        }
1485
1486        $this->restore();
1487    }
1488
1489    /**
1490     * A variable entity using the protective `{}` e.g. `@{var}`.
1491     *
1492     * @return Less_Tree_Variable|null
1493     */
1494    private function parseEntitiesVariableCurly() {
1495        $index = $this->pos;
1496
1497        if ( $this->input_len > ( $this->pos + 1 ) && $this->input[$this->pos] === '@' ) {
1498            $curly = $this->matchReg( '/\\G@\{([\w-]+)\}/' );
1499            if ( $curly ) {
1500                return new Less_Tree_Variable( '@' . $curly[1], $index, $this->env->currentFileInfo );
1501            }
1502        }
1503    }
1504
1505    /**
1506     * A Property accessor, such as `$color`, in
1507     *
1508     *   background-color: $color
1509     */
1510    private function parseEntitiesProperty() {
1511        $index = $this->pos;
1512
1513        if ( ( $this->input[$this->pos] ?? '' ) === '$' ) {
1514            $name = $this->matchReg( '/\\G\$[\w-]+/' );
1515            if ( $name ) {
1516                return new Less_Tree_Property( $name, $index, $this->env->currentFileInfo );
1517            }
1518        }
1519    }
1520
1521    // A property entity useing the protective {} e.g. @{prop}
1522    private function parseEntitiesPropertyCurly() {
1523        $index = $this->pos;
1524
1525        if ( $this->input[$this->pos] === '$' ) {
1526            $curly = $this->matchReg( '/\\G@\{([\w-]+)\}/' );
1527            if ( $curly ) {
1528                return new Less_Tree_Property( "$" . $curly[1], $index, $this->env->currentFileInfo );
1529            }
1530        }
1531    }
1532
1533    /**
1534     * A Hexadecimal color
1535     *
1536     *     #4F3C2F
1537     *
1538     * `rgb` and `hsl` colors are parsed through the `entities.call` parser.
1539     *
1540     * @return Less_Tree_Color|null
1541     */
1542    private function parseEntitiesColor() {
1543        if ( $this->peekChar( '#' ) ) {
1544            $rgb = $this->matchReg( '/\\G#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})/' );
1545            if ( $rgb ) {
1546                return new Less_Tree_Color( $rgb[1], 1, null, $rgb[0] );
1547            }
1548        }
1549    }
1550
1551    /**
1552     * A Dimension, that is, a number and a unit
1553     *
1554     *     0.5em 95%
1555     *
1556     * @return Less_Tree_Dimension|null
1557     */
1558    private function parseEntitiesDimension() {
1559        $c = @ord( $this->input[$this->pos] ?? '' );
1560
1561        // Is the first char of the dimension 0-9, '.', '+' or '-'
1562        if ( ( $c > 57 || $c < 43 ) || $c === 47 || $c == 44 ) {
1563            return;
1564        }
1565
1566        $value = $this->matchReg( '/\\G([+-]?\d*\.?\d+)(%|[a-z]+)?/i' );
1567        if ( $value ) {
1568            if ( isset( $value[2] ) ) {
1569                return new Less_Tree_Dimension( $value[1], $value[2] );
1570            }
1571            return new Less_Tree_Dimension( $value[1] );
1572        }
1573    }
1574
1575    /**
1576     * A unicode descriptor, as is used in unicode-range
1577     *
1578     * U+0?? or U+00A1-00A9
1579     *
1580     * @return Less_Tree_UnicodeDescriptor|null
1581     */
1582    private function parseUnicodeDescriptor() {
1583        // Optimization: Hardcode first char, to avoid matchReg() cost for common case
1584        $char = $this->input[$this->pos] ?? null;
1585        if ( $char !== 'U' ) {
1586            return;
1587        }
1588
1589        $ud = $this->matchReg( '/\\G(U\+[0-9a-fA-F?]+)(\-[0-9a-fA-F?]+)?/' );
1590        if ( $ud ) {
1591            return new Less_Tree_UnicodeDescriptor( $ud[0] );
1592        }
1593    }
1594
1595    /**
1596     * JavaScript code to be evaluated
1597     *
1598     *     `window.location.href`
1599     *
1600     * @return Less_Tree_JavaScript|null
1601     * @see less-3.13.1.js#parsers.entities.javascript
1602     */
1603    private function parseEntitiesJavascript() {
1604        // Optimization: Hardcode first char, to avoid save()/restore() overhead
1605        // Optimization: Inline matchChar(), with skipWhitespace(1) below
1606        $char = $this->input[$this->pos] ?? null;
1607        $isEscaped = $char === '~';
1608        if ( !$isEscaped && $char !== '`' ) {
1609            return;
1610        }
1611
1612        $index = $this->pos;
1613        $this->save();
1614
1615        if ( $isEscaped ) {
1616            $this->skipWhitespace( 1 );
1617            $char = $this->input[$this->pos] ?? null;
1618            if ( $char !== '`' ) {
1619                $this->restore();
1620                return;
1621            }
1622        }
1623
1624        $this->skipWhitespace( 1 );
1625        $js = $this->matchReg( '/\\G[^`]*`/' );
1626        if ( $js ) {
1627            $this->forget();
1628            return new Less_Tree_JavaScript( substr( $js, 0, -1 ), $isEscaped, $index );
1629        }
1630        $this->restore();
1631    }
1632
1633    // The variable part of a variable definition. Used in the `rule` parser
1634    //
1635    // @fink:
1636    //
1637    // @see less-3.13.1.js#parsers.variable
1638    private function parseVariable() {
1639        if ( $this->peekChar( '@' ) ) {
1640            $name = $this->matchReg( '/\\G(@[\w-]+)\s*:/' );
1641            if ( $name ) {
1642                return $name[1];
1643            }
1644        }
1645    }
1646
1647    // Call a variable value to retrieve a detached ruleset
1648    // or a value from a detached ruleset's rules.
1649    //
1650    //     @fink();
1651    //     @fink;
1652    //     color: @fink[@color];
1653    //
1654    // @see less-3.13.1.js#parsers.variableCall
1655    private function parseVariableCall( $parsedName = null ) {
1656        $i = $this->pos;
1657        $inValue = (bool)$parsedName;
1658
1659        if ( $parsedName === null && !$this->peekChar( '@' ) ) {
1660            return;
1661        }
1662        $this->save();
1663        $name = $parsedName ?? $this->matchReg( '/\\G(@[\w-]+)(\(\s*\))?/' );
1664        if ( $name === null ) {
1665            $this->restore();
1666            return;
1667        }
1668
1669        $lookups = $this->parseMixinRuleLookups();
1670        if ( !$lookups && (
1671            ( $inValue && $this->matchStr( '()' ) !== '()' ) || ( ( $name[2] ?? '' ) !== '()' ) ) ) {
1672            // Restore error mesage: 'Missing \'[...]\' lookup in variable call'
1673            $this->restore();
1674            return;
1675        }
1676        if ( !$inValue ) {
1677            $name = $name[1];
1678        }
1679
1680        $call = new Less_Tree_VariableCall( $name, $i, $this->env->currentFileInfo );
1681        if ( !$inValue && $this->parseEnd() ) {
1682            $this->forget();
1683            return $call;
1684        } else {
1685            $this->forget();
1686            return new Less_Tree_NamespaceValue( $call, $lookups, $i, $this->env->currentFileInfo );
1687        }
1688    }
1689
1690    //
1691    // extend syntax - used to extend selectors
1692    //
1693    // @see less-2.5.3.js#parsers.extend
1694    private function parseExtend( $isRule = false ) {
1695        $index = $this->pos;
1696        $extendList = [];
1697
1698        if ( !$this->matchStr( $isRule ? '&:extend(' : ':extend(' ) ) {
1699            return;
1700        }
1701
1702        do {
1703            $option = null;
1704            $elements = [];
1705            while ( true ) {
1706                $option = $this->matchReg( '/\\G(all)(?=\s*(\)|,))/' );
1707                if ( $option ) {
1708                    break;
1709                }
1710                $e = $this->parseElement();
1711                if ( !$e ) {
1712                    break;
1713                }
1714                $elements[] = $e;
1715            }
1716
1717            if ( $option ) {
1718                $option = $option[1];
1719            }
1720
1721            $extendList[] = new Less_Tree_Extend( new Less_Tree_Selector( $elements ), $option, $index );
1722
1723        } while ( $this->matchChar( "," ) );
1724
1725        $this->expect( '/\\G\)/' );
1726
1727        if ( $isRule ) {
1728            $this->expect( '/\\G;/' );
1729        }
1730
1731        return $extendList;
1732    }
1733
1734    //
1735    // A Mixin call, with an optional argument list
1736    //
1737    //     #mixins > .square(#fff);
1738    //     #mixins.square(#fff);
1739    //     .rounded(4px, black);
1740    //     .button;
1741    //
1742    // We can lookup / return a value using the lookup syntax:
1743    //
1744    //     color: #mixin.square(#fff)[@color];
1745    //
1746    // The `while` loop is there because mixins can be
1747    // namespaced, but we only support the child and descendant
1748    // selector for now.
1749    //
1750    // @see less-3.13.1.js#parsers.mixin.call
1751    //
1752    private function parseMixinCall( $inValue, $getLookup = null ) {
1753        $s = $this->input[$this->pos] ?? null;
1754        $important = false;
1755        $lookups = null;
1756        $index = $this->pos;
1757        $args = [];
1758        $hasParens = false;
1759        if ( $s !== '.' && $s !== '#' ) {
1760            return;
1761        }
1762
1763        $this->save(); // stop us absorbing part of an invalid selector
1764        $elements = $this->parseMixinCallElements();
1765
1766        if ( $elements ) {
1767            if ( $this->matchChar( '(' ) ) {
1768                $args = ( $this->parseMixinArgs( true ) )['args'];
1769                $this->expectChar( ')' );
1770                $hasParens = true;
1771            }
1772            if ( $getLookup !== false ) {
1773                $lookups = $this->parseMixinRuleLookups();
1774            }
1775            if ( $getLookup === true && $lookups === null ) {
1776                $this->restore();
1777                return;
1778            }
1779            if ( $inValue && !$lookups && !$hasParens ) {
1780                // This isn't a valid in-value mixin call
1781                $this->restore();
1782                return;
1783            }
1784
1785            if ( !$inValue && $this->parseImportant() ) {
1786                $important = true;
1787            }
1788
1789            if ( $inValue || $this->parseEnd() ) {
1790                $this->forget();
1791                $mixin = new Less_Tree_Mixin_Call( $elements, $args, $index,
1792                    $this->env->currentFileInfo, !$lookups && $important );
1793                if ( $lookups ) {
1794                    return new Less_Tree_NamespaceValue( $mixin, $lookups );
1795                } else {
1796                    return $mixin;
1797                }
1798            }
1799        }
1800
1801        $this->restore();
1802    }
1803
1804    /**
1805     * Matching elements for mixins
1806     * (Start with . or # and can have > )
1807     * @see less-3.13.1.js#parsers.mixin.elements
1808     */
1809    private function parseMixinCallElements() {
1810        $elements = [];
1811        $c = null;
1812
1813        while ( true ) {
1814            $elemIndex = $this->pos;
1815            $e = $this->matchReg( '/\\G[#.](?:[\w-]|\\\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/' );
1816            if ( !$e ) {
1817                break;
1818            }
1819            $elements[] = new Less_Tree_Element( $c, $e, $elemIndex, $this->env->currentFileInfo );
1820            $c = $this->matchChar( '>' );
1821        }
1822
1823        return $elements ?: null;
1824    }
1825
1826    /**
1827     * @param bool $isCall
1828     * @see less-2.5.3.js#parsers.mixin.args
1829     */
1830    private function parseMixinArgs( $isCall ) {
1831        $expressions = [];
1832        $argsSemiColon = [];
1833        $isSemiColonSeperated = null;
1834        $argsComma = [];
1835        $expressionContainsNamed = null;
1836        $name = null;
1837        $returner = [ 'args' => [], 'variadic' => false ];
1838        $expand = false;
1839
1840        $this->save();
1841
1842        while ( true ) {
1843            if ( $isCall ) {
1844                $arg = $this->parseDetachedRuleset() ?? $this->parseExpression();
1845            } else {
1846                $this->commentStore = [];
1847                if ( $this->input[ $this->pos ] === '.' && $this->matchStr( '...' ) ) {
1848                    $returner['variadic'] = true;
1849                    if ( $this->matchChar( ";" ) && !$isSemiColonSeperated ) {
1850                        $isSemiColonSeperated = true;
1851                    }
1852
1853                    if ( $isSemiColonSeperated ) {
1854                        $argsSemiColon[] = [ 'variadic' => true ];
1855                    } else {
1856                        $argsComma[] = [ 'variadic' => true ];
1857                    }
1858                    break;
1859                }
1860                $arg = $this->parseEntitiesVariable()
1861                    ?? $this->parseEntitiesProperty()
1862                    ?? $this->parseEntitiesLiteral()
1863                    ?? $this->parseEntitiesKeyword();
1864            }
1865
1866            if ( !$arg ) {
1867                break;
1868            }
1869
1870            $nameLoop = null;
1871            if ( $arg instanceof Less_Tree_Expression ) {
1872                $arg->throwAwayComments();
1873            }
1874            $value = $arg;
1875            $val = null;
1876
1877            if ( $isCall ) {
1878                // Variable
1879                if ( $value instanceof Less_Tree_Expression && count( $arg->value ) == 1 ) {
1880                    $val = $arg->value[0];
1881                }
1882            } else {
1883                $val = $arg;
1884            }
1885
1886            if ( $val instanceof Less_Tree_Variable || $val instanceof Less_Tree_Property ) {
1887
1888                if ( $this->matchChar( ':' ) ) {
1889                    if ( $expressions ) {
1890                        if ( $isSemiColonSeperated ) {
1891                            $this->Error( 'Cannot mix ; and , as delimiter types' );
1892                        }
1893                        $expressionContainsNamed = true;
1894                    }
1895
1896                    // we do not support setting a ruleset as a default variable - it doesn't make sense
1897                    // However if we do want to add it, there is nothing blocking it, just don't error
1898                    // and remove isCall dependency below
1899                    $value = $this->parseDetachedRuleset() ?? $this->parseExpression();
1900
1901                    if ( !$value ) {
1902                        if ( $isCall ) {
1903                            $this->Error( 'could not understand value for named argument' );
1904                        } else {
1905                            $this->restore();
1906                            $returner['args'] = [];
1907                            return $returner;
1908                        }
1909                    }
1910
1911                    $nameLoop = ( $name = $val->name );
1912                } elseif ( $this->matchStr( '...' ) ) {
1913                    if ( !$isCall ) {
1914                        $returner['variadic'] = true;
1915                        if ( $this->matchChar( ";" ) && !$isSemiColonSeperated ) {
1916                            $isSemiColonSeperated = true;
1917                        }
1918                        if ( $isSemiColonSeperated ) {
1919                            $argsSemiColon[] = [ 'name' => $arg->name, 'variadic' => true ];
1920                        } else {
1921                            $argsComma[] = [ 'name' => $arg->name, 'variadic' => true ];
1922                        }
1923                        break;
1924                    } else {
1925                        $expand = true;
1926                    }
1927                } elseif ( !$isCall ) {
1928                    $name = $nameLoop = $val->name;
1929                    $value = null;
1930                }
1931            }
1932
1933            if ( $value ) {
1934                $expressions[] = $value;
1935            }
1936
1937            $argsComma[] = [ 'name' => $nameLoop, 'value' => $value, 'expand' => $expand ];
1938
1939            if ( $this->matchChar( ',' ) ) {
1940                continue;
1941            }
1942
1943            if ( $this->matchChar( ';' ) || $isSemiColonSeperated ) {
1944
1945                if ( $expressionContainsNamed ) {
1946                    $this->Error( 'Cannot mix ; and , as delimiter types' );
1947                }
1948
1949                $isSemiColonSeperated = true;
1950
1951                if ( count( $expressions ) > 1 ) {
1952                    $value = new Less_Tree_Value( $expressions );
1953                }
1954                $argsSemiColon[] = [ 'name' => $name, 'value' => $value, 'expand' => $expand ];
1955
1956                $name = null;
1957                $expressions = [];
1958                $expressionContainsNamed = false;
1959            }
1960        }
1961
1962        $this->forget();
1963        $returner['args'] = ( $isSemiColonSeperated ? $argsSemiColon : $argsComma );
1964        return $returner;
1965    }
1966
1967    /**
1968     * @see less-3.13.1.js#parsers.mixin.ruleLookups
1969     */
1970    private function parseMixinRuleLookups() {
1971        $lookups = [];
1972
1973        if ( !$this->peekChar( '[' ) ) {
1974            return;
1975        }
1976
1977        while ( true ) {
1978            $this->save();
1979            $rule = $this->parseLookupValue();
1980            if ( !$rule && $rule !== '' ) {
1981                $this->restore();
1982                break;
1983            }
1984            $lookups[] = $rule;
1985            $this->forget();
1986        }
1987        if ( $lookups ) {
1988            return $lookups;
1989        }
1990    }
1991
1992    /**
1993     * @see less-3.13.1.js#parsers.mixin.lookupValue
1994     */
1995    private function parseLookupValue() {
1996        $this->save();
1997
1998        if ( !$this->matchChar( '[' ) ) {
1999            $this->restore();
2000            return;
2001        }
2002        $name = $this->matchReg( "/\\G(?:[@\$]{0,2})[_a-zA-Z0-9-]*/" );
2003
2004        if ( !$this->matchChar( ']' ) ) {
2005            $this->restore();
2006            return;
2007        }
2008        if ( $name || $name === '' ) {
2009            $this->forget();
2010            return $name;
2011        }
2012        $this->restore();
2013    }
2014
2015    //
2016    // A Mixin definition, with a list of parameters
2017    //
2018    //     .rounded (@radius: 2px, @color) {
2019    //        ...
2020    //     }
2021    //
2022    // Until we have a finer grained state-machine, we have to
2023    // do a look-ahead, to make sure we don't have a mixin call.
2024    // See the `rule` function for more information.
2025    //
2026    // We start by matching `.rounded (`, and then proceed on to
2027    // the argument list, which has optional default values.
2028    // We store the parameters in `params`, with a `value` key,
2029    // if there is a value, such as in the case of `@radius`.
2030    //
2031    // Once we've got our params list, and a closing `)`, we parse
2032    // the `{...}` block.
2033    //
2034    // @see less-2.5.3.js#parsers.mixin.definition
2035    private function parseMixinDefinition() {
2036        $cond = null;
2037
2038        $char = $this->input[$this->pos] ?? null;
2039        // TODO: Less.js doesn't limit this to $char == '{'.
2040        if ( ( $char !== '.' && $char !== '#' ) || ( $char === '{' && $this->peekReg( '/\\G[^{]*\}/' ) ) ) {
2041            return;
2042        }
2043
2044        $this->save();
2045
2046        $match = $this->matchReg( '/\\G([#.](?:[\w-]|\\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/' );
2047        if ( $match ) {
2048            $name = $match[1];
2049
2050            $argInfo = $this->parseMixinArgs( false );
2051            $params = $argInfo['args'];
2052            $variadic = $argInfo['variadic'];
2053
2054            // .mixincall("@{a}");
2055            // looks a bit like a mixin definition..
2056            // also
2057            // .mixincall(@a: {rule: set;});
2058            // so we have to be nice and restore
2059            if ( !$this->matchChar( ')' ) ) {
2060                $this->restore();
2061                return;
2062            }
2063
2064            $this->commentStore = [];
2065
2066            if ( $this->matchStr( 'when' ) ) { // Guard
2067                $cond = $this->expect( 'parseConditions', 'Expected conditions' );
2068            }
2069
2070            $ruleset = $this->parseBlock();
2071
2072            if ( $ruleset !== null ) {
2073                $this->forget();
2074                return new Less_Tree_Mixin_Definition( $name, $params, $ruleset, $cond, $variadic );
2075            }
2076
2077            $this->restore();
2078        } else {
2079            $this->forget();
2080        }
2081    }
2082
2083    //
2084    // Entities are the smallest recognized token,
2085    // and can be found inside a rule's value.
2086    //
2087    private function parseEntity() {
2088        return $this->parseComment() ??
2089            $this->parseEntitiesLiteral() ??
2090            $this->parseEntitiesVariable() ??
2091            $this->parseEntitiesUrl() ??
2092            $this->parseEntitiesProperty() ??
2093            $this->parseEntitiesCall() ??
2094            $this->parseEntitiesKeyword() ??
2095            $this->parseMixinCall( true ) ??
2096            $this->parseEntitiesJavascript();
2097    }
2098
2099    //
2100    // A Declaration terminator. Note that we use `peek()` to check for '}',
2101    // because the `block` rule will be expecting it, but we still need to make sure
2102    // it's there, if ';' was omitted.
2103    //
2104    private function parseEnd() {
2105        return $this->matchChar( ';' ) || $this->peekChar( '}' );
2106    }
2107
2108    //
2109    // IE's alpha function
2110    //
2111    //     alpha(opacity=88)
2112    //
2113    // @see less-2.5.3.js#parsers.alpha
2114    private function parseAlpha() {
2115        if ( !$this->matchReg( '/\\Gopacity=/i' ) ) {
2116            return;
2117        }
2118
2119        $value = $this->matchReg( '/\\G[0-9]+/' );
2120        if ( $value === null ) {
2121            $value = $this->expect( 'parseEntitiesVariable', 'Could not parse alpha' );
2122        }
2123
2124        $this->expectChar( ')' );
2125        return new Less_Tree_Alpha( $value );
2126    }
2127
2128    /**
2129     * A Selector Element
2130     *
2131     *     div
2132     *     + h1
2133     *     #socks
2134     *     input[type="text"]
2135     *
2136     * Elements are the building blocks for Selectors,
2137     * they are made out of a `Combinator` (see combinator rule),
2138     * and an element name, such as a tag a class, or `*`.
2139     *
2140     * @return Less_Tree_Element|null
2141     * @see less-2.5.3.js#parsers.element
2142     */
2143    private function parseElement() {
2144        $c = $this->parseCombinator();
2145        $index = $this->pos;
2146
2147        $e = $this->matchReg( '/\\G(?:\d+\.\d+|\d+)%/' )
2148            ?? $this->matchReg( '/\\G(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/' )
2149            ?? $this->matchChar( '*' )
2150            ?? $this->matchChar( '&' )
2151            ?? $this->parseAttribute()
2152            ?? $this->matchReg( '/\\G\([^&()@]+\)/' )
2153            ?? $this->matchReg( '/\\G[\.#:](?=@)/' )
2154            ?? $this->parseEntitiesVariableCurly();
2155
2156        if ( $e === null ) {
2157            $this->save();
2158            if ( $this->matchChar( '(' ) ) {
2159                $v = $this->parseSelector();
2160                if ( $v && $this->matchChar( ')' ) ) {
2161                    $e = new Less_Tree_Paren( $v );
2162                    $this->forget();
2163                } else {
2164                    $this->restore();
2165                }
2166            } else {
2167                $this->forget();
2168            }
2169        }
2170
2171        if ( $e !== null ) {
2172            return new Less_Tree_Element( $c, $e, $index, $this->env->currentFileInfo );
2173        }
2174    }
2175
2176    //
2177    // Combinators combine elements together, in a Selector.
2178    //
2179    // Because our parser isn't white-space sensitive, special care
2180    // has to be taken, when parsing the descendant combinator, ` `,
2181    // as it's an empty space. We have to check the previous character
2182    // in the input, to see if it's a ` ` character.
2183    //
2184    // @see less-2.5.3.js#parsers.combinator
2185    private function parseCombinator() {
2186        if ( $this->pos < $this->input_len ) {
2187            $c = $this->input[$this->pos];
2188            if ( $c === '/' ) {
2189                $this->save();
2190                $slashedCombinator = $this->matchReg( '/\\G\/[a-z]+\//i' );
2191                if ( $slashedCombinator ) {
2192                    $this->forget();
2193                    return $slashedCombinator;
2194                }
2195                $this->restore();
2196            }
2197
2198            // TODO: Figure out why less.js also handles '/' here, and implement with regression test.
2199            if ( $c === '>' || $c === '+' || $c === '~' || $c === '|' || $c === '^' ) {
2200
2201                $this->pos++;
2202                if ( $c === '^' && $this->input[$this->pos] === '^' ) {
2203                    $c = '^^';
2204                    $this->pos++;
2205                }
2206
2207                $this->skipWhitespace( 0 );
2208
2209                return $c;
2210            }
2211
2212            if ( $this->pos > 0 && $this->isWhitespace( -1 ) ) {
2213                return ' ';
2214            }
2215        }
2216    }
2217
2218    /**
2219     * A CSS selector (see selector below)
2220     * with less extensions e.g. the ability to extend and guard
2221     *
2222     * @return Less_Tree_Selector|null
2223     * @see less-2.5.3.js#parsers.lessSelector
2224     */
2225    private function parseLessSelector() {
2226        return $this->parseSelector( true );
2227    }
2228
2229    /**
2230     * A CSS Selector
2231     *
2232     *     .class > div + h1
2233     *     li a:hover
2234     *
2235     * Selectors are made out of one or more Elements, see ::parseElement.
2236     *
2237     * @return Less_Tree_Selector|null
2238     * @see less-2.5.3.js#parsers.selector
2239     */
2240    private function parseSelector( $isLess = false ) {
2241        $elements = [];
2242        $extendList = [];
2243        $condition = null;
2244        $when = false;
2245        $extend = false;
2246        $e = null;
2247        $c = null;
2248        $index = $this->pos;
2249
2250        // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition
2251        while ( ( $isLess && ( $extend = $this->parseExtend() ) ) || ( $isLess && ( $when = $this->matchStr( 'when' ) ) ) || ( $e = $this->parseElement() ) ) {
2252            if ( $when ) {
2253                $condition = $this->expect( 'parseConditions', 'expected condition' );
2254            } elseif ( $condition ) {
2255                // error("CSS guard can only be used at the end of selector");
2256            } elseif ( $extend ) {
2257                $extendList = array_merge( $extendList, $extend );
2258            } else {
2259                // if( count($extendList) ){
2260                //error("Extend can only be used at the end of selector");
2261                //}
2262                if ( $this->pos < $this->input_len ) {
2263                    $c = $this->input[ $this->pos ];
2264                }
2265                $elements[] = $e;
2266                $e = null;
2267            }
2268
2269            if ( $c === '{' || $c === '}' || $c === ';' || $c === ',' || $c === ')' ) {
2270                break;
2271            }
2272        }
2273
2274        if ( $elements ) {
2275            return new Less_Tree_Selector( $elements, $extendList, $condition, $index, $this->env->currentFileInfo );
2276        }
2277        if ( $extendList ) {
2278            $this->Error( 'Extend must be used to extend a selector, it cannot be used on its own' );
2279        }
2280    }
2281
2282    /**
2283     * @return Less_Tree_Attribute|null
2284     * @see less-2.5.3.js#parsers.attribute
2285     */
2286    private function parseAttribute() {
2287        $val = null;
2288
2289        if ( !$this->matchChar( '[' ) ) {
2290            return;
2291        }
2292
2293        $key = $this->parseEntitiesVariableCurly();
2294        if ( !$key ) {
2295            $key = $this->expect( '/\\G(?:[_A-Za-z0-9-\*]*\|)?(?:[_A-Za-z0-9-]|\\\\.)+/' );
2296        }
2297
2298        $op = $this->matchReg( '/\\G[|~*$^]?=/' );
2299        if ( $op ) {
2300            $val = $this->parseEntitiesQuoted() ?? $this->matchReg( '/\\G[0-9]+%/' ) ?? $this->matchReg( '/\\G[\w-]+/' ) ?? $this->parseEntitiesVariableCurly();
2301        }
2302
2303        $this->expectChar( ']' );
2304
2305        return new Less_Tree_Attribute( $key, $op, $val );
2306    }
2307
2308    /**
2309     * The `block` rule is used by `ruleset` and `mixin.definition`.
2310     * It's a wrapper around the `primary` rule, with added `{}`.
2311     *
2312     * @return array<Less_Tree>|null
2313     * @see less-2.5.3.js#parsers.block
2314     */
2315    private function parseBlock() {
2316        if ( $this->matchChar( '{' ) ) {
2317            $content = $this->parsePrimary();
2318            if ( $this->matchChar( '}' ) ) {
2319                return $content;
2320            }
2321        }
2322    }
2323
2324    private function parseBlockRuleset() {
2325        $block = $this->parseBlock();
2326        if ( $block !== null ) {
2327            return new Less_Tree_Ruleset( null, $block );
2328        }
2329    }
2330
2331    /** @return Less_Tree_DetachedRuleset|null */
2332    private function parseDetachedRuleset() {
2333        $blockRuleset = $this->parseBlockRuleset();
2334        if ( $blockRuleset ) {
2335            return new Less_Tree_DetachedRuleset( $blockRuleset );
2336        }
2337    }
2338
2339    /**
2340     * Ruleset such as:
2341     *
2342     *     div, .class, body > p {
2343     *     }
2344     *
2345     * @return Less_Tree_Ruleset|null
2346     * @see less-2.5.3.js#parsers.ruleset
2347     */
2348    private function parseRuleset() {
2349        $selectors = [];
2350
2351        $this->save();
2352        // TODO: missing https://github.com/less/less.js/commit/b8140d4baad18ba732e2b322d8891a9b0ff065d5#diff-cad419f131cbecb0799ee17eba9319d3ff51de09eb3876efb9e4c068c1f6025f
2353        // the commit above updated the `permissive-parse.less` fixture worked on Id36e0f142d7f430603da3f0d6825aa6a0bc9b7f1
2354        // and it required to add an override for permisive-parse.css.
2355        // When working on parse interpolation, please make sure to remove the permissive-parse
2356        // override
2357        while ( true ) {
2358            $s = $this->parseLessSelector();
2359            if ( !$s ) {
2360                break;
2361            }
2362            $selectors[] = $s;
2363            $this->commentStore = [];
2364
2365            if ( $s->condition && count( $selectors ) > 1 ) {
2366                $this->Error( 'Guards are only currently allowed on a single selector.' );
2367            }
2368
2369            if ( !$this->matchChar( ',' ) ) {
2370                break;
2371            }
2372            if ( $s->condition ) {
2373                $this->Error( 'Guards are only currently allowed on a single selector.' );
2374            }
2375            $this->commentStore = [];
2376        }
2377
2378        if ( $selectors ) {
2379            $rules = $this->parseBlock();
2380            if ( is_array( $rules ) ) {
2381                $this->forget();
2382                // TODO: Less_Environment::$strictImports is not yet ported
2383                // It is passed here by less.js
2384                return new Less_Tree_Ruleset( $selectors, $rules );
2385            }
2386        }
2387
2388        // Backtrack
2389        $this->restore();
2390    }
2391
2392    /**
2393     * Custom less.php parse function for finding simple name-value css pairs
2394     * ex: width:100px;
2395     */
2396    private function parseNameValue() {
2397        $index = $this->pos;
2398        $this->save();
2399
2400        $match = $this->matchReg( '/\\G([a-zA-Z\-]+)\s*:\s*([\'"]?[#a-zA-Z0-9\-%\.,]+?[\'"]?\s*) *(! *important)?\s*([;}])/' );
2401        if ( $match ) {
2402
2403            if ( $match[4] == '}' ) {
2404                // because we will parse all comments after closing }, we need to reset the store as
2405                // we're going to reset the position to closing }
2406                $this->commentStore = [];
2407                $this->pos = $index + strlen( $match[0] ) - 1;
2408                $match[2] = rtrim( $match[2] );
2409            }
2410
2411            if ( $match[3] ) {
2412                $match[2] .= $match[3];
2413            }
2414            $this->forget();
2415            return new Less_Tree_NameValue( $match[1], $match[2], $index, $this->env->currentFileInfo );
2416        }
2417
2418        $this->restore();
2419    }
2420
2421    // @see less-3.13.1.js#parsers.declaration
2422    private function parseDeclaration() {
2423        $value = null;
2424        $index = $this->pos;
2425        $hasDR = false;
2426        $c = $this->input[$this->pos] ?? null;
2427        $important = null;
2428        $merge = false;
2429        // TODO: Figure out why less.js also handles ':' here, and implement with regression test.
2430        if ( $c === '.' || $c === '#' || $c === '&' ) {
2431            return;
2432        }
2433
2434        $this->save();
2435        $name = $this->parseVariable() ?? $this->parseRuleProperty();
2436
2437        if ( $name ) {
2438            $isVariable = is_string( $name );
2439
2440            if ( $isVariable ) {
2441                $value = $this->parseDetachedRuleset();
2442                if ( $value ) {
2443                    $hasDR = true;
2444                }
2445            }
2446            $this->commentStore = [];
2447            if ( !$value ) {
2448                // a name returned by this.ruleProperty() is always an array of the form:
2449                // [string-1, ..., string-n, ""] or [string-1, ..., string-n, "+"]
2450                // where each item is a tree.Keyword or tree.Variable
2451                if ( !$isVariable && is_array( $name ) && count( $name ) > 1 ) {
2452                    $merge = array_pop( $name )->value;
2453                }
2454                // Custom property values get permissive parsing
2455                if ( is_array( $name ) && array_key_exists( 0, $name ) // to satisfy phan
2456                    && $name[0] instanceof Less_Tree_Keyword
2457                    && $name[0]->value && strpos( $name[0]->value, '--' ) === 0 ) {
2458                    $value = $this->parsePermissiveValue();
2459                } else {
2460                    // Try to store values as anonymous
2461                    // If we need the value later we'll re-parse it in ruleset.parseValue
2462                    $value = $this->parseAnonymousValue();
2463                }
2464
2465                if ( $value ) {
2466                    $this->forget();
2467                    // anonymous values absorb the end ';' which is required for them to work
2468                    return new Less_Tree_Declaration( $name, $value, false, $merge, $index,
2469                        $this->env->currentFileInfo );
2470                }
2471                if ( !$value ) {
2472                    $value = $this->parseValue();
2473                }
2474                if ( $value ) {
2475                    $important = $this->parseImportant();
2476                } elseif ( $isVariable ) {
2477                    $value = $this->parsePermissiveValue();
2478                }
2479            }
2480            if ( $value && ( $this->parseEnd() || $hasDR ) ) {
2481                $this->forget();
2482                return new Less_Tree_Declaration( $name, $value, $important, $merge, $index, $this->env->currentFileInfo );
2483            } else {
2484                $this->restore();
2485            }
2486        } else {
2487            $this->restore();
2488        }
2489    }
2490
2491    /**
2492     * @see less-3.13.1.js#parsers.anonymousValue
2493     */
2494    private function parseAnonymousValue() {
2495        $index = $this->pos;
2496        $match = $this->matchReg( '/\\G([^.#@\$+\/\'"*`(;{}-]*);/' );
2497        if ( $match ) {
2498            return new Less_Tree_Anonymous( $match[1], $index );
2499        }
2500    }
2501
2502    /**
2503     * Used for custom properties, at-rules, and variables (as fallback)
2504     * Parses almost anything inside of {} [] () "" blocks
2505     * until it reaches outer-most tokens.
2506     *
2507     * First, it will try to parse comments and entities to reach
2508     * the end. This is mostly like the Expression parser except no
2509     * math is allowed.
2510     *
2511     * @see less-3.13.1.js#parsers.permissiveValue
2512     * @param null|string|array $untilTokens
2513     */
2514    private function parsePermissiveValue( $untilTokens = null ) {
2515        $tok = $untilTokens ?? ';';
2516        $index = $this->pos;
2517        $result = [];
2518
2519        if ( is_array( $tok ) ) {
2520            $testCurrentChar = static function ( $currentChar ) use ( $tok ) {
2521                return in_array( $currentChar, $tok );
2522            };
2523        } else {
2524            $testCurrentChar = static function ( $currentChar ) use ( $tok ) {
2525                return $tok === $currentChar;
2526            };
2527        }
2528
2529        if ( $testCurrentChar( $this->input[$this->pos] ) ) {
2530            return;
2531        }
2532
2533        $value = [];
2534        do {
2535            $e = $this->parseComment();
2536            if ( $e ) {
2537                $value[] = $e;
2538                continue;
2539            }
2540            $e = $this->parseEntity();
2541            if ( $e ) {
2542                $value[] = $e;
2543            }
2544        } while ( $e );
2545        $done = $testCurrentChar( $this->input[$this->pos] );
2546        if ( $value ) {
2547            $value = new Less_Tree_Expression( $value );
2548            if ( $done ) {
2549                return $value;
2550            } else {
2551                $result[] = $value;
2552            }
2553            // Preserve space before $parseUntil as it will not
2554            if ( $this->input[$this->pos - 1] === ' ' ) {
2555                $result[] = new Less_Tree_Anonymous( ' ', $index );
2556            }
2557        }
2558        $this->save();
2559        $value = $this->parseUntil( $tok );
2560
2561        if ( $value ) {
2562            if ( is_string( $value ) ) {
2563                $this->Error( "expected '" . $value . "'" );
2564            }
2565            if ( count( $value ) === 1 && $value[0] === ' ' ) {
2566                $this->forget();
2567                return new Less_Tree_Anonymous( '', $index );
2568            }
2569            $valueLength = count( $value );
2570            for ( $i = 0; $i < $valueLength; $i++ ) {
2571                $item = $value[$i];
2572                if ( is_array( $item ) ) {
2573                    $result[] =    new Less_Tree_Quoted( $item[0], $item[1], true, $index,
2574                            $this->env->currentFileInfo );
2575                } else {
2576                    if ( $i === $valueLength - 1 ) {
2577                        $item = trim( $item );
2578                    }
2579                    // Treat like quoted values, but replace vars like unquoted expressions
2580                    $quote =
2581                        new Less_Tree_Quoted( '\'', $item, true, $index,
2582                            $this->env->currentFileInfo );
2583                    $quote->variableRegex = '/@([\w-]+)/';
2584                    $quote->propRegex = '/\$([\w-]+)/';
2585                    $result[] = $quote;
2586                }
2587            }
2588            $this->forget();
2589            return new Less_Tree_Expression( $result, true );
2590        }
2591        $this->restore();
2592    }
2593
2594    //
2595    // An @import atrule
2596    //
2597    //     @import "lib";
2598    //
2599    // Depending on our environment, importing is done differently:
2600    // In the browser, it's an XHR request, in Node, it would be a
2601    // file-system operation. The function used for importing is
2602    // stored in `import`, which we pass to the Import constructor.
2603    //
2604    private function parseImport() {
2605        $this->save();
2606
2607        $dir = $this->matchReg( '/\\G@import?\s+/' );
2608
2609        if ( $dir ) {
2610            $options = $this->parseImportOptions();
2611            $path = $this->parseEntitiesQuoted() ?? $this->parseEntitiesUrl();
2612
2613            if ( $path ) {
2614                $features = $this->parseMediaFeatures();
2615                if ( $this->matchChar( ';' ) ) {
2616                    if ( $features ) {
2617                        $features = new Less_Tree_Value( $features );
2618                    }
2619
2620                    $this->forget();
2621                    return new Less_Tree_Import( $path, $features, $options, $this->pos, $this->env->currentFileInfo );
2622                }
2623            }
2624        }
2625
2626        $this->restore();
2627    }
2628
2629    private function parseImportOptions() {
2630        $options = [];
2631
2632        // list of options, surrounded by parens
2633        if ( !$this->matchChar( '(' ) ) {
2634            return $options;
2635        }
2636        do {
2637            $optionName = $this->parseImportOption();
2638            if ( $optionName ) {
2639                $value = true;
2640                switch ( $optionName ) {
2641                    case "css":
2642                        $optionName = "less";
2643                        $value = false;
2644                        break;
2645                    case "once":
2646                        $optionName = "multiple";
2647                        $value = false;
2648                        break;
2649                }
2650                $options[$optionName] = $value;
2651                if ( !$this->matchChar( ',' ) ) {
2652                    break;
2653                }
2654            }
2655        } while ( $optionName );
2656        $this->expectChar( ')' );
2657        return $options;
2658    }
2659
2660    private function parseImportOption() {
2661        $opt = $this->matchReg( '/\\G(less|css|multiple|once|inline|reference|optional)/' );
2662        if ( $opt ) {
2663            return $opt[1];
2664        }
2665    }
2666
2667    private function parseMediaFeature() {
2668        $nodes = [];
2669
2670        do {
2671            $e = $this->parseEntitiesKeyword() ?? $this->parseEntitiesVariable();
2672            if ( $e ) {
2673                $nodes[] = $e;
2674            } elseif ( $this->matchChar( '(' ) ) {
2675                $p = $this->parseProperty();
2676                $e = $this->parseValue();
2677                if ( $this->matchChar( ')' ) ) {
2678                    if ( $p && $e ) {
2679                        $r = new Less_Tree_Declaration( $p, $e, null, null, $this->pos, $this->env->currentFileInfo, true );
2680                        $nodes[] = new Less_Tree_Paren( $r );
2681                    } elseif ( $e ) {
2682                        $nodes[] = new Less_Tree_Paren( $e );
2683                    } else {
2684                        return null;
2685                    }
2686                } else {
2687                    return null;
2688                }
2689            }
2690        } while ( $e );
2691
2692        if ( $nodes ) {
2693            return new Less_Tree_Expression( $nodes );
2694        }
2695    }
2696
2697    private function parseMediaFeatures() {
2698        $features = [];
2699
2700        do {
2701            $e = $this->parseMediaFeature();
2702            if ( $e ) {
2703                $features[] = $e;
2704                if ( !$this->matchChar( ',' ) ) {
2705                    break;
2706                }
2707            } else {
2708                $e = $this->parseEntitiesVariable();
2709                if ( $e ) {
2710                    $features[] = $e;
2711                    if ( !$this->matchChar( ',' ) ) {
2712                        break;
2713                    }
2714                }
2715            }
2716        } while ( $e );
2717
2718        return $features ?: null;
2719    }
2720
2721    /**
2722     * @see less-2.5.3.js#parsers.media
2723     */
2724    private function parseMedia() {
2725        if ( $this->matchStr( '@media' ) ) {
2726            $this->save();
2727
2728            $features = $this->parseMediaFeatures();
2729            $rules = $this->parseBlock();
2730
2731            if ( $rules === null ) {
2732                $this->restore();
2733                return;
2734            }
2735
2736            $this->forget();
2737            return new Less_Tree_Media( $rules, $features, $this->pos, $this->env->currentFileInfo );
2738        }
2739    }
2740
2741    /**
2742     * A CSS AtRule like `@charset "utf-8";`
2743     *
2744     * @return Less_Tree_Import|Less_Tree_Media|Less_Tree_AtRule|null
2745     * @see less-3.13.1.js#parsers.atrule
2746     * @todo check feature parity with 3.13.1
2747     */
2748    private function parseAtRule() {
2749        if ( !$this->peekChar( '@' ) ) {
2750            return;
2751        }
2752
2753        $rules = null;
2754        $index = $this->pos;
2755        $hasBlock = true;
2756        $hasIdentifier = false;
2757        $hasExpression = false;
2758        $hasUnknown = false;
2759        $isRooted = true;
2760
2761        $value = $this->parseImport() ?? $this->parseMedia();
2762        if ( $value ) {
2763            return $value;
2764        }
2765
2766        $this->save();
2767
2768        $name = $this->matchReg( '/\\G@[a-z-]+/' );
2769
2770        if ( !$name ) {
2771            return;
2772        }
2773
2774        $nonVendorSpecificName = $name;
2775        $pos = strpos( $name, '-', 2 );
2776        if ( $name[1] == '-' && $pos > 0 ) {
2777            $nonVendorSpecificName = "@" . substr( $name, $pos + 1 );
2778        }
2779
2780        switch ( $nonVendorSpecificName ) {
2781            /*
2782            case "@font-face":
2783            case "@viewport":
2784            case "@top-left":
2785            case "@top-left-corner":
2786            case "@top-center":
2787            case "@top-right":
2788            case "@top-right-corner":
2789            case "@bottom-left":
2790            case "@bottom-left-corner":
2791            case "@bottom-center":
2792            case "@bottom-right":
2793            case "@bottom-right-corner":
2794            case "@left-top":
2795            case "@left-middle":
2796            case "@left-bottom":
2797            case "@right-top":
2798            case "@right-middle":
2799            case "@right-bottom":
2800            hasBlock = true;
2801            isRooted = true;
2802            break;
2803            */
2804            case "@counter-style":
2805                $hasIdentifier = true;
2806                break;
2807            case "@charset":
2808                $hasIdentifier = true;
2809                $hasBlock = false;
2810                break;
2811            case "@namespace":
2812                $hasExpression = true;
2813                $hasBlock = false;
2814                break;
2815            case "@keyframes":
2816                $hasIdentifier = true;
2817                break;
2818            case "@host":
2819            case "@page":
2820                $hasUnknown = true;
2821                break;
2822            case "@document":
2823            case "@supports":
2824                $hasUnknown = true;
2825                $isRooted = false;
2826                break;
2827            default:
2828                // TODO: port other parts of https://github.com/less/less.js/commit/e3c13121dfdca48ba8fe26335cc12dd3f7948676
2829                $hasUnknown = true;
2830                break;
2831        }
2832
2833        $this->commentStore = [];
2834
2835        if ( $hasIdentifier ) {
2836            $value = $this->parseEntity();
2837            if ( !$value ) {
2838                $this->Error( "expected " . $name . " identifier" );
2839            }
2840        } elseif ( $hasExpression ) {
2841            $value = $this->parseExpression();
2842            if ( !$value ) {
2843                $this->Error( "expected " . $name . " expression" );
2844            }
2845        } elseif ( $hasUnknown ) {
2846            $value = $this->parsePermissiveValue( [ '{', ';' ] );
2847            $hasBlock = $this->input[$this->pos] === '{';
2848            if ( !$value ) {
2849                if ( !$hasBlock && $this->input[$this->pos] !== ';' ) {
2850                    $this->Error( $name . " rule is missing block or ending semi-colon" );
2851                }
2852            } elseif ( !$value->value ) {
2853                $value = null;
2854            }
2855        }
2856
2857        if ( $hasBlock ) {
2858            $rules = $this->parseBlockRuleset();
2859        }
2860
2861        if ( $rules || ( !$hasBlock && $value && $this->matchChar( ';' ) ) ) {
2862            $this->forget();
2863            return new Less_Tree_AtRule( $name, $value, $rules, $index, $isRooted, $this->env->currentFileInfo );
2864        }
2865
2866        $this->restore();
2867    }
2868
2869    //
2870    // A Value is a comma-delimited list of Expressions
2871    //
2872    //     font-family: Baskerville, Georgia, serif;
2873    //
2874    // In a Rule, a Value represents everything after the `:`,
2875    // and before the `;`.
2876    //
2877    private function parseValue() {
2878        $expressions = [];
2879        $index = $this->pos;
2880
2881        do {
2882            $e = $this->parseExpression();
2883            if ( $e ) {
2884                $expressions[] = $e;
2885                if ( !$this->matchChar( ',' ) ) {
2886                    break;
2887                }
2888            }
2889        } while ( $e );
2890
2891        if ( $expressions ) {
2892            return new Less_Tree_Value( $expressions, $index );
2893        }
2894    }
2895
2896    private function parseImportant() {
2897        if ( $this->peekChar( '!' ) && $this->matchReg( '/\\G! *important/' ) ) {
2898            return ' !important';
2899        }
2900    }
2901
2902    private function parseSub() {
2903        $this->save();
2904        if ( $this->matchChar( '(' ) ) {
2905            $a = $this->parseAddition();
2906            if ( $a && $this->matchChar( ')' ) ) {
2907                $this->forget();
2908                $e = new Less_Tree_Expression( [ $a ] );
2909                $e->parens = true;
2910                return $e;
2911            }
2912        }
2913        $this->restore();
2914    }
2915
2916    /**
2917     * Parses multiplication operation
2918     *
2919     * @return Less_Tree_Operation|null
2920     * @see less-3.13.1.js#parsers.multiplication
2921     */
2922    private function parseMultiplication() {
2923        $return = $m = $this->parseOperand();
2924        if ( $return ) {
2925            while ( true ) {
2926                $isSpaced = $this->isWhitespace( -1 );
2927
2928                if ( $this->peekReg( '/\\G\/[*\/]/' ) ) {
2929                    break;
2930                }
2931                $this->save();
2932
2933                $op = $this->matchChar( '/' ) ?? $this->matchChar( '*' ) ?? $this->matchStr( './' );
2934                if ( !$op ) {
2935                    $this->forget();
2936                    break;
2937                }
2938
2939                $a = $this->parseOperand();
2940
2941                if ( !$a ) {
2942                    $this->restore();
2943                    break;
2944                }
2945                $this->forget();
2946
2947                $m->parensInOp = true;
2948                $a->parensInOp = true;
2949                $return = new Less_Tree_Operation( $op, [ $return, $a ], $isSpaced );
2950            }
2951        }
2952        return $return;
2953    }
2954
2955    /**
2956     * Parses an addition operation
2957     *
2958     * @return Less_Tree_Operation|null
2959     */
2960    private function parseAddition() {
2961        $return = $m = $this->parseMultiplication();
2962        if ( $return ) {
2963            while ( true ) {
2964
2965                $isSpaced = $this->isWhitespace( -1 );
2966
2967                $op = $this->matchReg( '/\\G[-+]\s+/' );
2968                if ( !$op ) {
2969                    if ( !$isSpaced ) {
2970                        $op = $this->matchChar( '+' ) ?? $this->matchChar( '-' );
2971                    }
2972                    if ( !$op ) {
2973                        break;
2974                    }
2975                }
2976
2977                $a = $this->parseMultiplication();
2978                if ( !$a ) {
2979                    break;
2980                }
2981
2982                $m->parensInOp = true;
2983                $a->parensInOp = true;
2984                $return = new Less_Tree_Operation( $op, [ $return, $a ], $isSpaced );
2985            }
2986        }
2987
2988        return $return;
2989    }
2990
2991    /**
2992     * Parses the conditions
2993     *
2994     * @return Less_Tree_Condition|null
2995     */
2996    private function parseConditions() {
2997        $index = $this->pos;
2998        $return = $a = $this->parseCondition();
2999        if ( $a ) {
3000            while ( true ) {
3001                if ( !$this->peekReg( '/\\G,\s*(not\s*)?\(/' ) || !$this->matchChar( ',' ) ) {
3002                    break;
3003                }
3004                $b = $this->parseCondition();
3005                if ( !$b ) {
3006                    break;
3007                }
3008
3009                $return = new Less_Tree_Condition( 'or', $return, $b, $index );
3010            }
3011            return $return;
3012        }
3013    }
3014
3015    /**
3016     * @see less-2.5.3.js#parsers.condition
3017     */
3018    private function parseCondition() {
3019        $index = $this->pos;
3020        $negate = false;
3021        $c = null;
3022
3023        if ( $this->matchStr( 'not' ) ) {
3024            $negate = true;
3025        }
3026        $this->expectChar( '(' );
3027        /** @see less-3.13.1.js parsers.atomicCondition */
3028        $a = $this->parseAddition()
3029            ?? $this->parseEntitiesKeyword()
3030            ?? $this->parseEntitiesQuoted()
3031            ?? $this->parseEntitiesMixinLookup();
3032
3033        if ( $a ) {
3034            $op = $this->matchReg( '/\\G(?:>=|<=|=<|[<=>])/' );
3035            if ( $op ) {
3036                /** @see less-3.13.1.js parsers.atomicCondition */
3037                $b = $this->parseAddition()
3038                    ?? $this->parseEntitiesKeyword()
3039                    ?? $this->parseEntitiesQuoted()
3040                    ?? $this->parseEntitiesMixinLookup();
3041                if ( $b ) {
3042                    $c = new Less_Tree_Condition( $op, $a, $b, $index, $negate );
3043                } else {
3044                    $this->Error( 'Unexpected expression' );
3045                }
3046            } else {
3047                $k = new Less_Tree_Keyword( 'true' );
3048                $c = new Less_Tree_Condition( '=', $a, $k, $index, $negate );
3049            }
3050            $this->expectChar( ')' );
3051            // @phan-suppress-next-line PhanPossiblyInfiniteRecursionSameParams
3052            return $this->matchStr( 'and' ) ? new Less_Tree_Condition( 'and', $c, $this->parseCondition() ) : $c;
3053        }
3054    }
3055
3056    /**
3057     * An operand is anything that can be part of an operation,
3058     * such as a Color, or a Variable
3059     *
3060     * @see less-3.13.1.js#parsers.operand
3061     */
3062    private function parseOperand() {
3063        $negate = false;
3064        $offset = $this->pos + 1;
3065        if ( $offset >= $this->input_len ) {
3066            return;
3067        }
3068        $char = $this->input[$offset];
3069
3070        if ( $char === '@' || $char === '(' || $char === '$' ) {
3071            $negate = $this->matchChar( '-' );
3072        }
3073
3074        $o = $this->parseSub()
3075            ?? $this->parseEntitiesDimension()
3076            ?? $this->parseEntitiesColor()
3077            ?? $this->parseEntitiesVariable()
3078            ?? $this->parseEntitiesProperty()
3079            ?? $this->parseEntitiesCall()
3080            ?? $this->parseEntitiesQuoted( true )
3081            // TODO: from less-3.13.1.js missing entities.colorKeyword()
3082            ?? $this->parseEntitiesMixinLookup();
3083
3084        if ( $negate ) {
3085            $o->parensInOp = true;
3086            $o = new Less_Tree_Negative( $o );
3087        }
3088
3089        return $o;
3090    }
3091
3092    /**
3093     * Expressions either represent mathematical operations,
3094     * or white-space delimited Entities.
3095     *
3096     * @return Less_Tree_Expression|null
3097     * @see less-3.13.1.js#parsers.expression
3098     */
3099    private function parseExpression() {
3100        $entities = [];
3101        $index = $this->pos;
3102
3103        do {
3104            $e = $this->parseComment();
3105            if ( $e ) {
3106                $entities[] = $e;
3107                continue;
3108            }
3109            $e = $this->parseAddition() ?? $this->parseEntity();
3110            if ( $e instanceof Less_Tree_Comment ) {
3111                $e = null;
3112            }
3113            if ( $e ) {
3114                $entities[] = $e;
3115                // operations do not allow keyword "/" dimension (e.g. small/20px) so we support that here
3116                if ( !$this->peekReg( '/\\G\/[\/*]/' ) ) {
3117                    $delim = $this->matchChar( '/' );
3118                    if ( $delim ) {
3119                        $entities[] = new Less_Tree_Anonymous( $delim, $index );
3120                    }
3121                }
3122            }
3123        } while ( $e );
3124
3125        if ( $entities ) {
3126            return new Less_Tree_Expression( $entities );
3127        }
3128    }
3129
3130    /**
3131     * Parse a property
3132     * eg: 'min-width', 'orientation', etc
3133     *
3134     * @return string
3135     */
3136    private function parseProperty() {
3137        $name = $this->matchReg( '/\\G(\*?-?[_a-zA-Z0-9-]+)\s*:/' );
3138        if ( $name ) {
3139            return $name[1];
3140        }
3141    }
3142
3143    /**
3144     * Parse a rule property
3145     * eg: 'color', 'width', 'height', etc
3146     *
3147     * @return array<Less_Tree_Keyword|Less_Tree_Variable>
3148     * @see less-3.13.1.js#parsers.ruleProperty
3149     */
3150    private function parseRuleProperty() {
3151        $name = [];
3152        $index = [];
3153
3154        $this->save();
3155
3156        $simpleProperty = $this->matchReg( '/\\G([_a-zA-Z0-9-]+)\s*:/' );
3157        if ( $simpleProperty ) {
3158            $name[] = new Less_Tree_Keyword( $simpleProperty[1] );
3159            $this->forget();
3160            return $name;
3161        }
3162
3163        $this->rulePropertyMatch( '/\\G(\*?)/', $index, $name );
3164
3165        // Consume!
3166        // @phan-suppress-next-line PhanPluginEmptyStatementWhileLoop
3167        while ( $this->rulePropertyMatch( '/\\G((?:[\w-]+)|(?:[@\$]\{[\w-]+\}))/', $index, $name
3168        ) );
3169
3170        if ( ( count( $name ) > 1 ) && $this->rulePropertyMatch( '/\\G((?:\+_|\+)?)\s*:/', $index, $name ) ) {
3171            $this->forget();
3172
3173            // at last, we have the complete match now. move forward,
3174            // convert name particles to tree objects and return:
3175            if ( $name[0] === '' ) {
3176                array_shift( $name );
3177                array_shift( $index );
3178            }
3179            foreach ( $name as $k => $s ) {
3180                $firstChar = $s[0] ?? '';
3181                $name[$k] = ( $firstChar !== '@' && $firstChar !== '$' ) ?
3182                    new Less_Tree_Keyword( $s ) :
3183                    ( $s[0] === '@'
3184                        ? new Less_Tree_Variable( '@' . substr( $s, 2, -1 ), $index[$k], $this->env->currentFileInfo )
3185                        : new Less_Tree_Property( '$' . substr( $s, 2, -1 ), $index[$k], $this->env->currentFileInfo )
3186                    );
3187            }
3188            return $name;
3189        } else {
3190            $this->restore();
3191        }
3192    }
3193
3194    private function rulePropertyMatch( $re, &$index, &$name ) {
3195        $i = $this->pos;
3196        $chunk = $this->matchReg( $re );
3197        if ( $chunk ) {
3198            $index[] = $i;
3199            $name[] = $chunk[1];
3200            return true;
3201        }
3202    }
3203
3204    public static function serializeVars( $vars ) {
3205        $s = '';
3206
3207        foreach ( $vars as $name => $value ) {
3208            if ( strval( $value ) === "" ) {
3209                $value = '~""';
3210            }
3211            $s .= ( ( $name[0] === '@' ) ? '' : '@' ) . $name . ': ' . $value . ( ( substr( $value, -1 ) === ';' ) ? '' : ';' );
3212        }
3213
3214        return $s;
3215    }
3216
3217    /**
3218     * Some versions of PHP have trouble with method_exists($a,$b) if $a is not an object
3219     *
3220     * @internal For internal use only
3221     * @param mixed $a
3222     * @param string $b
3223     */
3224    public static function is_method( $a, $b ) {
3225        return is_object( $a ) && method_exists( $a, $b );
3226    }
3227
3228    /**
3229     * Round numbers similarly to javascript
3230     * eg: 1.499999 to 1 instead of 2
3231     *
3232     * @internal For internal use only
3233     */
3234    public static function round( $input, $precision = 0 ) {
3235        $precision = pow( 10, $precision );
3236        $i = $input * $precision;
3237
3238        $ceil = ceil( $i );
3239        $floor = floor( $i );
3240        if ( ( $ceil - $i ) <= ( $i - $floor ) ) {
3241            return $ceil / $precision;
3242        } else {
3243            return $floor / $precision;
3244        }
3245    }
3246
3247    /** @return never */
3248    public function Error( $msg ) {
3249        throw new Less_Exception_Parser( $msg, null, $this->furthest, $this->env->currentFileInfo );
3250    }
3251
3252    public static function WinPath( $path ) {
3253        return str_replace( '\\', '/', $path );
3254    }
3255
3256    public static function AbsPath( $path, $winPath = false ) {
3257        if ( strpos( $path, '//' ) !== false && preg_match( '/^(https?:)?\/\//i', $path ) ) {
3258            return $winPath ? '' : false;
3259        } else {
3260            $path = realpath( $path );
3261            if ( $winPath ) {
3262                $path = self::WinPath( $path );
3263            }
3264            return $path;
3265        }
3266    }
3267
3268    public function CacheEnabled() {
3269        return ( self::$options['cache_method'] && ( Less_Cache::$cache_dir || ( self::$options['cache_method'] == 'callback' ) ) );
3270    }
3271
3272}