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