• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

FormulasQuestion / moodle-qtype_formulas / 13197954677

07 Feb 2025 10:28AM UTC coverage: 76.583% (+1.5%) from 75.045%
13197954677

Pull #62

github

web-flow
Merge 0c5187b3c into acd272945
Pull Request #62: Rewrite the parser

2517 of 3116 new or added lines in 22 files covered. (80.78%)

146 existing lines in 6 files now uncovered.

2976 of 3886 relevant lines covered (76.58%)

431.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.33
/classes/local/evaluator.php
1
<?php
2
// This file is part of Moodle - https://moodle.org/
3
//
4
// Moodle is free software: you can redistribute it and/or modify
5
// it under the terms of the GNU General Public License as published by
6
// the Free Software Foundation, either version 3 of the License, or
7
// (at your option) any later version.
8
//
9
// Moodle is distributed in the hope that it will be useful,
10
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
// GNU General Public License for more details.
13
//
14
// You should have received a copy of the GNU General Public License
15
// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16

17
namespace qtype_formulas\local;
18

19
use qtype_formulas;
20
use Throwable, Exception;
21

22
/**
23
 * Evaluator for qtype_formulas
24
 *
25
 * @package    qtype_formulas
26
 * @copyright  2022 Philipp Imhof
27
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
28
 */
29
class evaluator {
30
    /** @var array list function name => [min params, max params] */
31
    const PHPFUNCTIONS = [
32
        'abs' => [1, 1],
33
        'acos' => [1, 1],
34
        'acosh' => [1, 1],
35
        'asin' => [1, 1],
36
        'asinh' => [1, 1],
37
        'atan2' => [2, 2],
38
        'atan' => [1, 1],
39
        'atanh' => [1, 1],
40
        'base_convert' => [3, 3],
41
        'bindec' => [1, 1],
42
        'ceil' => [1, 1],
43
        'cos' => [1, 1],
44
        'cosh' => [1, 1],
45
        'decbin' => [1, 1],
46
        'dechex' => [1, 1],
47
        'decoct' => [1, 1],
48
        'deg2rad' => [1, 1],
49
        'exp' => [1, 1],
50
        'expm1' => [1, 1],
51
        'fdiv' => [2, 2],
52
        'floor' => [1, 1],
53
        'hexdec' => [1, 1],
54
        'hypot' => [2, 2],
55
        'intdiv' => [2, 2],
56
        'is_finite' => [1, 1],
57
        'is_infinite' => [1, 1],
58
        'is_nan' => [1, 1],
59
        'log10' => [1, 1],
60
        'log1p' => [1, 1],
61
        'log' => [1, 2],
62
        'max' => [1, INF],
63
        'min' => [1, INF],
64
        'octdec' => [1, 1],
65
        'pow' => [2, 2],
66
        'rad2deg' => [1, 1],
67
        'round' => [1, 2],
68
        'sin' => [1, 1],
69
        'sinh' => [1, 1],
70
        'sqrt' => [1, 1],
71
        'tan' => [1, 1],
72
        'tanh' => [1, 1],
73
    ];
74

75
    /** @var array $variables array holding all variables */
76
    private array $variables = [];
77

78
    /** @var array $randomvariables array holding all (uninstantiated) random variables */
79
    private array $randomvariables = [];
80

81
    /** @var array $constants array holding all predefined constants, i. e. pi */
82
    private array $constants = [
83
        'π' => M_PI,
84
    ];
85

86
    /** @var array $stack the operand stack */
87
    private array $stack = [];
88

89
    /**
90
     * PRNG seed. This is used, because we want the same variable to be resolved to the same
91
     * value when evaluating any given expression.
92
     *
93
     * @var int $seed
94
     */
95
    private int $seed = 0;
96

97
    /** @var bool $godmode whether we are allowed to modify reserved variables, e.g. _a or _0 */
98
    private bool $godmode = false;
99

100
    /** @var bool $algebraicmode whether algebraic variables are replaced by a random value from their reservoir */
101
    private bool $algebraicmode = false;
102

103
    /**
104
     * Create an evaluator class. This class does all evaluations for expressions that have
105
     * been parsed by a parser or answer_parser.
106
     *
107
     * @param array $context serialized variable context from another evaluator class
108
     */
109
    public function __construct(array $context = []) {
110
        $this->reinitialize($context);
7,378✔
111
    }
112

113
    /**
114
     * Substitute placeholders like {a} or {=a*b} in a text by evaluating the corresponding
115
     * expressions in the current evaluator.
116
     *
117
     * @param string $text the text to be formatted
118
     * @param bool $skiplists whether lists should be skipped, otherwise they are printed as [1, 2, 3]
119
     * @return string
120
     */
121
    public function substitute_variables_in_text(string $text, bool $skiplists = true): string {
122
        // We have three sorts of placeholders: "naked" variables like {a},
123
        // variables with a numerical index like {a[1]} or more complex
124
        // expressions like {=a+b} or {=a[b]}.
125
        $varpattern = '[_A-Za-z]\w*';
34✔
126
        $arraypattern = '[_A-Za-z]\w*(\[\d+\])+';
34✔
127
        $expressionpattern = '=[^}]+';
34✔
128

129
        $matches = [];
34✔
130
        preg_match_all("/\{($varpattern|$arraypattern|$expressionpattern)\}/", $text, $matches);
34✔
131

132
        // We have the variable names or expressions in $matches[1]. Let's first filter out the
133
        // duplicates.
134
        $matches = array_unique($matches[1]);
34✔
135

136
        foreach ($matches as $match) {
34✔
137
            $input = $match;
34✔
138
            // For expressions, we have to remove the = sign.
139
            if ($input[0] === '=') {
34✔
140
                $input = substr($input, 1);
34✔
141
            }
142
            // We could resolve variables like {a} or {b[1]} directly and it would probably be faster
143
            // to do so, but the code is much simpler if we just feed everything to the evaluator.
144
            // If there is an evaluation error, we simply do not replace do placeholder.
145
            try {
146
                $parser = new parser($input);
34✔
147
                // Before evaluating an expression, we want to make sure it does not contain
148
                // an assignment operator, because that could overwrite values in the evaluator's
149
                // variable context.
150
                if ($input !== $match && $parser->has_token_in_tokenlist(token::OPERATOR, '=')) {
34✔
151
                    continue;
17✔
152
                }
153
                // Evaluation will fail e.g. if it is an algebraic variable or if there is an
154
                // error in the expression. In those cases, the placeholder will simply not
155
                // be replaced.
156
                $results = $this->evaluate($parser->get_statements());
34✔
157
                $result = end($results);
34✔
158
                // If the users does not want to substitute lists (arrays), well ... we don't.
159
                if ($skiplists && in_array($result->type, [token::LIST, token::SET])) {
34✔
160
                    continue;
17✔
161
                }
162
                $text = str_replace("{{$match}}", strval($result), $text);
34✔
163
            } catch (Exception $e) {
34✔
164
                // TODO: use non-capturing exception when we drop support for old PHP.
165
                unset($e);
34✔
166
            }
167
        }
168

169
        return $text;
34✔
170
    }
171

172
    /**
173
     * Remove the special variables like _a or _0, _1, ... from the evaluator.
174
     *
175
     * @return void
176
     */
177
    public function remove_special_vars(): void {
NEW
178
        foreach ($this->variables as $name => $variable) {
×
NEW
179
            $isreserved = in_array($name, ['_err', '_relerr', '_a', '_r', '_d', '_u']);
×
NEW
180
            $isanswer = preg_match('/^_\d+$/', $name);
×
181

NEW
182
            if ($isreserved || $isanswer) {
×
NEW
183
                unset($this->variables[$name]);
×
184
            }
185
        }
186
    }
187

188
    /**
189
     * Reinitialize the evaluator by clearing the stack and, if requested, setting the
190
     * variables and random variables to a certain state.
191
     *
192
     * @param array $context associative array containing the random and normal variables
193
     * @return void
194
     */
195
    public function reinitialize(array $context = []): void {
196
        $this->clear_stack();
7,378✔
197

198
        // If a context is given, we initialize our variables accordingly.
199
        if (key_exists('randomvariables', $context) && key_exists('variables', $context)) {
7,378✔
200
            $this->import_variable_context($context);
17✔
201
        }
202
    }
203

204
    /**
205
     * Clear the stack.
206
     *
207
     * @return void
208
     */
209
    public function clear_stack(): void {
210
        $this->stack = [];
7,378✔
211
    }
212

213
    /**
214
     * Export all random variables and variables. The function returns an associative array
215
     * with the keys 'randomvariables' and 'variables'. Each key will hold the serialized
216
     * string of the corresponding variables.
217
     *
218
     * @return array
219
     */
220
    public function export_variable_context(): array {
221
        return [
17✔
222
            'randomvariables' => serialize($this->randomvariables),
17✔
223
            'variables' => serialize($this->variables),
17✔
224
        ];
17✔
225
    }
226

227
    /**
228
     * Export the names of all known variables. This can be used to pass to a new parser,
229
     * in order to help it classify identifiers as functions or variables.
230
     *
231
     * @return array
232
     */
233
    public function export_variable_list(): array {
234
        return array_keys($this->variables);
2,652✔
235
    }
236

237
    /**
238
     * Export the variable with the given name. Depending on the second parameter, the function
239
     * returns a token (the variable's content) or a variable (the variable's actual definition).
240
     *
241
     * @param string $varname name of the variable
242
     * @param boolean $exportasvariable whether to export as an instance of variable, otherwise just export the content
243
     * @return token|variable
244
     */
245
    public function export_single_variable(string $varname, bool $exportasvariable = false) {
246
        if ($exportasvariable) {
1,921✔
247
            return $this->variables[$varname];
1,666✔
248
        }
249
        $result = $this->get_variable_value(token::wrap($varname));
255✔
250
        return $result;
255✔
251
    }
252

253
    /**
254
     * Calculate the number of possible variants according to the defined random variables.
255
     *
256
     * @return int
257
     */
258
    public function get_number_of_variants(): int {
259
        $result = 1;
221✔
260
        foreach ($this->randomvariables as $var) {
221✔
261
            $num = $var->how_many();
221✔
262
            if ($num > PHP_INT_MAX / $result) {
221✔
263
                return PHP_INT_MAX;
17✔
264
            }
265
            $result = $result * $num;
221✔
266
        }
267
        return $result;
204✔
268
    }
269

270
    /**
271
     * Instantiate random variables, i. e. assigning a fixed value to them and make them available
272
     * as regular global variables.
273
     *
274
     * @param integer|null $seed
275
     * @return void
276
     */
277
    public function instantiate_random_variables(?int $seed = null): void {
278
        if (isset($seed)) {
238✔
NEW
279
            mt_srand($seed);
×
280
        }
281
        foreach ($this->randomvariables as $var) {
238✔
282
            $value = $var->instantiate();
238✔
283
            $this->set_variable_to_value(token::wrap($var->name, token::VARIABLE), $value);
238✔
284
        }
285
    }
286

287
    /**
288
     * Import an existing variable context, e.g. from another evaluator class.
289
     * If the same variable exists in our context and the incoming context, the
290
     * incoming context will overwrite our data. This can be avoided by setting
291
     * the optional parameter to false.
292
     *
293
     * @param array $data serialized context for randomvariables and variables
294
     * @param boolean $overwrite whether to overwrite existing data with incoming context
295
     * @return void
296
     */
297
    public function import_variable_context(array $data, bool $overwrite = true) {
298
        // If the data is invalid, unserialize() will issue an E_NOTICE. We suppress that,
299
        // because we have our own error message.
300
        $randomvariables = @unserialize($data['randomvariables'], ['allowed_classes' => [random_variable::class, token::class]]);
17✔
301
        $variables = @unserialize($data['variables'], ['allowed_classes' => [variable::class, token::class]]);
17✔
302
        if ($randomvariables === false || $variables === false) {
17✔
303
            throw new Exception(get_string('error_invalidcontext', 'qtype_formulas'));
17✔
304
        }
305
        foreach ($variables as $name => $var) {
17✔
306
            // New variables are added.
307
            // Existing variables are only overwritten, if $overwrite is true.
308
            $notknownyet = !array_key_exists($name, $this->variables);
17✔
309
            if ($notknownyet || $overwrite) {
17✔
310
                $this->variables[$name] = $var;
17✔
311
            }
312
        }
313
        foreach ($randomvariables as $name => $var) {
17✔
314
            // New variables are added.
315
            // Existing variables are only overwritten, if $overwrite is true.
316
            $notknownyet = !array_key_exists($name, $this->randomvariables);
17✔
317
            if ($notknownyet || $overwrite) {
17✔
318
                $this->randomvariables[$name] = $var;
17✔
319
            }
320
        }
321
    }
322

323
    /**
324
     * Set the variable defined in $token to the value $value and correctly set
325
     * it's $type attribute.
326
     *
327
     * @param token $vartoken
328
     * @param token $value
329
     * @param bool $israndomvar
330
     * @return token
331
     */
332
    private function set_variable_to_value(token $vartoken, token $value, $israndomvar = false): token {
333
        // Get the "basename" of the variable, e.g. foo in case of foo[1][2].
334
        $basename = $vartoken->value;
3,859✔
335
        if (strpos($basename, '[') !== false) {
3,859✔
336
            $basename = strstr($basename, '[', true);
187✔
337
        }
338

339
        // Some variables are reserved and cannot be used as left-hand side in an assignment,
340
        // unless the evaluator is currently in god mode.
341
        // Note that _m is not a reserved name in itself, but the placeholder {_m} is accepted
342
        // by the renderer to mark the position of the feedback image. Allowing that variable
343
        // could lead to conflicts, so we do not allow it.
344
        $isreserved = in_array($basename, ['_err', '_relerr', '_a', '_r', '_d', '_u', '_m']);
3,859✔
345
        $isanswer = preg_match('/^_\d+$/', $basename);
3,859✔
346
        // We will -- at least for the moment -- block all variables starting with an underscore,
347
        // because we might one day need some internal variables or the like.
348
        $underscore = strpos($basename, '_') === 0;
3,859✔
349
        if ($underscore && $this->godmode === false) {
3,859✔
350
            $this->die(get_string('error_invalidvarname', 'qtype_formulas', $basename), $value);
17✔
351
        }
352

353
        // If there are no indices, we set the variable as requested.
354
        if ($basename === $vartoken->value) {
3,842✔
355
            // If we are assigning to a random variable, we create a new instance and
356
            // return the value of the first instantiation.
357
            if ($israndomvar) {
3,825✔
358
                $useshuffle = $value->type === variable::LIST;
272✔
359
                if (is_scalar($value->value)) {
272✔
NEW
360
                    $this->die(get_string('error_invalidrandvardef', 'qtype_formulas'), $value);
×
361
                }
362
                $randomvar = new random_variable($basename, $value->value, $useshuffle);
272✔
363
                $this->randomvariables[$basename] = $randomvar;
272✔
364
                return token::wrap($randomvar->reservoir);
272✔
365
            }
366

367
            // Otherwise we return the stored value. If the data is a SET, the variable is an
368
            // algebraic variable.
369
            if ($value->type === token::SET) {
3,808✔
370
                // Algebraic variables only accept a list of numbers; they must not contain
371
                // strings or nested lists.
372
                foreach ($value->value as $entry) {
1,479✔
373
                    if ($entry->type != token::NUMBER) {
1,479✔
374
                        $this->die(get_string('error_algvar_numbers', 'qtype_formulas'), $value);
85✔
375
                    }
376
                }
377

378
                $value->type = variable::ALGEBRAIC;
1,394✔
379
            }
380
            $var = new variable($basename, $value->value, $value->type, microtime(true));
3,723✔
381
            $this->variables[$basename] = $var;
3,723✔
382
            return token::wrap($var->value);
3,723✔
383
        }
384

385
        // If there is an index and we are setting a random variable, we throw an error.
386
        if ($israndomvar) {
187✔
387
            $this->die(get_string('error_setindividual_randvar', 'qtype_formulas'), $value);
17✔
388
        }
389

390
        // If there is an index, but the variable is a string, we throw an error. Setting
391
        // characters of a string in this way is not allowed.
392
        if ($this->variables[$basename]->type === variable::STRING) {
170✔
393
            $this->die(get_string('error_setindividual_string', 'qtype_formulas'), $value);
17✔
394
        }
395

396
        // Otherwise, we try to get the variable's value. The function will
397
        // - resolve indices correctly
398
        // - throw an error, if the variable does not exist
399
        // so we can just rely on that.
400
        $current = $this->get_variable_value($vartoken);
153✔
401

402
        // Array elements are stored as tokens rather than just values (because
403
        // each element can have a different type). That means, we received an
404
        // object or rather a reference to an object. Thus, if we change the value and
405
        // type attribute of that token object, it will automatically be changed
406
        // inside the array itself.
407
        $current->value = $value->value;
153✔
408
        $current->type = $value->type;
153✔
409
        // Update timestamp for the base variable.
410
        $this->variables[$basename]->timestamp = microtime(true);
153✔
411

412
        // Finally, we return what has been stored.
413
        return $current;
153✔
414
    }
415

416
    /**
417
     * Make sure the index is valid, i. e. an integer (as a number or string) and not out
418
     * of range. If needed, translate a negative index (count from end) to a 0-indexed value.
419
     *
420
     * @param mixed $arrayorstring array or string that should be indexed
421
     * @param mixed $index the index
422
     * @param ?token $anchor anchor token used in case of error (may be the array or the index)
423
     * @return int
424
     */
425
    private function validate_array_or_string_index($arrayorstring, $index, ?token $anchor = null): int {
426
        // Check if the index is a number. If it is not, try to convert it.
427
        // If conversion fails, throw an error.
428
        if (!is_numeric($index)) {
646✔
429
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
34✔
430
        }
431
        $index = floatval($index);
612✔
432

433
        // If the index is not a whole number, throw an error. A whole number in float
434
        // representation is fine, though.
435
        if ($index - intval($index) != 0) {
612✔
436
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
34✔
437
        }
438
        $index = intval($index);
578✔
439

440
        // Fetch the length of the array or string.
441
        if (is_string($arrayorstring)) {
578✔
442
            $len = strlen($arrayorstring);
102✔
443
        } else if (is_array($arrayorstring)) {
476✔
444
            $len = count($arrayorstring);
442✔
445
        } else {
446
            $this->die(get_string('error_notindexable', 'qtype_formulas'), $anchor);
34✔
447
        }
448

449
        // Negative indices can be used to count "from the end". For strings, this is
450
        // directly supported in PHP, but not for arrays. So for the sake of simplicity,
451
        // we do our own preprocessing.
452
        if ($index < 0) {
544✔
453
            $index = $index + $len;
119✔
454
        }
455
        // Now check if the index is out of range. We use the original value from the token.
456
        if ($index > $len - 1 || $index < 0) {
544✔
457
            $this->die(get_string('error_indexoutofrange', 'qtype_formulas', $index), $anchor);
102✔
458
        }
459

460
        return $index;
476✔
461
    }
462

463
    /**
464
     * Get the value token that is stored in a variable. If the token is a literal
465
     * (number, string, array, set), just return the value directly.
466
     *
467
     * @param token $variable
468
     * @return token
469
     */
470
    private function get_variable_value(token $variable): token {
471
        // The raw name may contain indices, e.g. a[1][2]. We split at the [ and
472
        // take the first chunk as the true variable name. If there are no brackets,
473
        // there will be only one chunk and everything is fine.
474
        $rawname = $variable->value;
2,193✔
475
        $parts = explode('[', $rawname);
2,193✔
476
        $name = array_shift($parts);
2,193✔
477
        if (!array_key_exists($name, $this->variables)) {
2,193✔
478
            $this->die(get_string('error_unknownvarname', 'qtype_formulas', $name), $variable);
272✔
479
        }
480
        $result = $this->variables[$name];
1,989✔
481

482
        // If we access the variable as a whole, we return a new token
483
        // created from the stored value and type.
484
        if (count($parts) === 0) {
1,989✔
485
            $type = $result->type;
1,649✔
486
            // In algebraic mode, an algebraic variable will resolve to a random value
487
            // from its reservoir.
488
            if ($type === variable::ALGEBRAIC) {
1,649✔
489
                if ($this->algebraicmode) {
612✔
490
                    // We re-seed the random generator with a preset value and the CRC32 of the
491
                    // variable's name. The preset will be changed by the calculate_algebraic_expression()
492
                    // function. This makes sure that while evaluating one single expression, we will
493
                    // get the same value for the same variable. Adding the variable name into the seed
494
                    // gives the chance to not have the same value for different variables with the
495
                    // same reservoir, even though this is not guaranteed, especially if the reservoir is
496
                    // small.
497
                    mt_srand($this->seed + crc32($name));
561✔
498

499
                    $randomindex = mt_rand(0, count($result->value) - 1);
561✔
500
                    $randomelement = $result->value[$randomindex];
561✔
501
                    $value = $randomelement->value;
561✔
502
                    $type = $randomelement->type;
561✔
503
                } else {
504
                    // If we are not in algebraic mode, it does not make sense to get the value of an algebraic
505
                    // variable.
506
                    $this->die(get_string('error_cannotusealgebraic', 'qtype_formulas', $name), $variable);
348✔
507
                }
508
            } else {
509
                $value = $result->value;
1,071✔
510
            }
511
            return new token($type, $value, $variable->row, $variable->column);
1,632✔
512
        }
513

514
        // If we do have indices, we access them one by one. The ] at the end of each
515
        // part must be stripped.
516
        foreach ($parts as $part) {
476✔
517
            // Validate the index and, if necessary, convert a negative index to the corresponding
518
            // positive value.
519
            $index = $this->validate_array_or_string_index($result->value, substr($part, 0, -1), $variable);
476✔
520
            $result = $result->value[$index];
340✔
521
        }
522

523
        // When accessing an array, the elements are already stored as tokens, so we return them
524
        // as they are. This allows the receiver to change values inside the array, because
525
        // objects are passed by reference.
526
        // For strings, we must create a new token, because we only get a character.
527
        if (is_string($result)) {
340✔
528
            return new token(token::STRING, $result, $variable->row, $variable->column);
51✔
529
        }
530
        return $result;
289✔
531
    }
532

533
    /**
534
     * Stop evaluating and indicate the human readable position (row/column) where the error occurred.
535
     *
536
     * @param string $message error message
537
     * @throws Exception
538
     */
539
    private function die(string $message, token $offendingtoken) {
540
        throw new Exception($offendingtoken->row . ':' . $offendingtoken->column . ':' . $message);
1,445✔
541
    }
542

543
    /**
544
     * Pop top element from the stack. If the token is a literal (number, string, list etc.), return it
545
     * directly. If it is a variable, resolve it and return its content.
546
     *
547
     * @return token
548
     */
549
    private function pop_real_value(): token {
550
        if (empty($this->stack)) {
7,310✔
551
            throw new Exception(get_string('error_emptystack', 'qtype_formulas'));
17✔
552
        }
553
        $token = array_pop($this->stack);
7,293✔
554
        if ($token->type === token::VARIABLE) {
7,293✔
555
            return $this->get_variable_value($token);
1,887✔
556
        }
557
        return $token;
7,123✔
558
    }
559

560
    /**
561
     * Take an algebraic expression, resolve its variables and calculate its value. For each
562
     * algebraic variable, a random value among its possible values will be taken.
563
     *
564
     * @param string $expression algebraic expression
565
     * @return token
566
     */
567
    public function calculate_algebraic_expression(string $expression): token {
568
        // Parse the expression. It will parsed by the answer parser, i. e. the ^ operator
569
        // will mean exponentiation rather than XOR, as per the documented behaviour.
570
        // As the expression might contain a PREFIX operator (from a model answer), we
571
        // set the fourth parameter of the constructor to TRUE.
572
        // Note that this step will also throw an error, if the expression is empty.
573
        $parser = new answer_parser($expression, $this->export_variable_list(), true, true);
765✔
574
        if (!$parser->is_acceptable_for_answertype(qtype_formulas::ANSWER_TYPE_ALGEBRAIC)) {
765✔
575
            throw new Exception(get_string('error_invalidalgebraic', 'qtype_formulas', $expression));
51✔
576
        }
577

578
        // Setting the evaluator's seed to the current time. If the function is called several
579
        // times in short intervals, we want to make sure the seed still changes.
580
        $lastseed = $this->seed;
714✔
581
        $this->seed = time();
714✔
582
        if ($lastseed >= $this->seed) {
714✔
583
            $this->seed = $lastseed + 1;
629✔
584
            $lastseed = $this->seed;
629✔
585
        }
586

587
        // Now evaluate the expression and return the result. By saving the stack and restoring
588
        // it afterwards, we create an empty substack for this evaluation only.
589
        $this->algebraicmode = true;
714✔
590
        $oldstack = $this->stack;
714✔
591
        $this->clear_stack();
714✔
592
        // Evaluation might fail. In that case, it is important to assure that the old stack
593
        // is re-established and that algebraic mode is turned off.
594
        try {
595
            $result = $this->evaluate($parser->get_statements()[0]);
714✔
596
        } catch (Exception $e) {
17✔
597
            ;
598
        } finally {
599
            $this->stack = $oldstack;
714✔
600
            $this->algebraicmode = false;
714✔
601
            // If we have an exception, we throw it again to pass the error upstream.
602
            if (isset($e)) {
714✔
603
                throw $e;
17✔
604
            }
605
        }
606

607
        return $result;
714✔
608
    }
609

610
    /**
611
     * For a given list of tokens, find the index of the closing bracket that marks the end of
612
     * the index definition, i. e. the part that says what element of the array should be accessed.
613
     *
614
     * @param array $tokens
615
     * @return int
616
     */
617
    private function find_end_of_array_access(array $tokens): int {
618
        $count = count($tokens);
17✔
619

620
        // If we don't have at least four tokens (variable, opening bracket, index, closing bracket)
621
        // or if the first token after the variable name is not an opening bracket, we can return
622
        // immediately.
623
        if ($count < 4 || $tokens[1]->type !== token::OPENING_BRACKET) {
17✔
624
            return 1;
17✔
625
        }
626

627
        for ($i = 1; $i < $count - 1; $i++) {
17✔
628
            $token = $tokens[$i];
17✔
629

630
            // As long as we are not at the closing bracket, we just keep advancing.
631
            if ($token->type !== token::CLOSING_BRACKET) {
17✔
632
                continue;
17✔
633
            }
634
            // We found a closing bracket. Now let's see whether the next token is
635
            // an opening bracket again. If it is, we have to keep searching for the end.
636
            if ($tokens[$i + 1]->type === token::OPENING_BRACKET) {
17✔
637
                continue;
17✔
638
            }
639
            // If it is not, we can return.
640
            return $i + 1;
17✔
641
        }
642

643
        // We have not found the closing bracket, so the end is ... at the end.
644
        return $count;
17✔
645
    }
646

647
    /**
648
     * Takes a string representation of an algebraic formula, e.g. "a*x^2 + b" and
649
     * replaces the non-algebraic variables by their numerical value. Returns the resulting
650
     * string.
651
     *
652
     * @return string
653
     */
654
    public function substitute_variables_in_algebraic_formula(string $formula): string {
655
        // We do not use the answer parser, because we do not actually evaluate the formula,
656
        // and if it is needed for later output (e.g. "the correct answer is ..."), there is
657
        // no need to replace ^ by **.
658
        $parser = new parser($formula, $this->export_variable_list());
17✔
659
        $tokens = $parser->get_tokens();
17✔
660
        $count = count($tokens);
17✔
661

662
        // Will will iterate over all tokens and build an output string bit by bit.
663
        $output = '';
17✔
664
        for ($i = 0; $i < $count; $i++) {
17✔
665
            $token = $tokens[$i];
17✔
666
            // The unary minus must be translated back to '-'.
667
            if ($token->type === token::OPERATOR && $token->value === '_') {
17✔
668
                $output .= '-';
17✔
669
                continue;
17✔
670
            }
671
            // For a nicer output, we add a space before and after the +, -, * and / operator.
672
            if ($token->type === token::OPERATOR && in_array($token->value, ['+', '-', '*', '/'])) {
17✔
673
                $output .= " {$token->value} ";
17✔
674
                continue;
17✔
675
            }
676
            // If the token is not a VARIABLE, it can be shipped out.
677
            if ($tokens[$i]->type !== token::VARIABLE) {
17✔
678
                $output .= $tokens[$i]->value;
17✔
679
                continue;
17✔
680
            }
681

682
            // If we are still here, we have a variable name, possibly followed by an opening bracket.
683
            // As it is not allowed to build lists in an algebraic formula, such a bracket could only
684
            // mean we are accessing an array element. We try to find out whether there is one and,
685
            // if needed, how far that "subexpression" goes.
686
            $numberoftokens = $this->find_end_of_array_access(array_slice($tokens, $i));
17✔
687
            $subexpression = implode('', array_slice($tokens, $i, $numberoftokens));
17✔
688
            $result = $this->substitute_variables_in_text("{=$subexpression}");
17✔
689

690
            // If there was an error, e.g. invalid array index, there will have been no substitution.
691
            // In that case, we only send the variable token to the output and keep on working, because
692
            // there might be nested variables to substitute.
693
            if ($result === "{=$subexpression}") {
17✔
694
                $output .= $token->value;
17✔
695
                continue;
17✔
696
            }
697

698
            // If we are still here, the subexpression has been replaced. We append it to the output
699
            // and remove all tokens until the end of that subexpression from the queue.
700
            $output .= $result;
17✔
701
            array_splice($tokens, $i + 1, $numberoftokens - 1);
17✔
702
            $count = $count - $numberoftokens + 1;
17✔
703
        }
704

705
        return $output;
17✔
706
    }
707

708
    /**
709
     * The diff() function calculates absolute differences between numerical or algebraic
710
     * expressions.
711
     *
712
     * @param array $first first list
713
     * @param array $second second list
714
     * @param int $n number of points where algebraic expressions will be evaluated
715
     * @return array
716
     */
717
    public function diff($first, $second, ?int $n = null) {
718
        // First, we check that $first and $second are lists of the same size.
719
        if (!is_array($first)) {
816✔
720
            throw new Exception(get_string('error_diff_first', 'qtype_formulas'));
17✔
721
        }
722
        if (!is_array($second)) {
799✔
723
            throw new Exception(get_string('error_diff_second', 'qtype_formulas'));
17✔
724
        }
725
        $count = count($first);
782✔
726
        if (count($second) !== $count) {
782✔
727
            throw new Exception(get_string('error_diff_samesize', 'qtype_formulas'));
34✔
728
        }
729

730
        // Now make sure the lists do contain one single data type (only numbers or only strings).
731
        // This is needed for the diff() function, because strings are evaluated as algebraic
732
        // formulas, i. e. in a completely different way. Also, both lists must have the same data
733
        // type.
734
        $type = $first[0]->type;
748✔
735
        if (!in_array($type, [token::NUMBER, token::STRING])) {
748✔
736
            throw new Exception(get_string('error_diff_firstlist_content', 'qtype_formulas'));
17✔
737
        }
738
        for ($i = 0; $i < $count; $i++) {
731✔
739
            if ($first[$i]->type !== $type) {
731✔
740
                throw new Exception(get_string('error_diff_firstlist_mismatch', 'qtype_formulas', $i));
34✔
741
            }
742
            if ($second[$i]->type !== $type) {
731✔
743
                throw new Exception(get_string('error_diff_secondlist_mismatch', 'qtype_formulas', $i));
34✔
744
            }
745
        }
746

747
        // If we are working with numbers, we can directly calculate the differences and return.
748
        if ($type === token::NUMBER) {
663✔
749
            // The user should not specify a third argument when working with numbers.
750
            if ($n !== null) {
34✔
751
                throw new Exception(get_string('error_diff_third', 'qtype_formulas'));
17✔
752
            }
753

754
            $result = [];
17✔
755
            for ($i = 0; $i < $count; $i++) {
17✔
756
                $diff = abs($first[$i]->value - $second[$i]->value);
17✔
757
                $result[$i] = token::wrap($diff);
17✔
758
            }
759
            return $result;
17✔
760
        }
761

762
        // If the user did not specify $n, we set it to 100, for backwards compatibility.
763
        if ($n === null) {
629✔
764
            $n = 100;
17✔
765
        }
766

767
        $result = [];
629✔
768
        // Iterate over all strings and calculate the root mean square difference between the two expressions.
769
        for ($i = 0; $i < $count; $i++) {
629✔
770
            $result[$i] = 0;
629✔
771
            $expression = "({$first[$i]}) - ({$second[$i]})";
629✔
772

773
            // Flag that we will set to TRUE if a difference cannot be evaluated. This
774
            // is to make sure that the difference will be PHP_FLOAT_MAX and not
775
            // sqrt(PHP_FLOAT_MAX) divided by $n.
776
            $cannotevaluate = false;
629✔
777
            for ($j = 0; $j < $n; $j++) {
629✔
778
                try {
779
                    $difference = $this->calculate_algebraic_expression($expression);
629✔
780
                } catch (Exception $e) {
17✔
781
                    // If evaluation failed, there is no need to evaluate any further. Instead,
782
                    // we set the $cannotevaluate flag and will later set the result to
783
                    // PHP_FLOAT_MAX. By choosing PHP_FLOAT_MAX rather than INF, we make sure
784
                    // that the result is still a float.
785
                    $cannotevaluate = true;
17✔
786
                    // Note: index is $i, because every $j step adds to the $i-th difference.
787
                    $result[$i] = PHP_FLOAT_MAX;
17✔
788
                    break;
17✔
789
                }
790
                $result[$i] += $difference->value ** 2;
629✔
791
            }
792
            $result[$i] = token::wrap(sqrt($result[$i] / $n), token::NUMBER);
629✔
793
            if ($cannotevaluate) {
629✔
794
                $result[$i] = token::wrap(PHP_FLOAT_MAX, token::NUMBER);
17✔
795
            }
796
        }
797

798
        return $result;
629✔
799
    }
800

801
    /**
802
     * Evaluate the given thing, e. g. an expression or a for loop.
803
     *
804
     * @param expression|for_loop $input
805
     * @param bool $godmode whether one should be allowed to modify reserved variables like e.g. _a or _0
806
     * @return token|void
807
     */
808
    private function evaluate_the_right_thing($input, bool $godmode = false) {
809
        if ($input instanceof expression) {
7,361✔
810
            return $this->evaluate_single_expression($input, $godmode);
7,327✔
811
        }
812
        if ($input instanceof for_loop) {
391✔
813
            return $this->evaluate_for_loop($input);
374✔
814
        }
815
        throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate_the_right_thing()'));
17✔
816
    }
817

818
    /**
819
     * Evaluate a single expression or an array of expressions.
820
     *
821
     * @param expression|for_loop|array $input
822
     * @param bool $godmode whether to run the evaluation in god mode
823
     * @return token|array
824
     */
825
    public function evaluate($input, bool $godmode = false) {
826
        if (($input instanceof expression) || ($input instanceof for_loop)) {
7,378✔
827
            return $this->evaluate_the_right_thing($input, $godmode);
918✔
828
        }
829
        if (!is_array($input)) {
7,191✔
830
            throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate()'));
17✔
831
        }
832
        $result = [];
7,191✔
833
        foreach ($input as $single) {
7,191✔
834
            $result[] = $this->evaluate_the_right_thing($single, $godmode);
7,174✔
835
        }
836
        return $result;
5,848✔
837
    }
838

839
    /**
840
     * Evaluate a for loop.
841
     *
842
     * @param for_loop $loop
843
     * @return void
844
     */
845
    private function evaluate_for_loop(for_loop $loop) {
846
        $rangetoken = $this->evaluate_single_expression($loop->range);
374✔
847
        $range = $rangetoken->value;
374✔
848
        $result = null;
374✔
849
        foreach ($range as $iterationvalue) {
374✔
850
            $this->set_variable_to_value($loop->variable, $iterationvalue);
374✔
851
            $result = $this->evaluate($loop->body);
374✔
852
        }
853
        $this->clear_stack();
374✔
854
        return end($result);
374✔
855
    }
856

857
    /**
858
     * Evaluate an expression, e. g. an assignment, a function call or a calculation.
859
     *
860
     * @param expression $expression
861
     * @param bool $godmode
862
     * @return token
863
     */
864
    private function evaluate_single_expression(expression $expression, bool $godmode = false): token {
865
        foreach ($expression->body as $token) {
7,361✔
866
            $type = $token->type;
7,361✔
867
            $value = $token->value;
7,361✔
868

869
            $isliteral = ($type & token::ANY_LITERAL);
7,361✔
870
            $isopening = ($type === token::OPENING_BRACE || $type === token::OPENING_BRACKET);
7,361✔
871
            $isvariable = ($type === token::VARIABLE);
7,361✔
872

873
            // Many tokens go directly to the stack.
874
            if ($isliteral || $isopening || $isvariable) {
7,361✔
875
                $this->stack[] = $token;
7,259✔
876
                continue;
7,259✔
877
            }
878

879
            // Constants are resolved and sent to the stack.
880
            if ($type === token::CONSTANT) {
7,191✔
881
                $this->stack[] = $this->resolve_constant($token);
170✔
882
                continue;
153✔
883
            }
884

885
            if ($type === token::OPERATOR) {
7,123✔
886
                if ($this->is_unary_operator($token)) {
6,936✔
887
                    $this->stack[] = $this->execute_unary_operator($token);
833✔
888
                }
889
                // The = operator is binary, but we treat it separately.
890
                if ($value === '=' || $value === 'r=') {
6,919✔
891
                    $israndomvar = ($value === 'r=');
3,859✔
892
                    $this->godmode = $godmode;
3,859✔
893
                    $this->stack[] = $this->execute_assignment($israndomvar);
3,859✔
894
                    $this->godmode = false;
3,706✔
895
                } else if ($this->is_binary_operator($token)) {
6,681✔
896
                    $this->stack[] = $this->execute_binary_operator($token);
3,876✔
897
                }
898
                // The %%ternary-sentinel pseudo-token goes on the stack where it will
899
                // help detect ternary expressions with too few arguments.
900
                if ($value === '%%ternary-sentinel') {
6,477✔
901
                    $this->stack[] = $token;
425✔
902
                }
903
                // When executing the ternary operator, we pass it the operator token
904
                // in order to have best possible error reporting.
905
                if ($value === '%%ternary') {
6,477✔
906
                    $this->stack[] = $this->execute_ternary_operator($token);
442✔
907
                }
908
                if ($value === '%%arrayindex') {
6,477✔
909
                    $this->stack[] = $this->fetch_array_element_or_char();
714✔
910
                }
911
                if ($value === '%%setbuild' || $value === '%%arraybuild') {
6,460✔
912
                    $this->stack[] = $this->build_set_or_array($value);
3,706✔
913
                }
914
                if ($value === '%%rangebuild') {
6,460✔
915
                    $elements = $this->build_range();
1,955✔
916
                    array_push($this->stack, ...$elements);
1,887✔
917
                }
918
            }
919

920
            if ($type === token::FUNCTION) {
6,579✔
921
                $this->stack[] = $this->execute_function($token);
2,006✔
922
            }
923

924
        }
925
        // If the stack contains more than one element, there must have been a problem somewhere.
926
        if (count($this->stack) !== 1) {
6,256✔
927
            throw new Exception(get_string('error_stacksize', 'qtype_formulas'));
17✔
928
        }
929
        // If the stack only contains one single variable token, return its content.
930
        // Otherwise, return the token.
931
        return $this->pop_real_value();
6,239✔
932
    }
933

934
    /**
935
     * Fetch an element from a list or a char from a string. The index and the list or string will
936
     * be taken from the stack.
937
     *
938
     * @return token the desired list element or char
939
     */
940
    private function fetch_array_element_or_char(): token {
941
        $indextoken = $this->pop_real_value();
714✔
942
        $index = $indextoken->value;
714✔
943
        $nexttoken = array_pop($this->stack);
714✔
944

945
        // Make sure there is only one index.
946
        if ($nexttoken->type !== token::OPENING_BRACKET) {
714✔
947
            $this->die(get_string('error_onlyoneindex', 'qtype_formulas'), $indextoken);
51✔
948
        }
949

950
        // Fetch the array or string from the stack.
951
        $arraytoken = array_pop($this->stack);
680✔
952

953
        // If it is a variable, we do lazy evaluation: just append the index and wait. It might be used
954
        // as a left-hand side in an assignment. If it is not, it will be resolved later. Also, if
955
        // the index is invalid, that will lead to an error later on.
956
        if ($arraytoken->type === token::VARIABLE) {
680✔
957
            $name = $arraytoken->value . "[$index]";
510✔
958
            return new token(token::VARIABLE, $name, $arraytoken->row, $arraytoken->column);
510✔
959
        }
960

961
        // Before accessing the array or string, we validate the index and, if necessary,
962
        // we translate a negative index to the corresponding positive value.
963
        $array = $arraytoken->value;
204✔
964
        $index = $this->validate_array_or_string_index($array, $index, $nexttoken);
204✔
965
        $element = $array[$index];
170✔
966

967
        // If we are accessing a string's char, we create a new string token.
968
        if ($arraytoken->type === token::STRING) {
170✔
969
            return new token(token::STRING, $element, $arraytoken->row, $arraytoken->column + $index);
17✔
970
        }
971
        // Otherwise, the element is already wrapped in a token.
972
        return $element;
153✔
973
    }
974

975
    /**
976
     * Build a list of (NUMBER) tokens based on a range definition. The lower and upper limit
977
     * and, if present, the step will be taken from the stack.
978
     *
979
     * @return array
980
     */
981
    private function build_range(): array {
982
        // Pop the number of parts. We generated it ourselves, so we know it will be 2 or 3.
983
        $parts = array_pop($this->stack)->value;
1,955✔
984

985
        $step = 1;
1,955✔
986
        // If we have 3 parts, extract the step size. Conserve the token in case of an error.
987
        if ($parts === 3) {
1,955✔
988
            $steptoken = $this->pop_real_value();
391✔
989
            // Abort with nice error message, if step is not numeric.
990
            $this->abort_if_not_scalar($steptoken);
391✔
991
            $step = $steptoken->value;
391✔
992
        }
993

994
        // Step must not be zero.
995
        if ($step == 0) {
1,955✔
996
            $this->die(get_string('error_stepzero', 'qtype_formulas'), $steptoken);
17✔
997
        }
998

999
        // Fetch start and end of the range. Conserve token for the end value, in case of an error.
1000
        $endtoken = $this->pop_real_value();
1,938✔
1001
        $end = $endtoken->value;
1,938✔
1002
        $starttoken = $this->pop_real_value();
1,938✔
1003
        $start = $starttoken->value;
1,938✔
1004

1005
        // Abort with nice error message, if start or end is not numeric.
1006
        $this->abort_if_not_scalar($starttoken);
1,938✔
1007
        $this->abort_if_not_scalar($endtoken);
1,938✔
1008

1009
        if ($start === $end) {
1,938✔
1010
            $this->die(get_string('error_samestartend', 'qtype_formulas'), $endtoken);
34✔
1011
        }
1012

1013
        if (($end - $start) * $step < 0) {
1,904✔
1014
            if ($parts === 3) {
34✔
1015
                $a = (object)['start' => $start, 'end' => $end, 'step' => $step];
17✔
1016
                $this->die(get_string('error_emptyrange', 'qtype_formulas', $a), $steptoken);
17✔
1017
            }
1018
            $step = -$step;
17✔
1019
        }
1020

1021
        $result = [];
1,887✔
1022
        $numofsteps = ($end - $start) / $step;
1,887✔
1023
        // Choosing multiplication of step instead of repeated addition for better numerical accuracy.
1024
        for ($i = 0; $i < $numofsteps; $i++) {
1,887✔
1025
            $result[] = new token(token::NUMBER, $start + $i * $step);
1,887✔
1026
        }
1027
        return $result;
1,887✔
1028
    }
1029

1030
    /**
1031
     * Create a SET or LIST token based on elements on the stack.
1032
     *
1033
     * @param string $type whether to build a SET or a LIST
1034
     * @return token
1035
     */
1036
    private function build_set_or_array(string $type): token {
1037
        if ($type === '%%setbuild') {
3,706✔
1038
            $delimitertype = token::OPENING_BRACE;
1,734✔
1039
            $outputtype = token::SET;
1,734✔
1040
        } else {
1041
            $delimitertype = token::OPENING_BRACKET;
2,771✔
1042
            $outputtype = token::LIST;
2,771✔
1043
        }
1044
        $elements = [];
3,706✔
1045
        $head = end($this->stack);
3,706✔
1046
        while ($head !== false) {
3,706✔
1047
            if ($head->type === $delimitertype) {
3,706✔
1048
                array_pop($this->stack);
3,706✔
1049
                break;
3,706✔
1050
            }
1051
            $elements[] = $this->pop_real_value();
3,655✔
1052
            $head = end($this->stack);
3,655✔
1053
        }
1054
        // Return reversed list, because the stack ist LIFO.
1055
        return new token($outputtype, array_reverse($elements));
3,706✔
1056
    }
1057

1058
    /**
1059
     * Whether a given OPERATOR token is an unary operator.
1060
     *
1061
     * @param token $token
1062
     * @return bool
1063
     */
1064
    private function is_unary_operator(token $token): bool {
1065
        return in_array($token->value, ['_', '!', '~']);
6,936✔
1066
    }
1067

1068
    /**
1069
     * Whether a given OPERATOR token expects its argument(s) to be numbers.
1070
     *
1071
     * @param token $token
1072
     * @return bool
1073
     */
1074
    private function needs_numeric_input(token $token): bool {
1075
        $operators = ['_', '~', '**', '*', '/', '%', '-', '<<', '>>', '&', '^', '|', '&&', '||'];
4,029✔
1076
        return in_array($token->value, $operators);
4,029✔
1077
    }
1078

1079
    /**
1080
     * In many cases, operators need a numeric or at least a scalar operand to work properly.
1081
     * This function does the necessary check and prepares a human-friendly error message
1082
     * if the conditions are not met.
1083
     *
1084
     * @param token $token the token to check
1085
     * @param boolean $enforcenumeric whether the value must be numeric in addition to being scalar
1086
     * @return void
1087
     * @throws Exception
1088
     */
1089
    private function abort_if_not_scalar(token $token, bool $enforcenumeric = true): void {
1090
        $found = '';
4,488✔
1091
        $a = (object)[];
4,488✔
1092
        if ($token->type !== token::NUMBER) {
4,488✔
1093
            if ($token->type === token::SET) {
153✔
1094
                $found = '_algebraicvar';
17✔
1095
                $value = "algebraic variable";
17✔
1096
            } else if ($token->type === token::LIST) {
136✔
1097
                $found = '_list';
51✔
1098
                $value = "list";
51✔
1099
            } else if ($enforcenumeric) {
102✔
1100
                $a->found = "'{$token->value}'";
34✔
1101
            } else if ($token->type === token::STRING) {
68✔
1102
                return;
68✔
1103
            }
1104
            $expected = ($enforcenumeric ? 'number' : 'scalar');
102✔
1105

1106
            $this->die(get_string("error_expected_{$expected}_found{$found}", 'qtype_formulas', $a), $token);
102✔
1107
        }
1108
    }
1109

1110
    /**
1111
     * Whether a given OPERATOR token is a binary operator.
1112
     *
1113
     * @param token $token
1114
     * @return bool
1115
     */
1116
    private function is_binary_operator(token $token): bool {
1117
        $binaryoperators = ['=', '**', '*', '/', '%', '+', '-', '<<', '>>', '&', '^',
6,681✔
1118
            '|', '&&', '||', '<', '>', '==', '>=', '<=', '!='];
6,681✔
1119

1120
        return in_array($token->value, $binaryoperators);
6,681✔
1121
    }
1122

1123
    /**
1124
     * Assign a value to a variable. The value and the variable name are taken from the stack.
1125
     *
1126
     * @param boolean $israndomvar
1127
     * @return token the assigned value
1128
     */
1129
    private function execute_assignment($israndomvar = false): token {
1130
        $what = $this->pop_real_value();
3,859✔
1131
        $destination = array_pop($this->stack);
3,859✔
1132

1133
        // When storing a value in a variable, the row and column should be
1134
        // set to the row and column of the variable token.
1135
        $what->row = $destination->row;
3,859✔
1136
        $what->column = $destination->column;
3,859✔
1137

1138
        // The destination must be a variable token.
1139
        if ($destination->type !== token::VARIABLE) {
3,859✔
1140
            $this->die(get_string('error_variablelhs', 'qtype_formulas'), $destination);
34✔
1141
        }
1142
        return $this->set_variable_to_value($destination, $what, $israndomvar);
3,825✔
1143
    }
1144

1145
    /**
1146
     * Evaluate a ternary expression, taking the arguments from the stack.
1147
     *
1148
     * @param token $optoken token that led to this function being called, for better error reporting
1149
     * @return token evaluation result
1150
     */
1151
    private function execute_ternary_operator(token $optoken) {
1152
        // For good error reporting, we first check, whether there are enough arguments on
1153
        // the stack. We subtract one, because there is a sentinel token.
1154
        if (count($this->stack) - 1 < 3) {
442✔
1155
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $optoken);
34✔
1156
        }
1157
        $else = array_pop($this->stack);
425✔
1158
        $then = array_pop($this->stack);
425✔
1159
        // The user might not have provided enough arguments for the ternary operator (missing 'else'
1160
        // part), but there might be other elements on the stack from earlier operations (or a LHS variable
1161
        // for an upcoming assignment). In that case, the intended 'then' token has been popped as
1162
        // the 'else' part and we have now read the '%%ternary-sentinel' pseudo-token.
1163
        if ($then->type === token::OPERATOR && $then->value === '%%ternary-sentinel') {
425✔
1164
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $then);
17✔
1165
        }
1166
        // If everything is OK, we should now arrive at the '%%ternary-sentinel' pseudo-token. Let's see...
1167
        $pseudotoken = array_pop($this->stack);
408✔
1168
        if ($pseudotoken->type !== token::OPERATOR && $pseudotoken->value !== '%%ternary-sentinel') {
408✔
1169
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $then);
17✔
1170
        }
1171

1172
        $condition = $this->pop_real_value();
391✔
1173
        return ($condition->value ? $then : $else);
391✔
1174
    }
1175

1176
    /**
1177
     * Apply an unary operator to the token that is currently on top of the stack.
1178
     *
1179
     * @param token $token operator token
1180
     * @return token result
1181
     */
1182
    private function execute_unary_operator($token) {
1183
        $input = $this->pop_real_value();
833✔
1184

1185
        // Check if the input is numeric. Boolean values are internally treated as 1 and 0 for
1186
        // backwards compatibility.
1187
        if ($this->needs_numeric_input($token)) {
833✔
1188
            $this->abort_if_not_scalar($input);
799✔
1189
        }
1190

1191
        $result = functions::apply_unary_operator($token->value, $input->value);
799✔
1192
        return token::wrap($result);
799✔
1193
    }
1194

1195
    /**
1196
     * Apply a binary operator to the two elements currently on top of the stack.
1197
     *
1198
     * @param token $optoken operator token
1199
     * @return token result
1200
     */
1201
    private function execute_binary_operator($optoken) {
1202
        // The stack is LIFO, so we pop the second operand first.
1203
        $secondtoken = $this->pop_real_value();
3,876✔
1204
        $firsttoken = $this->pop_real_value();
3,655✔
1205

1206
        // Abort with nice error message, if arguments should be numeric but are not.
1207
        if ($this->needs_numeric_input($optoken)) {
3,655✔
1208
            $this->abort_if_not_scalar($firsttoken);
2,635✔
1209
            $this->abort_if_not_scalar($secondtoken);
2,601✔
1210
        }
1211

1212
        $first = $firsttoken->value;
3,621✔
1213
        $second = $secondtoken->value;
3,621✔
1214

1215
        // For + (string concatenation or addition) we check the arguments here, even if another
1216
        // check is done in functions::apply_binary_operator(), because this allows for better
1217
        // error reporting.
1218
        if ($optoken->value === '+') {
3,621✔
1219
            // If at least one operand is a string, both values must be scalar, but
1220
            // not necessarily numeric; we use concatenation instead of addition.
1221
            // In all other cases, addition must (currently) be numeric, so we abort
1222
            // if the arguments are not numbers.
1223
            $acceptstring = is_string($first) || is_string($second);
1,802✔
1224
            $this->abort_if_not_scalar($firsttoken, !$acceptstring);
1,802✔
1225
            $this->abort_if_not_scalar($secondtoken, !$acceptstring);
1,785✔
1226
        }
1227

1228
        try {
1229
            $result = functions::apply_binary_operator($optoken->value, $first, $second);
3,587✔
1230
        } catch (Exception $e) {
272✔
1231
            $this->die($e->getMessage(), $optoken);
272✔
1232
        }
1233
        return token::wrap($result);
3,315✔
1234
    }
1235

1236
    /**
1237
     * Check whether the number of parameters is valid for a given function.
1238
     *
1239
     * @param token $function FUNCTION token containing the function name
1240
     * @param int $count number of arguments
1241
     * @return bool
1242
     */
1243
    private function is_valid_num_of_params(token $function, int $count): bool {
1244
        $funcname = $function->value;
2,006✔
1245
        $min = INF;
2,006✔
1246
        $max = -INF;
2,006✔
1247
        // Union gives precedence to first array, so we are able to override a
1248
        // built-in function.
1249
        $allfunctions = functions::FUNCTIONS + self::PHPFUNCTIONS;
2,006✔
1250
        if (array_key_exists($funcname, $allfunctions)) {
2,006✔
1251
            $min = $allfunctions[$funcname][0];
1,989✔
1252
            $max = $allfunctions[$funcname][1];
1,989✔
1253
            return $count >= $min && $count <= $max;
1,989✔
1254
        }
1255
        // Still here? That means the function is unknown.
1256
        $this->die(get_string('error_unknownfunction', 'qtype_formulas', $funcname), $function);
17✔
1257
    }
1258

1259
    /**
1260
     * Lookup the value of a constant and return its value.
1261
     *
1262
     * @param token $token CONSTANT token containing the constant's name
1263
     * @return token value of the requested constant
1264
     */
1265
    private function resolve_constant($token): token {
1266
        if (array_key_exists($token->value, $this->constants)) {
170✔
1267
            return new token(token::NUMBER, $this->constants[$token->value], $token->row, $token->column);
153✔
1268
        }
1269
        $this->die(get_string('error_undefinedconstant', 'qtype_formulas', $token->value), $token);
17✔
1270
    }
1271

1272
    /**
1273
     * Execute a given function, taking the needed argument(s) from the stack.
1274
     *
1275
     * @param token $token FUNCTION token containing the function's name.
1276
     * @return token result
1277
     */
1278
    private function execute_function(token $token): token {
1279
        $funcname = $token->value;
2,006✔
1280

1281
        // Fetch the number of params from the stack. Keep the token in case of an error.
1282
        $numparamstoken = array_pop($this->stack);
2,006✔
1283
        $numparams = $numparamstoken->value;
2,006✔
1284

1285
        // Check if the number of params is valid for the given function. If it is not,
1286
        // die with an error message.
1287
        if (!$this->is_valid_num_of_params($token, $numparams)) {
2,006✔
NEW
1288
            $a = (object)['function' => $funcname, 'count' => $numparams];
×
NEW
1289
            $this->die(get_string('error_func_argcount', 'qtype_formulas', $a), $token);
×
1290
        }
1291

1292
        // Fetch the params from the stack and reverse their order, because the stack is LIFO.
1293
        $params = [];
1,989✔
1294
        for ($i = 0; $i < $numparams; $i++) {
1,989✔
1295
            $params[] = $this->pop_real_value()->value;
1,989✔
1296
        }
1297
        $params = array_reverse($params);
1,989✔
1298

1299
        // If something goes wrong, e. g. wrong type of parameter, functions will throw a TypeError (built-in)
1300
        // or an Exception (custom functions). We catch the exception and build a nice error message.
1301
        try {
1302
            // If we have our own implementation, execute that one. Otherwise, use PHP's built-in function.
1303
            // The special function diff() is defined in the evaluator, so it needs special treatment.
1304
            $isown = array_key_exists($funcname, functions::FUNCTIONS);
1,989✔
1305
            $prefix = '';
1,989✔
1306
            if ($funcname === 'diff') {
1,989✔
1307
                $prefix = self::class . '::';
816✔
1308
            } else if ($isown) {
1,394✔
1309
                $prefix = functions::class . '::';
850✔
1310
            }
1311
            $result = call_user_func_array($prefix . $funcname, $params);
1,989✔
1312
            // Our own funtions should deal with all sorts of errors and invalid arguments. However,
1313
            // the PHP built-in functions will sometimes return NAN or ±INF, e.g. for sqrt(-2) or log(0).
1314
            // We will check for those return values and output a special error message.
1315
            // Note that for PHP the values NAN, INF and -INF are all numeric, but not finite.
1316
            if (is_numeric($result) && !is_finite($result)) {
1,751✔
1317
                throw new Exception(get_string('error_func_nan', 'qtype_formulas', $funcname));
1,751✔
1318
            }
1319
        } catch (Throwable $e) {
255✔
1320
            $this->die($e->getMessage(), $token);
255✔
1321
        }
1322

1323
        // Some of our own functions may return a token. In those cases, we reset
1324
        // the row and column value, because they are no longer accurate. Once that
1325
        // is done, we return the token.
1326
        if ($result instanceof token) {
1,751✔
NEW
1327
            $result->row = -1;
×
NEW
1328
            $result->column = -1;
×
NEW
1329
            return $result;
×
1330
        }
1331

1332
        // Most of the time, the return value will not be a token. In those cases,
1333
        // we have to wrap it up before returning.
1334
        return token::wrap($result);
1,751✔
1335
    }
1336
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc