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

FormulasQuestion / moodle-qtype_formulas / 15666456149

15 Jun 2025 07:06PM UTC coverage: 97.393% (-0.08%) from 97.476%
15666456149

Pull #228

github

web-flow
Merge c24220585 into 10cff4e74
Pull Request #228: Use localised numbers

27 of 31 new or added lines in 6 files covered. (87.1%)

17 existing lines in 1 file now uncovered.

4109 of 4219 relevant lines covered (97.39%)

1571.94 hits per line

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

99.56
/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

NEW
22
defined('MOODLE_INTERNAL') || die();
×
23

NEW
24
require_once($CFG->dirroot . '/question/type/formulas/questiontype.php');
×
25

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

79
    /** @var array $variables array holding all variables */
80
    private array $variables = [];
81

82
    /** @var array $randomvariables array holding all (uninstantiated) random variables */
83
    private array $randomvariables = [];
84

85
    /** @var array $constants array holding all predefined constants, i. e. pi */
86
    private array $constants = [
87
        'π' => M_PI,
88
    ];
89

90
    /** @var array $stack the operand stack */
91
    private array $stack = [];
92

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

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

104
    /** @var bool $algebraicmode whether algebraic variables are replaced by a random value from their reservoir */
105
    private bool $algebraicmode = false;
106

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

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

133
        $matches = [];
63✔
134
        preg_match_all("/\{($varpattern|$arraypattern|$expressionpattern)\}/", $text, $matches);
63✔
135

136
        // We have the variable names or expressions in $matches[1]. Let's first filter out the
137
        // duplicates.
138
        $matches = array_unique($matches[1]);
63✔
139

140
        foreach ($matches as $match) {
63✔
141
            $input = $match;
63✔
142
            // For expressions, we have to remove the = sign.
143
            if ($input[0] === '=') {
63✔
144
                $input = substr($input, 1);
63✔
145
            }
146
            // We could resolve variables like {a} or {b[1]} directly and it would probably be faster
147
            // to do so, but the code is much simpler if we just feed everything to the evaluator.
148
            // If there is an evaluation error, we simply do not replace do placeholder.
149
            try {
150
                $parser = new parser($input);
63✔
151
                // Before evaluating an expression, we want to make sure it does not contain
152
                // an assignment operator, because that could overwrite values in the evaluator's
153
                // variable context.
154
                if ($input !== $match && $parser->has_token_in_tokenlist(token::OPERATOR, '=')) {
63✔
155
                    continue;
21✔
156
                }
157
                // Evaluation will fail e.g. if it is an algebraic variable or if there is an
158
                // error in the expression. In those cases, the placeholder will simply not
159
                // be replaced.
160
                $results = $this->evaluate($parser->get_statements());
63✔
161
                $result = end($results);
63✔
162
                // If the users does not want to substitute lists (arrays), well ... we don't.
163
                if ($skiplists && in_array($result->type, [token::LIST, token::SET])) {
63✔
164
                    continue;
21✔
165
                }
166
                // If the result is a number, we try to localize it, unless the admin settings do not
167
                // allow the decimal comma.
168
                if ($result->type === token::NUMBER) {
63✔
169
                    $result = qtype_formulas::format_float($result->value);
63✔
170
                }
171

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

179
        return $text;
63✔
180
    }
181

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

192
            if ($isreserved || $isanswer) {
21✔
193
                unset($this->variables[$name]);
21✔
194
            }
195
        }
196
    }
197

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

208
        // If a context is given, we initialize our variables accordingly.
209
        if (key_exists('randomvariables', $context) && key_exists('variables', $context)) {
24,297✔
210
            $this->import_variable_context($context);
21✔
211
        }
212
    }
213

214
    /**
215
     * Clear the stack.
216
     *
217
     * @return void
218
     */
219
    public function clear_stack(): void {
220
        $this->stack = [];
24,297✔
221
    }
222

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

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

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

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

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

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

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

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

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

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

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

404
                $value->type = variable::ALGEBRAIC;
1,743✔
405
            }
406
            $var = new variable($basename, $value->value, $value->type, microtime(true));
8,001✔
407
            $this->variables[$basename] = $var;
8,001✔
408
            return token::wrap($var->value);
8,001✔
409
        }
410

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

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

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

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

438
        // Finally, we return what has been stored.
439
        return $current;
189✔
440
    }
441

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

459
        // If the index is not a whole number, throw an error. A whole number in float
460
        // representation is fine, though.
461
        if ($index - intval($index) != 0) {
798✔
462
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
42✔
463
        }
464
        $index = intval($index);
756✔
465

466
        // Fetch the length of the array or string.
467
        if (is_string($arrayorstring)) {
756✔
468
            $len = strlen($arrayorstring);
126✔
469
        } else if (is_array($arrayorstring)) {
630✔
470
            $len = count($arrayorstring);
588✔
471
        } else {
472
            $this->die(get_string('error_notindexable', 'qtype_formulas'), $anchor);
42✔
473
        }
474

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

486
        return $index;
630✔
487
    }
488

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

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

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

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

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

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

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

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

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

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

634
        return $result;
882✔
635
    }
636

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

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

654
        for ($i = 1; $i < $count - 1; $i++) {
21✔
655
            $token = $tokens[$i];
21✔
656

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

670
        // We have not found the closing bracket, so the end is ... at the end.
671
        return $count;
21✔
672
    }
673

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

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

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

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

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

733
        return $output;
21✔
734
    }
735

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

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

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

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

790
        // If the user did not specify $n, we set it to 100, for backwards compatibility.
791
        if ($n === null) {
777✔
792
            $n = 100;
21✔
793
        }
794

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

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

826
        return $result;
777✔
827
    }
828

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

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

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

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

897
            $isliteral = ($type & token::ANY_LITERAL);
24,255✔
898
            $isopening = ($type === token::OPENING_BRACE || $type === token::OPENING_BRACKET);
24,255✔
899
            $isvariable = ($type === token::VARIABLE);
24,255✔
900

901
            // Many tokens go directly to the stack.
902
            if ($isliteral || $isopening || $isvariable) {
24,255✔
903
                $this->stack[] = $token;
24,129✔
904
                continue;
24,129✔
905
            }
906

907
            // Constants are resolved and sent to the stack.
908
            if ($type === token::CONSTANT) {
24,045✔
909
                $this->stack[] = $this->resolve_constant($token);
252✔
910
                continue;
231✔
911
            }
912

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

948
            if ($type === token::FUNCTION) {
23,247✔
949
                $this->stack[] = $this->execute_function($token);
16,590✔
950
            }
951

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

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

973
        // Make sure there is only one index.
974
        if ($nexttoken->type !== token::OPENING_BRACKET) {
924✔
975
            $this->die(get_string('error_onlyoneindex', 'qtype_formulas'), $indextoken);
63✔
976
        }
977

978
        // Fetch the array or string from the stack.
979
        $arraytoken = array_pop($this->stack);
882✔
980

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

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

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

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

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

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

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

1033
        // Abort with nice error message, if start or end is not numeric.
1034
        $this->abort_if_not_scalar($starttoken);
2,436✔
1035
        $this->abort_if_not_scalar($endtoken);
2,436✔
1036

1037
        if ($start === $end) {
2,436✔
1038
            $this->die(get_string('error_samestartend', 'qtype_formulas'), $endtoken);
42✔
1039
        }
1040

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

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

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

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

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

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

1136
            $this->die(get_string("error_expected_{$expected}_found{$found}", 'qtype_formulas', $a), $token);
126✔
1137
        }
1138
    }
1139

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

1150
        return in_array($token->value, $binaryoperators);
14,028✔
1151
    }
1152

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

1163
        // When storing a value in a variable, the row and column should be
1164
        // set to the row and column of the variable token.
1165
        $what->row = $destination->row;
8,190✔
1166
        $what->column = $destination->column;
8,190✔
1167

1168
        // The destination must be a variable token.
1169
        if ($destination->type !== token::VARIABLE) {
8,190✔
1170
            $this->die(get_string('error_variablelhs', 'qtype_formulas'), $destination);
42✔
1171
        }
1172
        return $this->set_variable_to_value($destination, $what, $israndomvar);
8,148✔
1173
    }
1174

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

1202
        $condition = $this->pop_real_value();
483✔
1203
        return ($condition->value ? $then : $else);
483✔
1204
    }
1205

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

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

1221
        $result = functions::apply_unary_operator($token->value, $input->value);
2,772✔
1222
        return token::wrap($result);
2,772✔
1223
    }
1224

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

1236
        // Abort with nice error message, if arguments should be numeric but are not.
1237
        if ($this->needs_numeric_input($optoken)) {
5,124✔
1238
            $this->abort_if_not_scalar($firsttoken);
3,738✔
1239
            $this->abort_if_not_scalar($secondtoken);
3,696✔
1240
        }
1241

1242
        $first = $firsttoken->value;
5,082✔
1243
        $second = $secondtoken->value;
5,082✔
1244

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

1258
        try {
1259
            $result = functions::apply_binary_operator($optoken->value, $first, $second);
5,040✔
1260
        } catch (Exception $e) {
357✔
1261
            $this->die($e->getMessage(), $optoken);
357✔
1262
        }
1263
        return token::wrap($result);
4,683✔
1264
    }
1265

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

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

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

1311
        // Fetch the number of params from the stack. Keep the token in case of an error.
1312
        $numparamstoken = array_pop($this->stack);
16,590✔
1313
        $numparams = $numparamstoken->value;
16,590✔
1314

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

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

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

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

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