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

FormulasQuestion / moodle-qtype_formulas / 15537037899

09 Jun 2025 02:31PM UTC coverage: 97.371% (-0.06%) from 97.435%
15537037899

Pull #228

github

web-flow
Merge b377a6497 into 3c12eaad6
Pull Request #228: Use localised numbers

16 of 19 new or added lines in 5 files covered. (84.21%)

1 existing line in 1 file now uncovered.

4037 of 4146 relevant lines covered (97.37%)

1580.36 hits per line

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

99.78
/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);
24,276✔
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*';
42✔
126
        $arraypattern = '[_A-Za-z]\w*(\[\d+\])+';
42✔
127
        $expressionpattern = '=[^}]+';
42✔
128

129
        $matches = [];
42✔
130
        preg_match_all("/\{($varpattern|$arraypattern|$expressionpattern)\}/", $text, $matches);
42✔
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]);
42✔
135

136
        foreach ($matches as $match) {
42✔
137
            $input = $match;
42✔
138
            // For expressions, we have to remove the = sign.
139
            if ($input[0] === '=') {
42✔
140
                $input = substr($input, 1);
42✔
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);
42✔
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, '=')) {
42✔
151
                    continue;
21✔
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());
42✔
157
                $result = end($results);
42✔
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])) {
42✔
160
                    continue;
21✔
161
                }
162
                // If the result is a number, we try to localize it, unless the admin settings do not
163
                // allow the decimal comma.
164
                if ($result->type === token::NUMBER && get_config('qtype_formulas', 'allowdecimalcomma')) {
42✔
NEW
UNCOV
165
                    $result = format_float($result->value, -1);
×
166
                }
167

168
                $text = str_replace("{{$match}}", strval($result), $text);
42✔
169
            } catch (Exception $e) {
42✔
170
                // TODO: use non-capturing exception when we drop support for old PHP.
171
                unset($e);
42✔
172
            }
173
        }
174

175
        return $text;
42✔
176
    }
177

178
    /**
179
     * Remove the special variables like _a or _0, _1, ... from the evaluator.
180
     *
181
     * @return void
182
     */
183
    public function remove_special_vars(): void {
184
        foreach ($this->variables as $name => $variable) {
21✔
185
            $isreserved = in_array($name, ['_err', '_relerr', '_a', '_r', '_d', '_u']);
21✔
186
            $isanswer = preg_match('/^_\d+$/', $name);
21✔
187

188
            if ($isreserved || $isanswer) {
21✔
189
                unset($this->variables[$name]);
21✔
190
            }
191
        }
192
    }
193

194
    /**
195
     * Reinitialize the evaluator by clearing the stack and, if requested, setting the
196
     * variables and random variables to a certain state.
197
     *
198
     * @param array $context associative array containing the random and normal variables
199
     * @return void
200
     */
201
    public function reinitialize(array $context = []): void {
202
        $this->clear_stack();
24,276✔
203

204
        // If a context is given, we initialize our variables accordingly.
205
        if (key_exists('randomvariables', $context) && key_exists('variables', $context)) {
24,276✔
206
            $this->import_variable_context($context);
21✔
207
        }
208
    }
209

210
    /**
211
     * Clear the stack.
212
     *
213
     * @return void
214
     */
215
    public function clear_stack(): void {
216
        $this->stack = [];
24,276✔
217
    }
218

219
    /**
220
     * Export all random variables and variables. The function returns an associative array
221
     * with the keys 'randomvariables' and 'variables'. Each key will hold the serialized
222
     * string of the corresponding variables.
223
     *
224
     * @return array
225
     */
226
    public function export_variable_context(): array {
227
        return [
21✔
228
            'randomvariables' => serialize($this->randomvariables),
21✔
229
            'variables' => serialize($this->variables),
21✔
230
        ];
21✔
231
    }
232

233
    /**
234
     * Build a string that can be used to redefine the instantiated random variables with
235
     * the same values, but as global values. This is how Formulas question prior to version 6.x
236
     * used to store their state. We implement this for maximum backwards compatibility, i. e.
237
     * in order to allow switching back to a 5.x version.
238
     *
239
     * @return string
240
     */
241
    public function export_randomvars_for_step_data(): string {
242
        $result = '';
441✔
243
        foreach ($this->randomvariables as $var) {
441✔
244
            $result .= $var->get_instantiated_definition();
420✔
245
        }
246
        return $result;
441✔
247
    }
248

249
    /**
250
     * Export the names of all known variables. This can be used to pass to a new parser,
251
     * in order to help it classify identifiers as functions or variables.
252
     *
253
     * @return array
254
     */
255
    public function export_variable_list(): array {
256
        return array_keys($this->variables);
3,423✔
257
    }
258

259
    /**
260
     * Export the variable with the given name. Depending on the second parameter, the function
261
     * returns a token (the variable's content) or a variable (the variable's actual definition).
262
     *
263
     * @param string $varname name of the variable
264
     * @param bool $exportasvariable whether to export as an instance of variable, otherwise just export the content
265
     * @return token|variable
266
     */
267
    public function export_single_variable(string $varname, bool $exportasvariable = false) {
268
        if ($exportasvariable) {
3,465✔
269
            return $this->variables[$varname];
3,150✔
270
        }
271
        $result = $this->get_variable_value(token::wrap($varname));
315✔
272
        return $result;
315✔
273
    }
274

275
    /**
276
     * Calculate the number of possible variants according to the defined random variables.
277
     *
278
     * @return int
279
     */
280
    public function get_number_of_variants(): int {
281
        $result = 1;
273✔
282
        foreach ($this->randomvariables as $var) {
273✔
283
            $num = $var->how_many();
273✔
284
            if ($num > PHP_INT_MAX / $result) {
273✔
285
                return PHP_INT_MAX;
21✔
286
            }
287
            $result = $result * $num;
273✔
288
        }
289
        return $result;
252✔
290
    }
291

292
    /**
293
     * Instantiate random variables, i. e. assigning a fixed value to them and make them available
294
     * as regular global variables.
295
     *
296
     * @param int|null $seed initialization seed for the PRNG
297
     * @return void
298
     */
299
    public function instantiate_random_variables(?int $seed = null): void {
300
        if (isset($seed)) {
735✔
301
            mt_srand($seed);
21✔
302
        }
303
        foreach ($this->randomvariables as $var) {
735✔
304
            $value = $var->instantiate();
714✔
305
            $this->set_variable_to_value(token::wrap($var->name, token::VARIABLE), $value);
714✔
306
        }
307
    }
308

309
    /**
310
     * Import an existing variable context, e.g. from another evaluator class.
311
     * If the same variable exists in our context and the incoming context, the
312
     * incoming context will overwrite our data. This can be avoided by setting
313
     * the optional parameter to false.
314
     *
315
     * @param array $data serialized context for randomvariables and variables
316
     * @param bool $overwrite whether to overwrite existing data with incoming context
317
     * @return void
318
     */
319
    public function import_variable_context(array $data, bool $overwrite = true) {
320
        // If the data is invalid, unserialize() will issue an E_NOTICE. We suppress that,
321
        // because we have our own error message.
322
        $randomvariables = @unserialize($data['randomvariables'], ['allowed_classes' => [random_variable::class, token::class]]);
21✔
323
        $variables = @unserialize($data['variables'], ['allowed_classes' => [variable::class, token::class]]);
21✔
324
        if ($randomvariables === false || $variables === false) {
21✔
325
            throw new Exception(get_string('error_invalidcontext', 'qtype_formulas'));
21✔
326
        }
327
        foreach ($variables as $name => $var) {
21✔
328
            // New variables are added.
329
            // Existing variables are only overwritten, if $overwrite is true.
330
            $notknownyet = !array_key_exists($name, $this->variables);
21✔
331
            if ($notknownyet || $overwrite) {
21✔
332
                $this->variables[$name] = $var;
21✔
333
            }
334
        }
335
        foreach ($randomvariables as $name => $var) {
21✔
336
            // New variables are added.
337
            // Existing variables are only overwritten, if $overwrite is true.
338
            $notknownyet = !array_key_exists($name, $this->randomvariables);
21✔
339
            if ($notknownyet || $overwrite) {
21✔
340
                $this->randomvariables[$name] = $var;
21✔
341
            }
342
        }
343
    }
344

345
    /**
346
     * Set the variable defined in $token to the value $value and correctly set
347
     * it's $type attribute.
348
     *
349
     * @param token $vartoken
350
     * @param token $value
351
     * @param bool $israndomvar
352
     * @return token
353
     */
354
    private function set_variable_to_value(token $vartoken, token $value, $israndomvar = false): token {
355
        // Get the "basename" of the variable, e.g. foo in case of foo[1][2].
356
        $basename = $vartoken->value;
8,169✔
357
        if (strpos($basename, '[') !== false) {
8,169✔
358
            $basename = strstr($basename, '[', true);
231✔
359
        }
360

361
        // Some variables are reserved and cannot be used as left-hand side in an assignment,
362
        // unless the evaluator is currently in god mode.
363
        // Note that _m is not a reserved name in itself, but the placeholder {_m} is accepted
364
        // by the renderer to mark the position of the feedback image. Allowing that variable
365
        // could lead to conflicts, so we do not allow it.
366
        $isreserved = in_array($basename, ['_err', '_relerr', '_a', '_r', '_d', '_u', '_m']);
8,169✔
367
        $isanswer = preg_match('/^_\d+$/', $basename);
8,169✔
368
        // We will -- at least for the moment -- block all variables starting with an underscore,
369
        // because we might one day need some internal variables or the like.
370
        $underscore = strpos($basename, '_') === 0;
8,169✔
371
        if ($underscore && $this->godmode === false) {
8,169✔
372
            $this->die(get_string('error_invalidvarname', 'qtype_formulas', $basename), $value);
21✔
373
        }
374

375
        // If there are no indices, we set the variable as requested.
376
        if ($basename === $vartoken->value) {
8,148✔
377
            // If we are assigning to a random variable, we create a new instance and
378
            // return the value of the first instantiation.
379
            if ($israndomvar) {
8,127✔
380
                $useshuffle = $value->type === variable::LIST;
777✔
381
                if (is_scalar($value->value)) {
777✔
382
                    $this->die(get_string('error_invalidrandvardef', 'qtype_formulas'), $value);
21✔
383
                }
384
                $randomvar = new random_variable($basename, $value->value, $useshuffle);
756✔
385
                $this->randomvariables[$basename] = $randomvar;
756✔
386
                return token::wrap($randomvar->reservoir);
756✔
387
            }
388

389
            // Otherwise we return the stored value. If the data is a SET, the variable is an
390
            // algebraic variable.
391
            if ($value->type === token::SET) {
8,085✔
392
                // Algebraic variables only accept a list of numbers; they must not contain
393
                // strings or nested lists.
394
                foreach ($value->value as $entry) {
1,848✔
395
                    if ($entry->type != token::NUMBER) {
1,848✔
396
                        $this->die(get_string('error_algvar_numbers', 'qtype_formulas'), $value);
105✔
397
                    }
398
                }
399

400
                $value->type = variable::ALGEBRAIC;
1,743✔
401
            }
402
            $var = new variable($basename, $value->value, $value->type, microtime(true));
7,980✔
403
            $this->variables[$basename] = $var;
7,980✔
404
            return token::wrap($var->value);
7,980✔
405
        }
406

407
        // If there is an index and we are setting a random variable, we throw an error.
408
        if ($israndomvar) {
231✔
409
            $this->die(get_string('error_setindividual_randvar', 'qtype_formulas'), $value);
21✔
410
        }
411

412
        // If there is an index, but the variable is a string, we throw an error. Setting
413
        // characters of a string in this way is not allowed.
414
        if ($this->variables[$basename]->type === variable::STRING) {
210✔
415
            $this->die(get_string('error_setindividual_string', 'qtype_formulas'), $value);
21✔
416
        }
417

418
        // Otherwise, we try to get the variable's value. The function will
419
        // - resolve indices correctly
420
        // - throw an error, if the variable does not exist
421
        // so we can just rely on that.
422
        $current = $this->get_variable_value($vartoken);
189✔
423

424
        // Array elements are stored as tokens rather than just values (because
425
        // each element can have a different type). That means, we received an
426
        // object or rather a reference to an object. Thus, if we change the value and
427
        // type attribute of that token object, it will automatically be changed
428
        // inside the array itself.
429
        $current->value = $value->value;
189✔
430
        $current->type = $value->type;
189✔
431
        // Update timestamp for the base variable.
432
        $this->variables[$basename]->timestamp = microtime(true);
189✔
433

434
        // Finally, we return what has been stored.
435
        return $current;
189✔
436
    }
437

438
    /**
439
     * Make sure the index is valid, i. e. an integer (as a number or string) and not out
440
     * of range. If needed, translate a negative index (count from end) to a 0-indexed value.
441
     *
442
     * @param mixed $arrayorstring array or string that should be indexed
443
     * @param mixed $index the index
444
     * @param ?token $anchor anchor token used in case of error (may be the array or the index)
445
     * @return int
446
     */
447
    private function validate_array_or_string_index($arrayorstring, $index, ?token $anchor = null): int {
448
        // Check if the index is a number. If it is not, try to convert it.
449
        // If conversion fails, throw an error.
450
        if (!is_numeric($index)) {
819✔
451
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
42✔
452
        }
453
        $index = floatval($index);
777✔
454

455
        // If the index is not a whole number, throw an error. A whole number in float
456
        // representation is fine, though.
457
        if ($index - intval($index) != 0) {
777✔
458
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
42✔
459
        }
460
        $index = intval($index);
735✔
461

462
        // Fetch the length of the array or string.
463
        if (is_string($arrayorstring)) {
735✔
464
            $len = strlen($arrayorstring);
126✔
465
        } else if (is_array($arrayorstring)) {
609✔
466
            $len = count($arrayorstring);
567✔
467
        } else {
468
            $this->die(get_string('error_notindexable', 'qtype_formulas'), $anchor);
42✔
469
        }
470

471
        // Negative indices can be used to count "from the end". For strings, this is
472
        // directly supported in PHP, but not for arrays. So for the sake of simplicity,
473
        // we do our own preprocessing.
474
        if ($index < 0) {
693✔
475
            $index = $index + $len;
147✔
476
        }
477
        // Now check if the index is out of range. We use the original value from the token.
478
        if ($index > $len - 1 || $index < 0) {
693✔
479
            $this->die(get_string('error_indexoutofrange', 'qtype_formulas', $index), $anchor);
126✔
480
        }
481

482
        return $index;
609✔
483
    }
484

485
    /**
486
     * Get the value token that is stored in a variable. If the token is a literal
487
     * (number, string, array, set), just return the value directly.
488
     *
489
     * @param token $variable
490
     * @return token
491
     */
492
    private function get_variable_value(token $variable): token {
493
        // The raw name may contain indices, e.g. a[1][2]. We split at the [ and
494
        // take the first chunk as the true variable name. If there are no brackets,
495
        // there will be only one chunk and everything is fine.
496
        $rawname = $variable->value;
2,772✔
497
        $parts = explode('[', $rawname);
2,772✔
498
        $name = array_shift($parts);
2,772✔
499
        if (!array_key_exists($name, $this->variables)) {
2,772✔
500
            $this->die(get_string('error_unknownvarname', 'qtype_formulas', $name), $variable);
336✔
501
        }
502
        $result = $this->variables[$name];
2,520✔
503

504
        // If we access the variable as a whole, we return a new token
505
        // created from the stored value and type.
506
        if (count($parts) === 0) {
2,520✔
507
            $type = $result->type;
2,100✔
508
            // In algebraic mode, an algebraic variable will resolve to a random value
509
            // from its reservoir.
510
            if ($type === variable::ALGEBRAIC) {
2,100✔
511
                if ($this->algebraicmode) {
777✔
512
                    // We re-seed the random generator with a preset value and the CRC32 of the
513
                    // variable's name. The preset will be changed by the calculate_algebraic_expression()
514
                    // function. This makes sure that while evaluating one single expression, we will
515
                    // get the same value for the same variable. Adding the variable name into the seed
516
                    // gives the chance to not have the same value for different variables with the
517
                    // same reservoir, even though this is not guaranteed, especially if the reservoir is
518
                    // small.
519
                    mt_srand($this->seed + crc32($name));
693✔
520

521
                    $randomindex = mt_rand(0, count($result->value) - 1);
693✔
522
                    $randomelement = $result->value[$randomindex];
693✔
523
                    $value = $randomelement->value;
693✔
524
                    $type = $randomelement->type;
693✔
525
                } else {
526
                    // If we are not in algebraic mode, it does not make sense to get the value of an algebraic
527
                    // variable.
528
                    $this->die(get_string('error_cannotusealgebraic', 'qtype_formulas', $name), $variable);
381✔
529
                }
530
            } else {
531
                $value = $result->value;
1,365✔
532
            }
533
            return new token($type, $value, $variable->row, $variable->column);
2,058✔
534
        }
535

536
        // If we do have indices, we access them one by one. The ] at the end of each
537
        // part must be stripped.
538
        foreach ($parts as $part) {
588✔
539
            // Validate the index and, if necessary, convert a negative index to the corresponding
540
            // positive value.
541
            $index = $this->validate_array_or_string_index($result->value, substr($part, 0, -1), $variable);
588✔
542
            $result = $result->value[$index];
420✔
543
        }
544

545
        // When accessing an array, the elements are already stored as tokens, so we return them
546
        // as they are. This allows the receiver to change values inside the array, because
547
        // objects are passed by reference.
548
        // For strings, we must create a new token, because we only get a character.
549
        if (is_string($result)) {
420✔
550
            return new token(token::STRING, $result, $variable->row, $variable->column);
63✔
551
        }
552
        return $result;
357✔
553
    }
554

555
    /**
556
     * Stop evaluating and indicate the human readable position (row/column) where the error occurred.
557
     *
558
     * @param string $message error message
559
     * @param token $offendingtoken the token where the error occurred
560
     * @throws Exception
561
     */
562
    private function die(string $message, token $offendingtoken) {
563
        throw new Exception($offendingtoken->row . ':' . $offendingtoken->column . ':' . $message);
6,594✔
564
    }
565

566
    /**
567
     * Pop top element from the stack. If the token is a literal (number, string, list etc.), return it
568
     * directly. If it is a variable, resolve it and return its content.
569
     *
570
     * @return token
571
     */
572
    private function pop_real_value(): token {
573
        if (empty($this->stack)) {
21,378✔
574
            throw new Exception(get_string('error_emptystack', 'qtype_formulas'));
21✔
575
        }
576
        $token = array_pop($this->stack);
21,357✔
577
        if ($token->type === token::VARIABLE) {
21,357✔
578
            return $this->get_variable_value($token);
2,394✔
579
        }
580
        return $token;
21,147✔
581
    }
582

583
    /**
584
     * Take an algebraic expression, resolve its variables and calculate its value. For each
585
     * algebraic variable, a random value among its possible values will be taken.
586
     *
587
     * @param string $expression algebraic expression
588
     * @return token
589
     */
590
    public function calculate_algebraic_expression(string $expression): token {
591
        // Parse the expression. It will parsed by the answer parser, i. e. the ^ operator
592
        // will mean exponentiation rather than XOR, as per the documented behaviour.
593
        // As the expression might contain a PREFIX operator (from a model answer), we
594
        // set the fourth parameter of the constructor to TRUE.
595
        // Note that this step will also throw an error, if the expression is empty.
596
        $parser = new answer_parser($expression, $this->export_variable_list(), true, true);
945✔
597
        if (!$parser->is_acceptable_for_answertype(qtype_formulas::ANSWER_TYPE_ALGEBRAIC)) {
945✔
598
            throw new Exception(get_string('error_invalidalgebraic', 'qtype_formulas', $expression));
63✔
599
        }
600

601
        // Setting the evaluator's seed to the current time. If the function is called several
602
        // times in short intervals, we want to make sure the seed still changes.
603
        $lastseed = $this->seed;
882✔
604
        $this->seed = time();
882✔
605
        if ($lastseed >= $this->seed) {
882✔
606
            $this->seed = $lastseed + 1;
777✔
607
            $lastseed = $this->seed;
777✔
608
        }
609

610
        // Now evaluate the expression and return the result. By saving the stack and restoring
611
        // it afterwards, we create an empty substack for this evaluation only.
612
        $this->algebraicmode = true;
882✔
613
        $oldstack = $this->stack;
882✔
614
        $this->clear_stack();
882✔
615
        // Evaluation might fail. In that case, it is important to assure that the old stack
616
        // is re-established and that algebraic mode is turned off.
617
        try {
618
            $result = $this->evaluate($parser->get_statements()[0]);
882✔
619
        } catch (Exception $e) {
21✔
620
            ;
621
        } finally {
622
            $this->stack = $oldstack;
882✔
623
            $this->algebraicmode = false;
882✔
624
            // If we have an exception, we throw it again to pass the error upstream.
625
            if (isset($e)) {
882✔
626
                throw $e;
21✔
627
            }
628
        }
629

630
        return $result;
882✔
631
    }
632

633
    /**
634
     * For a given list of tokens, find the index of the closing bracket that marks the end of
635
     * the index definition, i. e. the part that says what element of the array should be accessed.
636
     *
637
     * @param array $tokens
638
     * @return int
639
     */
640
    private function find_end_of_array_access(array $tokens): int {
641
        $count = count($tokens);
21✔
642

643
        // If we don't have at least four tokens (variable, opening bracket, index, closing bracket)
644
        // or if the first token after the variable name is not an opening bracket, we can return
645
        // immediately.
646
        if ($count < 4 || $tokens[1]->type !== token::OPENING_BRACKET) {
21✔
647
            return 1;
21✔
648
        }
649

650
        for ($i = 1; $i < $count - 1; $i++) {
21✔
651
            $token = $tokens[$i];
21✔
652

653
            // As long as we are not at the closing bracket, we just keep advancing.
654
            if ($token->type !== token::CLOSING_BRACKET) {
21✔
655
                continue;
21✔
656
            }
657
            // We found a closing bracket. Now let's see whether the next token is
658
            // an opening bracket again. If it is, we have to keep searching for the end.
659
            if ($tokens[$i + 1]->type === token::OPENING_BRACKET) {
21✔
660
                continue;
21✔
661
            }
662
            // If it is not, we can return.
663
            return $i + 1;
21✔
664
        }
665

666
        // We have not found the closing bracket, so the end is ... at the end.
667
        return $count;
21✔
668
    }
669

670
    /**
671
     * Takes a string representation of an algebraic formula, e.g. "a*x^2 + b" and
672
     * replaces the non-algebraic variables by their numerical value. Returns the resulting
673
     * string.
674
     *
675
     * @param string $formula the algebraic formula
676
     * @return string
677
     */
678
    public function substitute_variables_in_algebraic_formula(string $formula): string {
679
        // We do not use the answer parser, because we do not actually evaluate the formula,
680
        // and if it is needed for later output (e.g. "the correct answer is ..."), there is
681
        // no need to replace ^ by **.
682
        $parser = new parser($formula, $this->export_variable_list());
21✔
683
        $tokens = $parser->get_tokens();
21✔
684
        $count = count($tokens);
21✔
685

686
        // Will will iterate over all tokens and build an output string bit by bit.
687
        $output = '';
21✔
688
        for ($i = 0; $i < $count; $i++) {
21✔
689
            $token = $tokens[$i];
21✔
690
            // The unary minus must be translated back to '-'.
691
            if ($token->type === token::OPERATOR && $token->value === '_') {
21✔
692
                $output .= '-';
21✔
693
                continue;
21✔
694
            }
695
            // For a nicer output, we add a space before and after the +, -, * and / operator.
696
            if ($token->type === token::OPERATOR && in_array($token->value, ['+', '-', '*', '/'])) {
21✔
697
                $output .= " {$token->value} ";
21✔
698
                continue;
21✔
699
            }
700
            // If the token is not a VARIABLE, it can be shipped out.
701
            if ($tokens[$i]->type !== token::VARIABLE) {
21✔
702
                $output .= $tokens[$i]->value;
21✔
703
                continue;
21✔
704
            }
705

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

714
            // If there was an error, e.g. invalid array index, there will have been no substitution.
715
            // In that case, we only send the variable token to the output and keep on working, because
716
            // there might be nested variables to substitute.
717
            if ($result === "{=$subexpression}") {
21✔
718
                $output .= $token->value;
21✔
719
                continue;
21✔
720
            }
721

722
            // If we are still here, the subexpression has been replaced. We append it to the output
723
            // and remove all tokens until the end of that subexpression from the queue.
724
            $output .= $result;
21✔
725
            array_splice($tokens, $i + 1, $numberoftokens - 1);
21✔
726
            $count = $count - $numberoftokens + 1;
21✔
727
        }
728

729
        return $output;
21✔
730
    }
731

732
    /**
733
     * The diff() function calculates absolute differences between numerical or algebraic
734
     * expressions.
735
     *
736
     * @param array $first first list
737
     * @param array $second second list
738
     * @param int|null $n number of points where algebraic expressions will be evaluated
739
     * @return array
740
     */
741
    public function diff($first, $second, ?int $n = null) {
742
        // First, we check that $first and $second are lists of the same size.
743
        if (!is_array($first)) {
1,050✔
744
            throw new Exception(get_string('error_diff_first', 'qtype_formulas'));
42✔
745
        }
746
        if (!is_array($second)) {
1,008✔
747
            throw new Exception(get_string('error_diff_second', 'qtype_formulas'));
21✔
748
        }
749
        $count = count($first);
987✔
750
        if (count($second) !== $count) {
987✔
751
            throw new Exception(get_string('error_diff_samesize', 'qtype_formulas'));
42✔
752
        }
753

754
        // Now make sure the lists do contain one single data type (only numbers or only strings).
755
        // This is needed for the diff() function, because strings are evaluated as algebraic
756
        // formulas, i. e. in a completely different way. Also, both lists must have the same data
757
        // type.
758
        $type = $first[0]->type;
945✔
759
        if (!in_array($type, [token::NUMBER, token::STRING])) {
945✔
760
            throw new Exception(get_string('error_diff_firstlist_content', 'qtype_formulas'));
21✔
761
        }
762
        for ($i = 0; $i < $count; $i++) {
924✔
763
            if ($first[$i]->type !== $type) {
924✔
764
                throw new Exception(get_string('error_diff_firstlist_mismatch', 'qtype_formulas', $i));
42✔
765
            }
766
            if ($second[$i]->type !== $type) {
924✔
767
                throw new Exception(get_string('error_diff_secondlist_mismatch', 'qtype_formulas', $i));
42✔
768
            }
769
        }
770

771
        // If we are working with numbers, we can directly calculate the differences and return.
772
        if ($type === token::NUMBER) {
840✔
773
            // The user should not specify a third argument when working with numbers.
774
            if ($n !== null) {
63✔
775
                throw new Exception(get_string('error_diff_third', 'qtype_formulas'));
21✔
776
            }
777

778
            $result = [];
42✔
779
            for ($i = 0; $i < $count; $i++) {
42✔
780
                $diff = abs($first[$i]->value - $second[$i]->value);
42✔
781
                $result[$i] = token::wrap($diff);
42✔
782
            }
783
            return $result;
42✔
784
        }
785

786
        // If the user did not specify $n, we set it to 100, for backwards compatibility.
787
        if ($n === null) {
777✔
788
            $n = 100;
21✔
789
        }
790

791
        $result = [];
777✔
792
        // Iterate over all strings and calculate the root mean square difference between the two expressions.
793
        for ($i = 0; $i < $count; $i++) {
777✔
794
            $result[$i] = 0;
777✔
795
            $expression = "({$first[$i]}) - ({$second[$i]})";
777✔
796

797
            // Flag that we will set to TRUE if a difference cannot be evaluated. This
798
            // is to make sure that the difference will be PHP_FLOAT_MAX and not
799
            // sqrt(PHP_FLOAT_MAX) divided by $n.
800
            $cannotevaluate = false;
777✔
801
            for ($j = 0; $j < $n; $j++) {
777✔
802
                try {
803
                    $difference = $this->calculate_algebraic_expression($expression);
777✔
804
                } catch (Exception $e) {
21✔
805
                    // If evaluation failed, there is no need to evaluate any further. Instead,
806
                    // we set the $cannotevaluate flag and will later set the result to
807
                    // PHP_FLOAT_MAX. By choosing PHP_FLOAT_MAX rather than INF, we make sure
808
                    // that the result is still a float.
809
                    $cannotevaluate = true;
21✔
810
                    // Note: index is $i, because every $j step adds to the $i-th difference.
811
                    $result[$i] = PHP_FLOAT_MAX;
21✔
812
                    break;
21✔
813
                }
814
                $result[$i] += $difference->value ** 2;
777✔
815
            }
816
            $result[$i] = token::wrap(sqrt($result[$i] / $n), token::NUMBER);
777✔
817
            if ($cannotevaluate) {
777✔
818
                $result[$i] = token::wrap(PHP_FLOAT_MAX, token::NUMBER);
21✔
819
            }
820
        }
821

822
        return $result;
777✔
823
    }
824

825
    /**
826
     * Evaluate the given thing, e. g. an expression or a for loop.
827
     *
828
     * @param expression|for_loop $input
829
     * @param bool $godmode whether one should be allowed to modify reserved variables like e.g. _a or _0
830
     * @return token|void
831
     */
832
    private function evaluate_the_right_thing($input, bool $godmode = false) {
833
        if ($input instanceof expression) {
24,234✔
834
            return $this->evaluate_single_expression($input, $godmode);
24,192✔
835
        }
836
        if ($input instanceof for_loop) {
483✔
837
            return $this->evaluate_for_loop($input);
462✔
838
        }
839
        throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate_the_right_thing()'));
21✔
840
    }
841

842
    /**
843
     * Evaluate a single expression or an array of expressions.
844
     *
845
     * @param expression|for_loop|array $input
846
     * @param bool $godmode whether to run the evaluation in god mode
847
     * @return token|array
848
     */
849
    public function evaluate($input, bool $godmode = false) {
850
        if (($input instanceof expression) || ($input instanceof for_loop)) {
24,276✔
851
            return $this->evaluate_the_right_thing($input, $godmode);
1,134✔
852
        }
853
        if (!is_array($input)) {
24,045✔
854
            throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate()'));
21✔
855
        }
856
        $result = [];
24,045✔
857
        foreach ($input as $single) {
24,045✔
858
            $result[] = $this->evaluate_the_right_thing($single, $godmode);
24,003✔
859
        }
860
        return $result;
17,577✔
861
    }
862

863
    /**
864
     * Evaluate a for loop.
865
     *
866
     * @param for_loop $loop
867
     * @return void
868
     */
869
    private function evaluate_for_loop(for_loop $loop) {
870
        $rangetoken = $this->evaluate_single_expression($loop->range);
462✔
871
        $range = $rangetoken->value;
462✔
872
        $result = null;
462✔
873
        foreach ($range as $iterationvalue) {
462✔
874
            $this->set_variable_to_value($loop->variable, $iterationvalue);
462✔
875
            $result = $this->evaluate($loop->body);
462✔
876
        }
877
        $this->clear_stack();
462✔
878
        return end($result);
462✔
879
    }
880

881
    /**
882
     * Evaluate an expression, e. g. an assignment, a function call or a calculation.
883
     *
884
     * @param expression $expression
885
     * @param bool $godmode
886
     * @return token
887
     */
888
    private function evaluate_single_expression(expression $expression, bool $godmode = false): token {
889
        foreach ($expression->body as $token) {
24,234✔
890
            $type = $token->type;
24,234✔
891
            $value = $token->value;
24,234✔
892

893
            $isliteral = ($type & token::ANY_LITERAL);
24,234✔
894
            $isopening = ($type === token::OPENING_BRACE || $type === token::OPENING_BRACKET);
24,234✔
895
            $isvariable = ($type === token::VARIABLE);
24,234✔
896

897
            // Many tokens go directly to the stack.
898
            if ($isliteral || $isopening || $isvariable) {
24,234✔
899
                $this->stack[] = $token;
24,108✔
900
                continue;
24,108✔
901
            }
902

903
            // Constants are resolved and sent to the stack.
904
            if ($type === token::CONSTANT) {
24,024✔
905
                $this->stack[] = $this->resolve_constant($token);
252✔
906
                continue;
231✔
907
            }
908

909
            if ($type === token::OPERATOR) {
23,940✔
910
                if ($this->is_unary_operator($token)) {
16,674✔
911
                    $this->stack[] = $this->execute_unary_operator($token);
2,814✔
912
                }
913
                // The = operator is binary, but we treat it separately.
914
                if ($value === '=' || $value === 'r=') {
16,653✔
915
                    $israndomvar = ($value === 'r=');
8,169✔
916
                    $this->godmode = $godmode;
8,169✔
917
                    $this->stack[] = $this->execute_assignment($israndomvar);
8,169✔
918
                    $this->godmode = false;
7,959✔
919
                } else if ($this->is_binary_operator($token)) {
14,007✔
920
                    $this->stack[] = $this->execute_binary_operator($token);
5,376✔
921
                }
922
                // The %%ternary-sentinel pseudo-token goes on the stack where it will
923
                // help detect ternary expressions with too few arguments.
924
                if ($value === '%%ternary-sentinel') {
16,065✔
925
                    $this->stack[] = $token;
525✔
926
                }
927
                // When executing the ternary operator, we pass it the operator token
928
                // in order to have best possible error reporting.
929
                if ($value === '%%ternary') {
16,065✔
930
                    $this->stack[] = $this->execute_ternary_operator($token);
546✔
931
                }
932
                if ($value === '%%arrayindex') {
16,065✔
933
                    $this->stack[] = $this->fetch_array_element_or_char();
903✔
934
                }
935
                if ($value === '%%setbuild' || $value === '%%arraybuild') {
16,044✔
936
                    $this->stack[] = $this->build_set_or_array($value);
8,715✔
937
                }
938
                if ($value === '%%rangebuild') {
16,044✔
939
                    $elements = $this->build_range();
2,457✔
940
                    array_push($this->stack, ...$elements);
2,373✔
941
                }
942
            }
943

944
            if ($type === token::FUNCTION) {
23,226✔
945
                $this->stack[] = $this->execute_function($token);
16,590✔
946
            }
947

948
        }
949
        // If the stack contains more than one element, there must have been a problem somewhere.
950
        if (count($this->stack) !== 1) {
18,081✔
951
            throw new Exception(get_string('error_stacksize', 'qtype_formulas'));
21✔
952
        }
953
        // If the stack only contains one single variable token, return its content.
954
        // Otherwise, return the token.
955
        return $this->pop_real_value();
18,060✔
956
    }
957

958
    /**
959
     * Fetch an element from a list or a char from a string. The index and the list or string will
960
     * be taken from the stack.
961
     *
962
     * @return token the desired list element or char
963
     */
964
    private function fetch_array_element_or_char(): token {
965
        $indextoken = $this->pop_real_value();
903✔
966
        $index = $indextoken->value;
903✔
967
        $nexttoken = array_pop($this->stack);
903✔
968

969
        // Make sure there is only one index.
970
        if ($nexttoken->type !== token::OPENING_BRACKET) {
903✔
971
            $this->die(get_string('error_onlyoneindex', 'qtype_formulas'), $indextoken);
63✔
972
        }
973

974
        // Fetch the array or string from the stack.
975
        $arraytoken = array_pop($this->stack);
861✔
976

977
        // If it is a variable, we do lazy evaluation: just append the index and wait. It might be used
978
        // as a left-hand side in an assignment. If it is not, it will be resolved later. Also, if
979
        // the index is invalid, that will lead to an error later on.
980
        if ($arraytoken->type === token::VARIABLE) {
861✔
981
            $name = $arraytoken->value . "[$index]";
630✔
982
            return new token(token::VARIABLE, $name, $arraytoken->row, $arraytoken->column);
630✔
983
        }
984

985
        // Before accessing the array or string, we validate the index and, if necessary,
986
        // we translate a negative index to the corresponding positive value.
987
        $array = $arraytoken->value;
273✔
988
        $index = $this->validate_array_or_string_index($array, $index, $nexttoken);
273✔
989
        $element = $array[$index];
231✔
990

991
        // If we are accessing a string's char, we create a new string token.
992
        if ($arraytoken->type === token::STRING) {
231✔
993
            return new token(token::STRING, $element, $arraytoken->row, $arraytoken->column + $index);
21✔
994
        }
995
        // Otherwise, the element is already wrapped in a token.
996
        return $element;
210✔
997
    }
998

999
    /**
1000
     * Build a list of (NUMBER) tokens based on a range definition. The lower and upper limit
1001
     * and, if present, the step will be taken from the stack.
1002
     *
1003
     * @return array
1004
     */
1005
    private function build_range(): array {
1006
        // Pop the number of parts. We generated it ourselves, so we know it will be 2 or 3.
1007
        $parts = array_pop($this->stack)->value;
2,457✔
1008

1009
        $step = 1;
2,457✔
1010
        // If we have 3 parts, extract the step size. Conserve the token in case of an error.
1011
        if ($parts === 3) {
2,457✔
1012
            $steptoken = $this->pop_real_value();
483✔
1013
            // Abort with nice error message, if step is not numeric.
1014
            $this->abort_if_not_scalar($steptoken);
483✔
1015
            $step = $steptoken->value;
483✔
1016
        }
1017

1018
        // Step must not be zero.
1019
        if ($step == 0) {
2,457✔
1020
            $this->die(get_string('error_stepzero', 'qtype_formulas'), $steptoken);
21✔
1021
        }
1022

1023
        // Fetch start and end of the range. Conserve token for the end value, in case of an error.
1024
        $endtoken = $this->pop_real_value();
2,436✔
1025
        $end = $endtoken->value;
2,436✔
1026
        $starttoken = $this->pop_real_value();
2,436✔
1027
        $start = $starttoken->value;
2,436✔
1028

1029
        // Abort with nice error message, if start or end is not numeric.
1030
        $this->abort_if_not_scalar($starttoken);
2,436✔
1031
        $this->abort_if_not_scalar($endtoken);
2,436✔
1032

1033
        if ($start === $end) {
2,436✔
1034
            $this->die(get_string('error_samestartend', 'qtype_formulas'), $endtoken);
42✔
1035
        }
1036

1037
        if (($end - $start) * $step < 0) {
2,394✔
1038
            if ($parts === 3) {
42✔
1039
                $a = (object)['start' => $start, 'end' => $end, 'step' => $step];
21✔
1040
                $this->die(get_string('error_emptyrange', 'qtype_formulas', $a), $steptoken);
21✔
1041
            }
1042
            $step = -$step;
21✔
1043
        }
1044

1045
        $result = [];
2,373✔
1046
        $numofsteps = ($end - $start) / $step;
2,373✔
1047
        // Choosing multiplication of step instead of repeated addition for better numerical accuracy.
1048
        for ($i = 0; $i < $numofsteps; $i++) {
2,373✔
1049
            $result[] = new token(token::NUMBER, $start + $i * $step);
2,373✔
1050
        }
1051
        return $result;
2,373✔
1052
    }
1053

1054
    /**
1055
     * Create a SET or LIST token based on elements on the stack.
1056
     *
1057
     * @param string $type whether to build a SET or a LIST
1058
     * @return token
1059
     */
1060
    private function build_set_or_array(string $type): token {
1061
        if ($type === '%%setbuild') {
8,715✔
1062
            $delimitertype = token::OPENING_BRACE;
2,436✔
1063
            $outputtype = token::SET;
2,436✔
1064
        } else {
1065
            $delimitertype = token::OPENING_BRACKET;
7,392✔
1066
            $outputtype = token::LIST;
7,392✔
1067
        }
1068
        $elements = [];
8,715✔
1069
        $head = end($this->stack);
8,715✔
1070
        while ($head !== false) {
8,715✔
1071
            if ($head->type === $delimitertype) {
8,715✔
1072
                array_pop($this->stack);
8,715✔
1073
                break;
8,715✔
1074
            }
1075
            $elements[] = $this->pop_real_value();
8,589✔
1076
            $head = end($this->stack);
8,589✔
1077
        }
1078
        // Return reversed list, because the stack ist LIFO.
1079
        return new token($outputtype, array_reverse($elements));
8,715✔
1080
    }
1081

1082
    /**
1083
     * Whether a given OPERATOR token is an unary operator.
1084
     *
1085
     * @param token $token
1086
     * @return bool
1087
     */
1088
    private function is_unary_operator(token $token): bool {
1089
        return in_array($token->value, ['_', '!', '~']);
16,674✔
1090
    }
1091

1092
    /**
1093
     * Whether a given OPERATOR token expects its argument(s) to be numbers.
1094
     *
1095
     * @param token $token
1096
     * @return bool
1097
     */
1098
    private function needs_numeric_input(token $token): bool {
1099
        $operators = ['_', '~', '**', '*', '/', '%', '-', '<<', '>>', '&', '^', '|', '&&', '||'];
7,266✔
1100
        return in_array($token->value, $operators);
7,266✔
1101
    }
1102

1103
    /**
1104
     * In many cases, operators need a numeric or at least a scalar operand to work properly.
1105
     * This function does the necessary check and prepares a human-friendly error message
1106
     * if the conditions are not met.
1107
     *
1108
     * @param token $token the token to check
1109
     * @param bool $enforcenumeric whether the value must be numeric in addition to being scalar
1110
     * @return void
1111
     * @throws Exception
1112
     */
1113
    private function abort_if_not_scalar(token $token, bool $enforcenumeric = true): void {
1114
        $found = '';
7,875✔
1115
        $a = (object)[];
7,875✔
1116
        if ($token->type !== token::NUMBER) {
7,875✔
1117
            if ($token->type === token::SET) {
546✔
1118
                $found = '_algebraicvar';
21✔
1119
            } else if ($token->type === token::LIST) {
525✔
1120
                $found = '_list';
63✔
1121
            } else if ($enforcenumeric) {
483✔
1122
                // Let's be lenient if the token is not a NUMBER, but its value is numeric.
1123
                if (is_numeric($token->value)) {
294✔
1124
                    return;
252✔
1125
                }
1126
                $a->found = "'{$token->value}'";
42✔
1127
            } else if ($token->type === token::STRING) {
189✔
1128
                return;
189✔
1129
            }
1130
            $expected = ($enforcenumeric ? 'number' : 'scalar');
126✔
1131

1132
            $this->die(get_string("error_expected_{$expected}_found{$found}", 'qtype_formulas', $a), $token);
126✔
1133
        }
1134
    }
1135

1136
    /**
1137
     * Whether a given OPERATOR token is a binary operator.
1138
     *
1139
     * @param token $token
1140
     * @return bool
1141
     */
1142
    public static function is_binary_operator(token $token): bool {
1143
        $binaryoperators = ['=', '**', '*', '/', '%', '+', '-', '<<', '>>', '&', '^',
14,007✔
1144
            '|', '&&', '||', '<', '>', '==', '>=', '<=', '!='];
14,007✔
1145

1146
        return in_array($token->value, $binaryoperators);
14,007✔
1147
    }
1148

1149
    /**
1150
     * Assign a value to a variable. The value and the variable name are taken from the stack.
1151
     *
1152
     * @param boolean $israndomvar
1153
     * @return token the assigned value
1154
     */
1155
    private function execute_assignment($israndomvar = false): token {
1156
        $what = $this->pop_real_value();
8,169✔
1157
        $destination = array_pop($this->stack);
8,169✔
1158

1159
        // When storing a value in a variable, the row and column should be
1160
        // set to the row and column of the variable token.
1161
        $what->row = $destination->row;
8,169✔
1162
        $what->column = $destination->column;
8,169✔
1163

1164
        // The destination must be a variable token.
1165
        if ($destination->type !== token::VARIABLE) {
8,169✔
1166
            $this->die(get_string('error_variablelhs', 'qtype_formulas'), $destination);
42✔
1167
        }
1168
        return $this->set_variable_to_value($destination, $what, $israndomvar);
8,127✔
1169
    }
1170

1171
    /**
1172
     * Evaluate a ternary expression, taking the arguments from the stack.
1173
     *
1174
     * @param token $optoken token that led to this function being called, for better error reporting
1175
     * @return token evaluation result
1176
     */
1177
    private function execute_ternary_operator(token $optoken) {
1178
        // For good error reporting, we first check, whether there are enough arguments on
1179
        // the stack. We subtract one, because there is a sentinel token.
1180
        if (count($this->stack) - 1 < 3) {
546✔
1181
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $optoken);
42✔
1182
        }
1183
        $else = array_pop($this->stack);
525✔
1184
        $then = array_pop($this->stack);
525✔
1185
        // The user might not have provided enough arguments for the ternary operator (missing 'else'
1186
        // part), but there might be other elements on the stack from earlier operations (or a LHS variable
1187
        // for an upcoming assignment). In that case, the intended 'then' token has been popped as
1188
        // the 'else' part and we have now read the '%%ternary-sentinel' pseudo-token.
1189
        if ($then->type === token::OPERATOR && $then->value === '%%ternary-sentinel') {
525✔
1190
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $then);
21✔
1191
        }
1192
        // If everything is OK, we should now arrive at the '%%ternary-sentinel' pseudo-token. Let's see...
1193
        $pseudotoken = array_pop($this->stack);
504✔
1194
        if ($pseudotoken->type !== token::OPERATOR && $pseudotoken->value !== '%%ternary-sentinel') {
504✔
1195
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $then);
21✔
1196
        }
1197

1198
        $condition = $this->pop_real_value();
483✔
1199
        return ($condition->value ? $then : $else);
483✔
1200
    }
1201

1202
    /**
1203
     * Apply an unary operator to the token that is currently on top of the stack.
1204
     *
1205
     * @param token $token operator token
1206
     * @return token result
1207
     */
1208
    private function execute_unary_operator($token) {
1209
        $input = $this->pop_real_value();
2,814✔
1210

1211
        // Check if the input is numeric. Boolean values are internally treated as 1 and 0 for
1212
        // backwards compatibility.
1213
        if ($this->needs_numeric_input($token)) {
2,814✔
1214
            $this->abort_if_not_scalar($input);
2,772✔
1215
        }
1216

1217
        $result = functions::apply_unary_operator($token->value, $input->value);
2,772✔
1218
        return token::wrap($result);
2,772✔
1219
    }
1220

1221
    /**
1222
     * Apply a binary operator to the two elements currently on top of the stack.
1223
     *
1224
     * @param token $optoken operator token
1225
     * @return token result
1226
     */
1227
    private function execute_binary_operator($optoken) {
1228
        // The stack is LIFO, so we pop the second operand first.
1229
        $secondtoken = $this->pop_real_value();
5,376✔
1230
        $firsttoken = $this->pop_real_value();
5,103✔
1231

1232
        // Abort with nice error message, if arguments should be numeric but are not.
1233
        if ($this->needs_numeric_input($optoken)) {
5,103✔
1234
            $this->abort_if_not_scalar($firsttoken);
3,717✔
1235
            $this->abort_if_not_scalar($secondtoken);
3,675✔
1236
        }
1237

1238
        $first = $firsttoken->value;
5,061✔
1239
        $second = $secondtoken->value;
5,061✔
1240

1241
        // For + (string concatenation or addition) we check the arguments here, even if another
1242
        // check is done in functions::apply_binary_operator(), because this allows for better
1243
        // error reporting.
1244
        if ($optoken->value === '+') {
5,061✔
1245
            // If at least one operand is a string, both values must be scalar, but
1246
            // not necessarily numeric; we use concatenation instead of addition.
1247
            // In all other cases, addition must (currently) be numeric, so we abort
1248
            // if the arguments are not numbers.
1249
            $acceptstring = is_string($first) || is_string($second);
2,394✔
1250
            $this->abort_if_not_scalar($firsttoken, !$acceptstring);
2,394✔
1251
            $this->abort_if_not_scalar($secondtoken, !$acceptstring);
2,373✔
1252
        }
1253

1254
        try {
1255
            $result = functions::apply_binary_operator($optoken->value, $first, $second);
5,019✔
1256
        } catch (Exception $e) {
357✔
1257
            $this->die($e->getMessage(), $optoken);
357✔
1258
        }
1259
        return token::wrap($result);
4,662✔
1260
    }
1261

1262
    /**
1263
     * Check whether the number of parameters is valid for a given function.
1264
     *
1265
     * @param token $function FUNCTION token containing the function name
1266
     * @param int $count number of arguments
1267
     * @return bool
1268
     */
1269
    private function is_valid_num_of_params(token $function, int $count): bool {
1270
        $funcname = $function->value;
16,590✔
1271
        $min = INF;
16,590✔
1272
        $max = -INF;
16,590✔
1273
        // Union gives precedence to first array, so we are able to override a
1274
        // built-in function.
1275
        $allfunctions = functions::FUNCTIONS + self::PHPFUNCTIONS;
16,590✔
1276
        if (array_key_exists($funcname, $allfunctions)) {
16,590✔
1277
            $min = $allfunctions[$funcname][0];
16,569✔
1278
            $max = $allfunctions[$funcname][1];
16,569✔
1279
            return $count >= $min && $count <= $max;
16,569✔
1280
        }
1281
        // Still here? That means the function is unknown.
1282
        $this->die(get_string('error_unknownfunction', 'qtype_formulas', $funcname), $function);
21✔
1283
    }
1284

1285
    /**
1286
     * Lookup the value of a constant and return its value.
1287
     *
1288
     * @param token $token CONSTANT token containing the constant's name
1289
     * @return token value of the requested constant
1290
     */
1291
    private function resolve_constant($token): token {
1292
        if (array_key_exists($token->value, $this->constants)) {
252✔
1293
            return new token(token::NUMBER, $this->constants[$token->value], $token->row, $token->column);
231✔
1294
        }
1295
        $this->die(get_string('error_undefinedconstant', 'qtype_formulas', $token->value), $token);
21✔
1296
    }
1297

1298
    /**
1299
     * Execute a given function, taking the needed argument(s) from the stack.
1300
     *
1301
     * @param token $token FUNCTION token containing the function's name.
1302
     * @return token result
1303
     */
1304
    private function execute_function(token $token): token {
1305
        $funcname = $token->value;
16,590✔
1306

1307
        // Fetch the number of params from the stack. Keep the token in case of an error.
1308
        $numparamstoken = array_pop($this->stack);
16,590✔
1309
        $numparams = $numparamstoken->value;
16,590✔
1310

1311
        // Check if the number of params is valid for the given function. If it is not,
1312
        // die with an error message.
1313
        if (!$this->is_valid_num_of_params($token, $numparams)) {
16,590✔
1314
            $a = (object)['function' => $funcname, 'count' => $numparams];
2,982✔
1315
            $this->die(get_string('error_func_argcount', 'qtype_formulas', $a), $token);
2,982✔
1316
        }
1317

1318
        // Fetch the params from the stack and reverse their order, because the stack is LIFO.
1319
        $params = [];
13,587✔
1320
        for ($i = 0; $i < $numparams; $i++) {
13,587✔
1321
            $params[] = $this->pop_real_value()->value;
13,566✔
1322
        }
1323
        $params = array_reverse($params);
13,566✔
1324

1325
        // If something goes wrong, e. g. wrong type of parameter, functions will throw a TypeError (built-in)
1326
        // or an Exception (custom functions). We catch the exception and build a nice error message.
1327
        try {
1328
            // If we have our own implementation, execute that one. Otherwise, use PHP's built-in function.
1329
            // The special function diff() is defined in the evaluator, so it needs special treatment.
1330
            $isown = array_key_exists($funcname, functions::FUNCTIONS);
13,566✔
1331
            $prefix = '';
13,566✔
1332
            if ($funcname === 'diff') {
13,566✔
1333
                $prefix = self::class . '::';
1,071✔
1334
            } else if ($isown) {
12,768✔
1335
                $prefix = functions::class . '::';
9,891✔
1336
            }
1337
            $result = call_user_func_array($prefix . $funcname, $params);
13,566✔
1338
            // Our own funtions should deal with all sorts of errors and invalid arguments. However,
1339
            // the PHP built-in functions will sometimes return NAN or ±INF, e.g. for sqrt(-2) or log(0).
1340
            // We will check for those return values and output a special error message.
1341
            // Note that for PHP the values NAN, INF and -INF are all numeric, but not finite.
1342
            if (is_numeric($result) && !is_finite($result)) {
11,571✔
1343
                throw new Exception(get_string('error_func_nan', 'qtype_formulas', $funcname));
11,571✔
1344
            }
1345
        } catch (Throwable $e) {
2,100✔
1346
            $this->die($e->getMessage(), $token);
2,100✔
1347
        }
1348

1349
        // Some of our own functions may return a token. In those cases, we reset
1350
        // the row and column value, because they are no longer accurate. Once that
1351
        // is done, we return the token.
1352
        if ($result instanceof token) {
11,487✔
1353
            $result->row = -1;
189✔
1354
            $result->column = -1;
189✔
1355
            return $result;
189✔
1356
        }
1357

1358
        // Most of the time, the return value will not be a token. In those cases,
1359
        // we have to wrap it up before returning.
1360
        return token::wrap($result);
11,298✔
1361
    }
1362
}
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