Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.57% covered (success)
94.57%
505 / 534
89.23% covered (warning)
89.23%
58 / 65
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilterEvaluator
94.57% covered (success)
94.57%
505 / 534
89.23% covered (warning)
89.23%
58 / 65
187.31
0.00% covered (danger)
0.00%
0 / 1
 __construct
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 toggleConditionLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 raiseCondCount
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 setVariables
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCacheVersion
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
3.82
 resetState
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 checkSyntaxThrow
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 checkSyntax
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 checkConditions
62.50% covered (warning)
62.50%
15 / 24
0.00% covered (danger)
0.00%
0 / 1
6.32
 parse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 evaluateExpression
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTree
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 evalTree
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 getUsedVars
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 evalNode
99.36% covered (success)
99.36%
155 / 156
0.00% covered (danger)
0.00%
0 / 1
58
 callFunc
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
8
 callKeyword
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 varExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getVarValue
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 setUserVariable
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 funcLc
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcUc
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcLen
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 funcSpecialRatio
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 funcCount
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 funcRCount
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 funcGetMatches
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 funcIPInRange
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 funcIPInRanges
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 funcCCNorm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 funcSanitize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 funcContainsAny
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcContainsAll
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcCCNormContainsAny
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcCCNormContainsAll
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 contains
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 funcEqualsToAny
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 equalsToAny
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 ccnorm
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rmspecials
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 rmdoubles
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 rmwhitespace
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 funcRMSpecials
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcRMWhitespace
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcRMDoubles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcNorm
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 funcSubstr
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 funcStrPos
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 funcStrReplace
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 funcStrReplaceRegexp
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 funcStrRegexEscape
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 funcSetVar
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 containmentKeyword
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 keywordIn
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keywordContains
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keywordLike
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 keywordRegex
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 keywordRegexInsensitive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 castString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 castInt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 castFloat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 castBool
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybeDiscardNode
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 mungeRegexp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 checkRegexMatchesEmpty
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
6.80
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\Parser;
4
5use Exception;
6use InvalidArgumentException;
7use MediaWiki\Extension\AbuseFilter\KeywordsManager;
8use MediaWiki\Extension\AbuseFilter\Parser\Exception\ConditionLimitException;
9use MediaWiki\Extension\AbuseFilter\Parser\Exception\ExceptionBase;
10use MediaWiki\Extension\AbuseFilter\Parser\Exception\InternalException;
11use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleException;
12use MediaWiki\Extension\AbuseFilter\Parser\Exception\UserVisibleWarning;
13use MediaWiki\Extension\AbuseFilter\Variables\VariableHolder;
14use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
15use MediaWiki\Language\Language;
16use MediaWiki\Parser\Sanitizer;
17use Psr\Log\LoggerInterface;
18use Wikimedia\Equivset\Equivset;
19use Wikimedia\IPUtils;
20use Wikimedia\ObjectCache\BagOStuff;
21use Wikimedia\Stats\IBufferingStatsdDataFactory;
22
23/**
24 * This class evaluates an AST generated by the filter parser.
25 *
26 * @todo Override checkSyntax and make it only try to build the AST. That would mean faster results,
27 *   and no need to mess with DUNDEFINED and the like. However, we must first try to reduce the
28 *   amount of runtime-only exceptions, and try to detect them in the AFPTreeParser instead.
29 *   Otherwise, people may be able to save a broken filter without the syntax check reporting that.
30 */
31class FilterEvaluator {
32    private const CACHE_VERSION = 1;
33
34    public const FUNCTIONS = [
35        'lcase' => 'funcLc',
36        'ucase' => 'funcUc',
37        'length' => 'funcLen',
38        'string' => 'castString',
39        'int' => 'castInt',
40        'float' => 'castFloat',
41        'bool' => 'castBool',
42        'norm' => 'funcNorm',
43        'ccnorm' => 'funcCCNorm',
44        'ccnorm_contains_any' => 'funcCCNormContainsAny',
45        'ccnorm_contains_all' => 'funcCCNormContainsAll',
46        'specialratio' => 'funcSpecialRatio',
47        'rmspecials' => 'funcRMSpecials',
48        'rmdoubles' => 'funcRMDoubles',
49        'rmwhitespace' => 'funcRMWhitespace',
50        'count' => 'funcCount',
51        'rcount' => 'funcRCount',
52        'get_matches' => 'funcGetMatches',
53        'ip_in_range' => 'funcIPInRange',
54        'ip_in_ranges' => 'funcIPInRanges',
55        'contains_any' => 'funcContainsAny',
56        'contains_all' => 'funcContainsAll',
57        'equals_to_any' => 'funcEqualsToAny',
58        'substr' => 'funcSubstr',
59        'strlen' => 'funcLen',
60        'strpos' => 'funcStrPos',
61        'str_replace' => 'funcStrReplace',
62        'str_replace_regexp' => 'funcStrReplaceRegexp',
63        'rescape' => 'funcStrRegexEscape',
64        'set' => 'funcSetVar',
65        'set_var' => 'funcSetVar',
66        'sanitize' => 'funcSanitize',
67    ];
68
69    /**
70     * The minimum and maximum amount of arguments required by each function.
71     * @var int[][]
72     */
73    public const FUNC_ARG_COUNT = [
74        'lcase' => [ 1, 1 ],
75        'ucase' => [ 1, 1 ],
76        'length' => [ 1, 1 ],
77        'string' => [ 1, 1 ],
78        'int' => [ 1, 1 ],
79        'float' => [ 1, 1 ],
80        'bool' => [ 1, 1 ],
81        'norm' => [ 1, 1 ],
82        'ccnorm' => [ 1, 1 ],
83        'ccnorm_contains_any' => [ 2, INF ],
84        'ccnorm_contains_all' => [ 2, INF ],
85        'specialratio' => [ 1, 1 ],
86        'rmspecials' => [ 1, 1 ],
87        'rmdoubles' => [ 1, 1 ],
88        'rmwhitespace' => [ 1, 1 ],
89        'count' => [ 1, 2 ],
90        'rcount' => [ 1, 2 ],
91        'get_matches' => [ 2, 2 ],
92        'ip_in_range' => [ 2, 2 ],
93        'ip_in_ranges' => [ 2, INF ],
94        'contains_any' => [ 2, INF ],
95        'contains_all' => [ 2, INF ],
96        'equals_to_any' => [ 2, INF ],
97        'substr' => [ 2, 3 ],
98        'strlen' => [ 1, 1 ],
99        'strpos' => [ 2, 3 ],
100        'str_replace' => [ 3, 3 ],
101        'str_replace_regexp' => [ 3, 3 ],
102        'rescape' => [ 1, 1 ],
103        'set' => [ 2, 2 ],
104        'set_var' => [ 2, 2 ],
105        'sanitize' => [ 1, 1 ],
106    ];
107
108    // Functions that affect parser state, and shouldn't be cached.
109    private const ACTIVE_FUNCTIONS = [
110        'funcSetVar',
111    ];
112
113    public const KEYWORDS = [
114        'in' => 'keywordIn',
115        'like' => 'keywordLike',
116        'matches' => 'keywordLike',
117        'contains' => 'keywordContains',
118        'rlike' => 'keywordRegex',
119        'irlike' => 'keywordRegexInsensitive',
120        'regex' => 'keywordRegex',
121    ];
122
123    /**
124     * @var bool Are we allowed to use short-circuit evaluation?
125     */
126    private $mAllowShort;
127
128    /**
129     * @var VariableHolder
130     */
131    private $mVariables;
132    /**
133     * @var int The current amount of conditions being consumed
134     */
135    private $mCondCount;
136    /**
137     * @var bool Whether the condition limit is enabled.
138     */
139    private $condLimitEnabled = true;
140    /**
141     * @var string|null The ID of the filter being parsed, if available. Can also be "global-$ID"
142     */
143    private $mFilter;
144    /**
145     * @var bool Whether we can allow retrieving _builtin_ variables not included in $this->mVariables
146     */
147    private $allowMissingVariables = false;
148
149    /**
150     * @var BagOStuff Used to cache the AST and the tokens
151     */
152    private $cache;
153    /**
154     * @var bool Whether the AST was retrieved from cache
155     */
156    private $fromCache = false;
157    /**
158     * @var LoggerInterface Used for debugging
159     */
160    private $logger;
161    /**
162     * @var Language Content language, used for language-dependent functions
163     */
164    private $contLang;
165    /**
166     * @var IBufferingStatsdDataFactory
167     */
168    private $statsd;
169
170    /** @var KeywordsManager */
171    private $keywordsManager;
172
173    /** @var VariablesManager */
174    private $varManager;
175
176    /** @var int */
177    private $conditionsLimit;
178
179    /** @var UserVisibleWarning[] */
180    private $warnings = [];
181
182    /**
183     * @var array Cached results of functions
184     */
185    private $funcCache = [];
186
187    /**
188     * @var Equivset
189     */
190    private $equivset;
191
192    /**
193     * @var array AFPToken::TID values found during node evaluation
194     */
195    private $usedVars = [];
196
197    /**
198     * Create a new instance
199     *
200     * @param Language $contLang Content language, used for language-dependent function
201     * @param BagOStuff $cache Used to cache the AST and the tokens
202     * @param LoggerInterface $logger Used for debugging
203     * @param KeywordsManager $keywordsManager
204     * @param VariablesManager $varManager
205     * @param IBufferingStatsdDataFactory $statsdDataFactory
206     * @param Equivset $equivset
207     * @param int $conditionsLimit
208     * @param VariableHolder|null $vars
209     */
210    public function __construct(
211        Language $contLang,
212        BagOStuff $cache,
213        LoggerInterface $logger,
214        KeywordsManager $keywordsManager,
215        VariablesManager $varManager,
216        IBufferingStatsdDataFactory $statsdDataFactory,
217        Equivset $equivset,
218        int $conditionsLimit,
219        ?VariableHolder $vars = null
220    ) {
221        $this->contLang = $contLang;
222        $this->cache = $cache;
223        $this->logger = $logger;
224        $this->statsd = $statsdDataFactory;
225        $this->keywordsManager = $keywordsManager;
226        $this->varManager = $varManager;
227        $this->equivset = $equivset;
228        $this->conditionsLimit = $conditionsLimit;
229        $this->resetState();
230        if ( $vars ) {
231            $this->mVariables = $vars;
232        }
233    }
234
235    /**
236     * For use in batch scripts and the like
237     *
238     * @param bool $enable True to enable the limit, false to disable it
239     */
240    public function toggleConditionLimit( $enable ) {
241        $this->condLimitEnabled = $enable;
242    }
243
244    /**
245     * @throws ConditionLimitException
246     */
247    private function raiseCondCount() {
248        $this->mCondCount++;
249        if ( $this->condLimitEnabled && $this->mCondCount > $this->conditionsLimit ) {
250            throw new ConditionLimitException();
251        }
252    }
253
254    /**
255     * @param VariableHolder $vars
256     */
257    public function setVariables( VariableHolder $vars ) {
258        $this->mVariables = $vars;
259    }
260
261    /**
262     * Return the generated version of the parser for cache invalidation
263     * purposes.  Automatically tracks list of all functions and invalidates the
264     * cache if it is changed.
265     * @return string
266     */
267    private static function getCacheVersion() {
268        static $version = null;
269        if ( $version !== null ) {
270            return $version;
271        }
272
273        $versionKey = [
274            self::CACHE_VERSION,
275            AFPTreeParser::CACHE_VERSION,
276            AbuseFilterTokenizer::CACHE_VERSION,
277            SyntaxChecker::CACHE_VERSION,
278            array_keys( self::FUNCTIONS ),
279            array_keys( self::KEYWORDS ),
280        ];
281        $version = hash( 'sha256', serialize( $versionKey ) );
282
283        return $version;
284    }
285
286    /**
287     * Resets the state of the parser
288     */
289    private function resetState() {
290        $this->mVariables = new VariableHolder();
291        $this->mCondCount = 0;
292        $this->mAllowShort = true;
293        $this->mFilter = null;
294        $this->warnings = [];
295        $this->usedVars = [];
296    }
297
298    /**
299     * Check the syntax of $filter, throwing an exception if invalid
300     * @param string $filter
301     * @return true When successful
302     * @throws UserVisibleException
303     */
304    public function checkSyntaxThrow( string $filter ): bool {
305        $this->allowMissingVariables = true;
306        $origAS = $this->mAllowShort;
307        try {
308            $this->mAllowShort = false;
309            $this->evalTree( $this->getTree( $filter ) );
310        } finally {
311            $this->mAllowShort = $origAS;
312            $this->allowMissingVariables = false;
313        }
314
315        return true;
316    }
317
318    /**
319     * Check the syntax of $filter, without throwing
320     *
321     * @param string $filter
322     * @return ParserStatus
323     */
324    public function checkSyntax( string $filter ): ParserStatus {
325        $initialConds = $this->mCondCount;
326        try {
327            $this->checkSyntaxThrow( $filter );
328        } catch ( UserVisibleException $excep ) {
329        }
330
331        return new ParserStatus(
332            $excep ?? null,
333            $this->warnings,
334            $this->mCondCount - $initialConds
335        );
336    }
337
338    /**
339     * This is the main entry point. It checks the given conditions and returns whether
340     * they match. Parser errors are always logged.
341     *
342     * @param string $conds
343     * @param string|null $filter The ID of the filter being parsed
344     * @return RuleCheckerStatus
345     */
346    public function checkConditions( string $conds, $filter = null ): RuleCheckerStatus {
347        $this->mFilter = $filter;
348        $excep = null;
349        $initialConds = $this->mCondCount;
350        $startTime = microtime( true );
351        try {
352            $res = $this->parse( $conds );
353        } catch ( ExceptionBase $excep ) {
354            $res = false;
355        }
356        $this->statsd->timing( 'abusefilter_cachingParser_full', microtime( true ) - $startTime );
357        $result = new RuleCheckerStatus(
358            $res,
359            $this->fromCache,
360            $excep,
361            $this->warnings,
362            $this->mCondCount - $initialConds
363        );
364
365        if ( $excep !== null ) {
366            if ( $excep instanceof UserVisibleException ) {
367                $msg = $excep->getMessageForLogs();
368            } else {
369                $msg = $excep->getMessage();
370            }
371
372            $this->logger->warning(
373                "AbuseFilter parser error: {parser_error}",
374                [ 'parser_error' => $msg, 'broken_filter' => $filter ?: 'none' ]
375            );
376        }
377
378        return $result;
379    }
380
381    /**
382     * @param string $code
383     * @return bool
384     */
385    public function parse( $code ) {
386        $res = $this->evalTree( $this->getTree( $code ) );
387        return $res->getType() === AFPData::DUNDEFINED ? false : $res->toBool();
388    }
389
390    /**
391     * @param string $filter
392     * @return mixed
393     */
394    public function evaluateExpression( $filter ) {
395        return $this->evalTree( $this->getTree( $filter ) )->toNative();
396    }
397
398    /**
399     * @param string $code
400     * @return AFPSyntaxTree
401     */
402    private function getTree( $code ): AFPSyntaxTree {
403        $this->fromCache = true;
404        return $this->cache->getWithSetCallback(
405            $this->cache->makeGlobalKey(
406                __CLASS__,
407                self::getCacheVersion(),
408                hash( 'sha256', $code )
409            ),
410            BagOStuff::TTL_DAY,
411            function () use ( $code ) {
412                $this->fromCache = false;
413                $tokenizer = new AbuseFilterTokenizer( $this->cache );
414                $tokens = $tokenizer->getTokens( $code );
415                $parser = new AFPTreeParser( $this->logger, $this->statsd, $this->keywordsManager );
416                $parser->setFilter( $this->mFilter );
417                $tree = $parser->parse( $tokens );
418                $checker = new SyntaxChecker(
419                    $tree,
420                    $this->keywordsManager,
421                    SyntaxChecker::MCONSERVATIVE,
422                    false
423                );
424                $checker->start();
425                return $tree;
426            }
427        );
428    }
429
430    /**
431     * @param AFPSyntaxTree $tree
432     * @return AFPData
433     */
434    private function evalTree( AFPSyntaxTree $tree ): AFPData {
435        $startTime = microtime( true );
436        $root = $tree->getRoot();
437
438        if ( !$root ) {
439            return new AFPData( AFPData::DNULL );
440        }
441
442        $ret = $this->evalNode( $root );
443        $this->statsd->timing( 'abusefilter_cachingParser_eval', microtime( true ) - $startTime );
444        return $ret;
445    }
446
447    /**
448     * Parse a filter and return the variables used.
449     * All variables are AFPToken::TID and are found during the node stepthrough in evaluation
450     * and saved to self::usedVars to be returned to the caller in this function.
451     *
452     * @param string $filter
453     * @return string[]
454     */
455    public function getUsedVars( string $filter ): array {
456        $this->checkSyntax( $filter );
457        return array_unique( $this->usedVars );
458    }
459
460    /**
461     * Evaluate the value of the specified AST node.
462     *
463     * @param AFPTreeNode $node The node to evaluate.
464     * @return AFPData|AFPTreeNode|string
465     * @throws ExceptionBase
466     * @throws UserVisibleException
467     */
468    private function evalNode( AFPTreeNode $node ) {
469        switch ( $node->type ) {
470            case AFPTreeNode::ATOM:
471                $tok = $node->children;
472                switch ( $tok->type ) {
473                    case AFPToken::TID:
474                        return $this->getVarValue( strtolower( $tok->value ) );
475                    case AFPToken::TSTRING:
476                        return new AFPData( AFPData::DSTRING, $tok->value );
477                    case AFPToken::TFLOAT:
478                        return new AFPData( AFPData::DFLOAT, $tok->value );
479                    case AFPToken::TINT:
480                        return new AFPData( AFPData::DINT, $tok->value );
481                    /** @noinspection PhpMissingBreakStatementInspection */
482                    case AFPToken::TKEYWORD:
483                        switch ( $tok->value ) {
484                            case "true":
485                                return new AFPData( AFPData::DBOOL, true );
486                            case "false":
487                                return new AFPData( AFPData::DBOOL, false );
488                            case "null":
489                                return new AFPData( AFPData::DNULL );
490                        }
491                    // Fallthrough intended
492                    default:
493                        // @codeCoverageIgnoreStart
494                        throw new InternalException( "Unknown token provided in the ATOM node" );
495                        // @codeCoverageIgnoreEnd
496                }
497                // Unreachable line
498            case AFPTreeNode::ARRAY_DEFINITION:
499                $items = [];
500                // Foreach is usually faster than array_map
501                // @phan-suppress-next-line PhanTypeSuspiciousNonTraversableForeach children is array here
502                foreach ( $node->children as $el ) {
503                    $items[] = $this->evalNode( $el );
504                }
505                return new AFPData( AFPData::DARRAY, $items );
506
507            case AFPTreeNode::FUNCTION_CALL:
508                $functionName = $node->children[0];
509                $args = array_slice( $node->children, 1 );
510
511                $dataArgs = [];
512                // Foreach is usually faster than array_map
513                foreach ( $args as $arg ) {
514                    $dataArgs[] = $this->evalNode( $arg );
515                }
516
517                return $this->callFunc( $functionName, $dataArgs, $node->position );
518            case AFPTreeNode::ARRAY_INDEX:
519                [ $array, $offset ] = $node->children;
520
521                $array = $this->evalNode( $array );
522                // Note: we MUST evaluate the offset to ensure it is valid, regardless
523                // of $array!
524                $offset = $this->evalNode( $offset );
525                // @todo If $array has no elements we could already throw an outofbounds. We don't
526                // know what the index is, though.
527                if ( $offset->getType() === AFPData::DUNDEFINED ) {
528                    return new AFPData( AFPData::DUNDEFINED );
529                }
530                $offset = $offset->toInt();
531
532                if ( $array->getType() === AFPData::DUNDEFINED ) {
533                    return new AFPData( AFPData::DUNDEFINED );
534                }
535
536                if ( $array->getType() !== AFPData::DARRAY ) {
537                    throw new UserVisibleException( 'notarray', $node->position, [] );
538                }
539
540                $array = $array->toArray();
541                if ( count( $array ) <= $offset ) {
542                    throw new UserVisibleException( 'outofbounds', $node->position,
543                        [ $offset, count( $array ) ] );
544                } elseif ( $offset < 0 ) {
545                    throw new UserVisibleException( 'negativeindex', $node->position, [ $offset ] );
546                }
547
548                return $array[$offset];
549
550            case AFPTreeNode::UNARY:
551                [ $operation, $argument ] = $node->children;
552                $argument = $this->evalNode( $argument );
553                if ( $operation === '-' ) {
554                    return $argument->unaryMinus();
555                }
556                return $argument;
557
558            case AFPTreeNode::KEYWORD_OPERATOR:
559                [ $keyword, $leftOperand, $rightOperand ] = $node->children;
560                $leftOperand = $this->evalNode( $leftOperand );
561                $rightOperand = $this->evalNode( $rightOperand );
562
563                return $this->callKeyword( $keyword, $leftOperand, $rightOperand, $node->position );
564            case AFPTreeNode::BOOL_INVERT:
565                [ $argument ] = $node->children;
566                $argument = $this->evalNode( $argument );
567                return $argument->boolInvert();
568
569            case AFPTreeNode::POW:
570                [ $base, $exponent ] = $node->children;
571                $base = $this->evalNode( $base );
572                $exponent = $this->evalNode( $exponent );
573                return $base->pow( $exponent );
574