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

FormulasQuestion / moodle-qtype_formulas / 13217446514

08 Feb 2025 04:37PM UTC coverage: 76.899% (+1.9%) from 75.045%
13217446514

Pull #62

github

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

2547 of 3139 new or added lines in 22 files covered. (81.14%)

146 existing lines in 6 files now uncovered.

3006 of 3909 relevant lines covered (76.9%)

438.31 hits per line

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

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

17
namespace qtype_formulas\local;
18

19
use qtype_formulas;
20
use Throwable, Exception;
21

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

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

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

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

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

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

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

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

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

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

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

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

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

169
        return $text;
34✔
170
    }
171

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

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

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

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

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

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

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

243
    /**
244
     * Export the names of all known variables. This can be used to pass to a new parser,
245
     * in order to help it classify identifiers as functions or variables.
246
     *
247
     * @return array
248
     */
249
    public function export_variable_list(): array {
250
        return array_keys($this->variables);
2,652✔
251
    }
252

253
    /**
254
     * Export the variable with the given name. Depending on the second parameter, the function
255
     * returns a token (the variable's content) or a variable (the variable's actual definition).
256
     *
257
     * @param string $varname name of the variable
258
     * @param boolean $exportasvariable whether to export as an instance of variable, otherwise just export the content
259
     * @return token|variable
260
     */
261
    public function export_single_variable(string $varname, bool $exportasvariable = false) {
262
        if ($exportasvariable) {
1,921✔
263
            return $this->variables[$varname];
1,666✔
264
        }
265
        $result = $this->get_variable_value(token::wrap($varname));
255✔
266
        return $result;
255✔
267
    }
268

269
    /**
270
     * Calculate the number of possible variants according to the defined random variables.
271
     *
272
     * @return int
273
     */
274
    public function get_number_of_variants(): int {
275
        $result = 1;
221✔
276
        foreach ($this->randomvariables as $var) {
221✔
277
            $num = $var->how_many();
221✔
278
            if ($num > PHP_INT_MAX / $result) {
221✔
279
                return PHP_INT_MAX;
17✔
280
            }
281
            $result = $result * $num;
221✔
282
        }
283
        return $result;
204✔
284
    }
285

286
    /**
287
     * Instantiate random variables, i. e. assigning a fixed value to them and make them available
288
     * as regular global variables.
289
     *
290
     * @param integer|null $seed
291
     * @return void
292
     */
293
    public function instantiate_random_variables(?int $seed = null): void {
294
        if (isset($seed)) {
578✔
NEW
295
            mt_srand($seed);
×
296
        }
297
        foreach ($this->randomvariables as $var) {
578✔
298
            $value = $var->instantiate();
561✔
299
            $this->set_variable_to_value(token::wrap($var->name, token::VARIABLE), $value);
561✔
300
        }
301
    }
302

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

339
    /**
340
     * Set the variable defined in $token to the value $value and correctly set
341
     * it's $type attribute.
342
     *
343
     * @param token $vartoken
344
     * @param token $value
345
     * @param bool $israndomvar
346
     * @return token
347
     */
348
    private function set_variable_to_value(token $vartoken, token $value, $israndomvar = false): token {
349
        // Get the "basename" of the variable, e.g. foo in case of foo[1][2].
350
        $basename = $vartoken->value;
4,182✔
351
        if (strpos($basename, '[') !== false) {
4,182✔
352
            $basename = strstr($basename, '[', true);
187✔
353
        }
354

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

369
        // If there are no indices, we set the variable as requested.
370
        if ($basename === $vartoken->value) {
4,165✔
371
            // If we are assigning to a random variable, we create a new instance and
372
            // return the value of the first instantiation.
373
            if ($israndomvar) {
4,148✔
374
                $useshuffle = $value->type === variable::LIST;
595✔
375
                if (is_scalar($value->value)) {
595✔
NEW
376
                    $this->die(get_string('error_invalidrandvardef', 'qtype_formulas'), $value);
×
377
                }
378
                $randomvar = new random_variable($basename, $value->value, $useshuffle);
595✔
379
                $this->randomvariables[$basename] = $randomvar;
595✔
380
                return token::wrap($randomvar->reservoir);
595✔
381
            }
382

383
            // Otherwise we return the stored value. If the data is a SET, the variable is an
384
            // algebraic variable.
385
            if ($value->type === token::SET) {
4,131✔
386
                // Algebraic variables only accept a list of numbers; they must not contain
387
                // strings or nested lists.
388
                foreach ($value->value as $entry) {
1,479✔
389
                    if ($entry->type != token::NUMBER) {
1,479✔
390
                        $this->die(get_string('error_algvar_numbers', 'qtype_formulas'), $value);
85✔
391
                    }
392
                }
393

394
                $value->type = variable::ALGEBRAIC;
1,394✔
395
            }
396
            $var = new variable($basename, $value->value, $value->type, microtime(true));
4,046✔
397
            $this->variables[$basename] = $var;
4,046✔
398
            return token::wrap($var->value);
4,046✔
399
        }
400

401
        // If there is an index and we are setting a random variable, we throw an error.
402
        if ($israndomvar) {
187✔
403
            $this->die(get_string('error_setindividual_randvar', 'qtype_formulas'), $value);
17✔
404
        }
405

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

412
        // Otherwise, we try to get the variable's value. The function will
413
        // - resolve indices correctly
414
        // - throw an error, if the variable does not exist
415
        // so we can just rely on that.
416
        $current = $this->get_variable_value($vartoken);
153✔
417

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

428
        // Finally, we return what has been stored.
429
        return $current;
153✔
430
    }
431

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

449
        // If the index is not a whole number, throw an error. A whole number in float
450
        // representation is fine, though.
451
        if ($index - intval($index) != 0) {
612✔
452
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
34✔
453
        }
454
        $index = intval($index);
578✔
455

456
        // Fetch the length of the array or string.
457
        if (is_string($arrayorstring)) {
578✔
458
            $len = strlen($arrayorstring);
102✔
459
        } else if (is_array($arrayorstring)) {
476✔
460
            $len = count($arrayorstring);
442✔
461
        } else {
462
            $this->die(get_string('error_notindexable', 'qtype_formulas'), $anchor);
34✔
463
        }
464

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

476
        return $index;
476✔
477
    }
478

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

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

515
                    $randomindex = mt_rand(0, count($result->value) - 1);
561✔
516
                    $randomelement = $result->value[$randomindex];
561✔
517
                    $value = $randomelement->value;
561✔
518
                    $type = $randomelement->type;
561✔
519
                } else {
520
                    // If we are not in algebraic mode, it does not make sense to get the value of an algebraic
521
                    // variable.
522
                    $this->die(get_string('error_cannotusealgebraic', 'qtype_formulas', $name), $variable);
348✔
523
                }
524
            } else {
525
                $value = $result->value;
1,071✔
526
            }
527
            return new token($type, $value, $variable->row, $variable->column);
1,632✔
528
        }
529

530
        // If we do have indices, we access them one by one. The ] at the end of each
531
        // part must be stripped.
532
        foreach ($parts as $part) {
476✔
533
            // Validate the index and, if necessary, convert a negative index to the corresponding
534
            // positive value.
535
            $index = $this->validate_array_or_string_index($result->value, substr($part, 0, -1), $variable);
476✔
536
            $result = $result->value[$index];
340✔
537
        }
538

539
        // When accessing an array, the elements are already stored as tokens, so we return them
540
        // as they are. This allows the receiver to change values inside the array, because
541
        // objects are passed by reference.
542
        // For strings, we must create a new token, because we only get a character.
543
        if (is_string($result)) {
340✔
544
            return new token(token::STRING, $result, $variable->row, $variable->column);
51✔
545
        }
546
        return $result;
289✔
547
    }
548

549
    /**
550
     * Stop evaluating and indicate the human readable position (row/column) where the error occurred.
551
     *
552
     * @param string $message error message
553
     * @throws Exception
554
     */
555
    private function die(string $message, token $offendingtoken) {
556
        throw new Exception($offendingtoken->row . ':' . $offendingtoken->column . ':' . $message);
1,445✔
557
    }
558

559
    /**
560
     * Pop top element from the stack. If the token is a literal (number, string, list etc.), return it
561
     * directly. If it is a variable, resolve it and return its content.
562
     *
563
     * @return token
564
     */
565
    private function pop_real_value(): token {
566
        if (empty($this->stack)) {
7,633✔
567
            throw new Exception(get_string('error_emptystack', 'qtype_formulas'));
17✔
568
        }
569
        $token = array_pop($this->stack);
7,616✔
570
        if ($token->type === token::VARIABLE) {
7,616✔
571
            return $this->get_variable_value($token);
1,887✔
572
        }
573
        return $token;
7,446✔
574
    }
575

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

594
        // Setting the evaluator's seed to the current time. If the function is called several
595
        // times in short intervals, we want to make sure the seed still changes.
596
        $lastseed = $this->seed;
714✔
597
        $this->seed = time();
714✔
598
        if ($lastseed >= $this->seed) {
714✔
599
            $this->seed = $lastseed + 1;
629✔
600
            $lastseed = $this->seed;
629✔
601
        }
602

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

623
        return $result;
714✔
624
    }
625

626
    /**
627
     * For a given list of tokens, find the index of the closing bracket that marks the end of
628
     * the index definition, i. e. the part that says what element of the array should be accessed.
629
     *
630
     * @param array $tokens
631
     * @return int
632
     */
633
    private function find_end_of_array_access(array $tokens): int {
634
        $count = count($tokens);
17✔
635

636
        // If we don't have at least four tokens (variable, opening bracket, index, closing bracket)
637
        // or if the first token after the variable name is not an opening bracket, we can return
638
        // immediately.
639
        if ($count < 4 || $tokens[1]->type !== token::OPENING_BRACKET) {
17✔
640
            return 1;
17✔
641
        }
642

643
        for ($i = 1; $i < $count - 1; $i++) {
17✔
644
            $token = $tokens[$i];
17✔
645

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

659
        // We have not found the closing bracket, so the end is ... at the end.
660
        return $count;
17✔
661
    }
662

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

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

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

706
            // If there was an error, e.g. invalid array index, there will have been no substitution.
707
            // In that case, we only send the variable token to the output and keep on working, because
708
            // there might be nested variables to substitute.
709
            if ($result === "{=$subexpression}") {
17✔
710
                $output .= $token->value;
17✔
711
                continue;
17✔
712
            }
713

714
            // If we are still here, the subexpression has been replaced. We append it to the output
715
            // and remove all tokens until the end of that subexpression from the queue.
716
            $output .= $result;
17✔
717
            array_splice($tokens, $i + 1, $numberoftokens - 1);
17✔
718
            $count = $count - $numberoftokens + 1;
17✔
719
        }
720

721
        return $output;
17✔
722
    }
723

724
    /**
725
     * The diff() function calculates absolute differences between numerical or algebraic
726
     * expressions.
727
     *
728
     * @param array $first first list
729
     * @param array $second second list
730
     * @param int $n number of points where algebraic expressions will be evaluated
731
     * @return array
732
     */
733
    public function diff($first, $second, ?int $n = null) {
734
        // First, we check that $first and $second are lists of the same size.
735
        if (!is_array($first)) {
816✔
736
            throw new Exception(get_string('error_diff_first', 'qtype_formulas'));
17✔
737
        }
738
        if (!is_array($second)) {
799✔
739
            throw new Exception(get_string('error_diff_second', 'qtype_formulas'));
17✔
740
        }
741
        $count = count($first);
782✔
742
        if (count($second) !== $count) {
782✔
743
            throw new Exception(get_string('error_diff_samesize', 'qtype_formulas'));
34✔
744
        }
745

746
        // Now make sure the lists do contain one single data type (only numbers or only strings).
747
        // This is needed for the diff() function, because strings are evaluated as algebraic
748
        // formulas, i. e. in a completely different way. Also, both lists must have the same data
749
        // type.
750
        $type = $first[0]->type;
748✔
751
        if (!in_array($type, [token::NUMBER, token::STRING])) {
748✔
752
            throw new Exception(get_string('error_diff_firstlist_content', 'qtype_formulas'));
17✔
753
        }
754
        for ($i = 0; $i < $count; $i++) {
731✔
755
            if ($first[$i]->type !== $type) {
731✔
756
                throw new Exception(get_string('error_diff_firstlist_mismatch', 'qtype_formulas', $i));
34✔
757
            }
758
            if ($second[$i]->type !== $type) {
731✔
759
                throw new Exception(get_string('error_diff_secondlist_mismatch', 'qtype_formulas', $i));
34✔
760
            }
761
        }
762

763
        // If we are working with numbers, we can directly calculate the differences and return.
764
        if ($type === token::NUMBER) {
663✔
765
            // The user should not specify a third argument when working with numbers.
766
            if ($n !== null) {
34✔
767
                throw new Exception(get_string('error_diff_third', 'qtype_formulas'));
17✔
768
            }
769

770
            $result = [];
17✔
771
            for ($i = 0; $i < $count; $i++) {
17✔
772
                $diff = abs($first[$i]->value - $second[$i]->value);
17✔
773
                $result[$i] = token::wrap($diff);
17✔
774
            }
775
            return $result;
17✔
776
        }
777

778
        // If the user did not specify $n, we set it to 100, for backwards compatibility.
779
        if ($n === null) {
629✔
780
            $n = 100;
17✔
781
        }
782

783
        $result = [];
629✔
784
        // Iterate over all strings and calculate the root mean square difference between the two expressions.
785
        for ($i = 0; $i < $count; $i++) {
629✔
786
            $result[$i] = 0;
629✔
787
            $expression = "({$first[$i]}) - ({$second[$i]})";
629✔
788

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

814
        return $result;
629✔
815
    }
816

817
    /**
818
     * Evaluate the given thing, e. g. an expression or a for loop.
819
     *
820
     * @param expression|for_loop $input
821
     * @param bool $godmode whether one should be allowed to modify reserved variables like e.g. _a or _0
822
     * @return token|void
823
     */
824
    private function evaluate_the_right_thing($input, bool $godmode = false) {
825
        if ($input instanceof expression) {
7,684✔
826
            return $this->evaluate_single_expression($input, $godmode);
7,650✔
827
        }
828
        if ($input instanceof for_loop) {
391✔
829
            return $this->evaluate_for_loop($input);
374✔
830
        }
831
        throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate_the_right_thing()'));
17✔
832
    }
833

834
    /**
835
     * Evaluate a single expression or an array of expressions.
836
     *
837
     * @param expression|for_loop|array $input
838
     * @param bool $godmode whether to run the evaluation in god mode
839
     * @return token|array
840
     */
841
    public function evaluate($input, bool $godmode = false) {
842
        if (($input instanceof expression) || ($input instanceof for_loop)) {
7,718✔
843
            return $this->evaluate_the_right_thing($input, $godmode);
918✔
844
        }
845
        if (!is_array($input)) {
7,531✔
846
            throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate()'));
17✔
847
        }
848
        $result = [];
7,531✔
849
        foreach ($input as $single) {
7,531✔
850
            $result[] = $this->evaluate_the_right_thing($single, $godmode);
7,497✔
851
        }
852
        return $result;
6,188✔
853
    }
854

855
    /**
856
     * Evaluate a for loop.
857
     *
858
     * @param for_loop $loop
859
     * @return void
860
     */
861
    private function evaluate_for_loop(for_loop $loop) {
862
        $rangetoken = $this->evaluate_single_expression($loop->range);
374✔
863
        $range = $rangetoken->value;
374✔
864
        $result = null;
374✔
865
        foreach ($range as $iterationvalue) {
374✔
866
            $this->set_variable_to_value($loop->variable, $iterationvalue);
374✔
867
            $result = $this->evaluate($loop->body);
374✔
868
        }
869
        $this->clear_stack();
374✔
870
        return end($result);
374✔
871
    }
872

873
    /**
874
     * Evaluate an expression, e. g. an assignment, a function call or a calculation.
875
     *
876
     * @param expression $expression
877
     * @param bool $godmode
878
     * @return token
879
     */
880
    private function evaluate_single_expression(expression $expression, bool $godmode = false): token {
881
        foreach ($expression->body as $token) {
7,684✔
882
            $type = $token->type;
7,684✔
883
            $value = $token->value;
7,684✔
884

885
            $isliteral = ($type & token::ANY_LITERAL);
7,684✔
886
            $isopening = ($type === token::OPENING_BRACE || $type === token::OPENING_BRACKET);
7,684✔
887
            $isvariable = ($type === token::VARIABLE);
7,684✔
888

889
            // Many tokens go directly to the stack.
890
            if ($isliteral || $isopening || $isvariable) {
7,684✔
891
                $this->stack[] = $token;
7,582✔
892
                continue;
7,582✔
893
            }
894

895
            // Constants are resolved and sent to the stack.
896
            if ($type === token::CONSTANT) {
7,514✔
897
                $this->stack[] = $this->resolve_constant($token);
170✔
898
                continue;
153✔
899
            }
900

901
            if ($type === token::OPERATOR) {
7,446✔
902
                if ($this->is_unary_operator($token)) {
7,259✔
903
                    $this->stack[] = $this->execute_unary_operator($token);
833✔
904
                }
905
                // The = operator is binary, but we treat it separately.
906
                if ($value === '=' || $value === 'r=') {
7,242✔
907
                    $israndomvar = ($value === 'r=');
4,182✔
908
                    $this->godmode = $godmode;
4,182✔
909
                    $this->stack[] = $this->execute_assignment($israndomvar);
4,182✔
910
                    $this->godmode = false;
4,029✔
911
                } else if ($this->is_binary_operator($token)) {
7,004✔
912
                    $this->stack[] = $this->execute_binary_operator($token);
3,876✔
913
                }
914
                // The %%ternary-sentinel pseudo-token goes on the stack where it will
915
                // help detect ternary expressions with too few arguments.
916
                if ($value === '%%ternary-sentinel') {
6,800✔
917
                    $this->stack[] = $token;
425✔
918
                }
919
                // When executing the ternary operator, we pass it the operator token
920
                // in order to have best possible error reporting.
921
                if ($value === '%%ternary') {
6,800✔
922
                    $this->stack[] = $this->execute_ternary_operator($token);
442✔
923
                }
924
                if ($value === '%%arrayindex') {
6,800✔
925
                    $this->stack[] = $this->fetch_array_element_or_char();
714✔
926
                }
927
                if ($value === '%%setbuild' || $value === '%%arraybuild') {
6,783✔
928
                    $this->stack[] = $this->build_set_or_array($value);
4,029✔
929
                }
930
                if ($value === '%%rangebuild') {
6,783✔
931
                    $elements = $this->build_range();
1,972✔
932
                    array_push($this->stack, ...$elements);
1,904✔
933
                }
934
            }
935

936
            if ($type === token::FUNCTION) {
6,902✔
937
                $this->stack[] = $this->execute_function($token);
2,006✔
938
            }
939

940
        }
941
        // If the stack contains more than one element, there must have been a problem somewhere.
942
        if (count($this->stack) !== 1) {
6,579✔
943
            throw new Exception(get_string('error_stacksize', 'qtype_formulas'));
17✔
944
        }
945
        // If the stack only contains one single variable token, return its content.
946
        // Otherwise, return the token.
947
        return $this->pop_real_value();
6,562✔
948
    }
949

950
    /**
951
     * Fetch an element from a list or a char from a string. The index and the list or string will
952
     * be taken from the stack.
953
     *
954
     * @return token the desired list element or char
955
     */
956
    private function fetch_array_element_or_char(): token {
957
        $indextoken = $this->pop_real_value();
714✔
958
        $index = $indextoken->value;
714✔
959
        $nexttoken = array_pop($this->stack);
714✔
960

961
        // Make sure there is only one index.
962
        if ($nexttoken->type !== token::OPENING_BRACKET) {
714✔
963
            $this->die(get_string('error_onlyoneindex', 'qtype_formulas'), $indextoken);
51✔
964
        }
965

966
        // Fetch the array or string from the stack.
967
        $arraytoken = array_pop($this->stack);
680✔
968

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

977
        // Before accessing the array or string, we validate the index and, if necessary,
978
        // we translate a negative index to the corresponding positive value.
979
        $array = $arraytoken->value;
204✔
980
        $index = $this->validate_array_or_string_index($array, $index, $nexttoken);
204✔
981
        $element = $array[$index];
170✔
982

983
        // If we are accessing a string's char, we create a new string token.
984
        if ($arraytoken->type === token::STRING) {
170✔
985
            return new token(token::STRING, $element, $arraytoken->row, $arraytoken->column + $index);
17✔
986
        }
987
        // Otherwise, the element is already wrapped in a token.
988
        return $element;
153✔
989
    }
990

991
    /**
992
     * Build a list of (NUMBER) tokens based on a range definition. The lower and upper limit
993
     * and, if present, the step will be taken from the stack.
994
     *
995
     * @return array
996
     */
997
    private function build_range(): array {
998
        // Pop the number of parts. We generated it ourselves, so we know it will be 2 or 3.
999
        $parts = array_pop($this->stack)->value;
1,972✔
1000

1001
        $step = 1;
1,972✔
1002
        // If we have 3 parts, extract the step size. Conserve the token in case of an error.
1003
        if ($parts === 3) {
1,972✔
1004
            $steptoken = $this->pop_real_value();
391✔
1005
            // Abort with nice error message, if step is not numeric.
1006
            $this->abort_if_not_scalar($steptoken);
391✔
1007
            $step = $steptoken->value;
391✔
1008
        }
1009

1010
        // Step must not be zero.
1011
        if ($step == 0) {
1,972✔
1012
            $this->die(get_string('error_stepzero', 'qtype_formulas'), $steptoken);
17✔
1013
        }
1014

1015
        // Fetch start and end of the range. Conserve token for the end value, in case of an error.
1016
        $endtoken = $this->pop_real_value();
1,955✔
1017
        $end = $endtoken->value;
1,955✔
1018
        $starttoken = $this->pop_real_value();
1,955✔
1019
        $start = $starttoken->value;
1,955✔
1020

1021
        // Abort with nice error message, if start or end is not numeric.
1022
        $this->abort_if_not_scalar($starttoken);
1,955✔
1023
        $this->abort_if_not_scalar($endtoken);
1,955✔
1024

1025
        if ($start === $end) {
1,955✔
1026
            $this->die(get_string('error_samestartend', 'qtype_formulas'), $endtoken);
34✔
1027
        }
1028

1029
        if (($end - $start) * $step < 0) {
1,921✔
1030
            if ($parts === 3) {
34✔
1031
                $a = (object)['start' => $start, 'end' => $end, 'step' => $step];
17✔
1032
                $this->die(get_string('error_emptyrange', 'qtype_formulas', $a), $steptoken);
17✔
1033
            }
1034
            $step = -$step;
17✔
1035
        }
1036

1037
        $result = [];
1,904✔
1038
        $numofsteps = ($end - $start) / $step;
1,904✔
1039
        // Choosing multiplication of step instead of repeated addition for better numerical accuracy.
1040
        for ($i = 0; $i < $numofsteps; $i++) {
1,904✔
1041
            $result[] = new token(token::NUMBER, $start + $i * $step);
1,904✔
1042
        }
1043
        return $result;
1,904✔
1044
    }
1045

1046
    /**
1047
     * Create a SET or LIST token based on elements on the stack.
1048
     *
1049
     * @param string $type whether to build a SET or a LIST
1050
     * @return token
1051
     */
1052
    private function build_set_or_array(string $type): token {
1053
        if ($type === '%%setbuild') {
4,029✔
1054
            $delimitertype = token::OPENING_BRACE;
1,938✔
1055
            $outputtype = token::SET;
1,938✔
1056
        } else {
1057
            $delimitertype = token::OPENING_BRACKET;
2,975✔
1058
            $outputtype = token::LIST;
2,975✔
1059
        }
1060
        $elements = [];
4,029✔
1061
        $head = end($this->stack);
4,029✔
1062
        while ($head !== false) {
4,029✔
1063
            if ($head->type === $delimitertype) {
4,029✔
1064
                array_pop($this->stack);
4,029✔
1065
                break;
4,029✔
1066
            }
1067
            $elements[] = $this->pop_real_value();
3,978✔
1068
            $head = end($this->stack);
3,978✔
1069
        }
1070
        // Return reversed list, because the stack ist LIFO.
1071
        return new token($outputtype, array_reverse($elements));
4,029✔
1072
    }
1073

1074
    /**
1075
     * Whether a given OPERATOR token is an unary operator.
1076
     *
1077
     * @param token $token
1078
     * @return bool
1079
     */
1080
    private function is_unary_operator(token $token): bool {
1081
        return in_array($token->value, ['_', '!', '~']);
7,259✔
1082
    }
1083

1084
    /**
1085
     * Whether a given OPERATOR token expects its argument(s) to be numbers.
1086
     *
1087
     * @param token $token
1088
     * @return bool
1089
     */
1090
    private function needs_numeric_input(token $token): bool {
1091
        $operators = ['_', '~', '**', '*', '/', '%', '-', '<<', '>>', '&', '^', '|', '&&', '||'];
4,029✔
1092
        return in_array($token->value, $operators);
4,029✔
1093
    }
1094

1095
    /**
1096
     * In many cases, operators need a numeric or at least a scalar operand to work properly.
1097
     * This function does the necessary check and prepares a human-friendly error message
1098
     * if the conditions are not met.
1099
     *
1100
     * @param token $token the token to check
1101
     * @param boolean $enforcenumeric whether the value must be numeric in addition to being scalar
1102
     * @return void
1103
     * @throws Exception
1104
     */
1105
    private function abort_if_not_scalar(token $token, bool $enforcenumeric = true): void {
1106
        $found = '';
4,505✔
1107
        $a = (object)[];
4,505✔
1108
        if ($token->type !== token::NUMBER) {
4,505✔
1109
            if ($token->type === token::SET) {
153✔
1110
                $found = '_algebraicvar';
17✔
1111
                $value = "algebraic variable";
17✔
1112
            } else if ($token->type === token::LIST) {
136✔
1113
                $found = '_list';
51✔
1114
                $value = "list";
51✔
1115
            } else if ($enforcenumeric) {
102✔
1116
                $a->found = "'{$token->value}'";
34✔
1117
            } else if ($token->type === token::STRING) {
68✔
1118
                return;
68✔
1119
            }
1120
            $expected = ($enforcenumeric ? 'number' : 'scalar');
102✔
1121

1122
            $this->die(get_string("error_expected_{$expected}_found{$found}", 'qtype_formulas', $a), $token);
102✔
1123
        }
1124
    }
1125

1126
    /**
1127
     * Whether a given OPERATOR token is a binary operator.
1128
     *
1129
     * @param token $token
1130
     * @return bool
1131
     */
1132
    private function is_binary_operator(token $token): bool {
1133
        $binaryoperators = ['=', '**', '*', '/', '%', '+', '-', '<<', '>>', '&', '^',
7,004✔
1134
            '|', '&&', '||', '<', '>', '==', '>=', '<=', '!='];
7,004✔
1135

1136
        return in_array($token->value, $binaryoperators);
7,004✔
1137
    }
1138

1139
    /**
1140
     * Assign a value to a variable. The value and the variable name are taken from the stack.
1141
     *
1142
     * @param boolean $israndomvar
1143
     * @return token the assigned value
1144
     */
1145
    private function execute_assignment($israndomvar = false): token {
1146
        $what = $this->pop_real_value();
4,182✔
1147
        $destination = array_pop($this->stack);
4,182✔
1148

1149
        // When storing a value in a variable, the row and column should be
1150
        // set to the row and column of the variable token.
1151
        $what->row = $destination->row;
4,182✔
1152
        $what->column = $destination->column;
4,182✔
1153

1154
        // The destination must be a variable token.
1155
        if ($destination->type !== token::VARIABLE) {
4,182✔
1156
            $this->die(get_string('error_variablelhs', 'qtype_formulas'), $destination);
34✔
1157
        }
1158
        return $this->set_variable_to_value($destination, $what, $israndomvar);
4,148✔
1159
    }
1160

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

1188
        $condition = $this->pop_real_value();
391✔
1189
        return ($condition->value ? $then : $else);
391✔
1190
    }
1191

1192
    /**
1193
     * Apply an unary operator to the token that is currently on top of the stack.
1194
     *
1195
     * @param token $token operator token
1196
     * @return token result
1197
     */
1198
    private function execute_unary_operator($token) {
1199
        $input = $this->pop_real_value();
833✔
1200

1201
        // Check if the input is numeric. Boolean values are internally treated as 1 and 0 for
1202
        // backwards compatibility.
1203
        if ($this->needs_numeric_input($token)) {
833✔
1204
            $this->abort_if_not_scalar($input);
799✔
1205
        }
1206

1207
        $result = functions::apply_unary_operator($token->value, $input->value);
799✔
1208
        return token::wrap($result);
799✔
1209
    }
1210

1211
    /**
1212
     * Apply a binary operator to the two elements currently on top of the stack.
1213
     *
1214
     * @param token $optoken operator token
1215
     * @return token result
1216
     */
1217
    private function execute_binary_operator($optoken) {
1218
        // The stack is LIFO, so we pop the second operand first.
1219
        $secondtoken = $this->pop_real_value();
3,876✔
1220
        $firsttoken = $this->pop_real_value();
3,655✔
1221

1222
        // Abort with nice error message, if arguments should be numeric but are not.
1223
        if ($this->needs_numeric_input($optoken)) {
3,655✔
1224
            $this->abort_if_not_scalar($firsttoken);
2,635✔
1225
            $this->abort_if_not_scalar($secondtoken);
2,601✔
1226
        }
1227

1228
        $first = $firsttoken->value;
3,621✔
1229
        $second = $secondtoken->value;
3,621✔
1230

1231
        // For + (string concatenation or addition) we check the arguments here, even if another
1232
        // check is done in functions::apply_binary_operator(), because this allows for better
1233
        // error reporting.
1234
        if ($optoken->value === '+') {
3,621✔
1235
            // If at least one operand is a string, both values must be scalar, but
1236
            // not necessarily numeric; we use concatenation instead of addition.
1237
            // In all other cases, addition must (currently) be numeric, so we abort
1238
            // if the arguments are not numbers.
1239
            $acceptstring = is_string($first) || is_string($second);
1,802✔
1240
            $this->abort_if_not_scalar($firsttoken, !$acceptstring);
1,802✔
1241
            $this->abort_if_not_scalar($secondtoken, !$acceptstring);
1,785✔
1242
        }
1243

1244
        try {
1245
            $result = functions::apply_binary_operator($optoken->value, $first, $second);
3,587✔
1246
        } catch (Exception $e) {
272✔
1247
            $this->die($e->getMessage(), $optoken);
272✔
1248
        }
1249
        return token::wrap($result);
3,315✔
1250
    }
1251

1252
    /**
1253
     * Check whether the number of parameters is valid for a given function.
1254
     *
1255
     * @param token $function FUNCTION token containing the function name
1256
     * @param int $count number of arguments
1257
     * @return bool
1258
     */
1259
    private function is_valid_num_of_params(token $function, int $count): bool {
1260
        $funcname = $function->value;
2,006✔
1261
        $min = INF;
2,006✔
1262
        $max = -INF;
2,006✔
1263
        // Union gives precedence to first array, so we are able to override a
1264
        // built-in function.
1265
        $allfunctions = functions::FUNCTIONS + self::PHPFUNCTIONS;
2,006✔
1266
        if (array_key_exists($funcname, $allfunctions)) {
2,006✔
1267
            $min = $allfunctions[$funcname][0];
1,989✔
1268
            $max = $allfunctions[$funcname][1];
1,989✔
1269
            return $count >= $min && $count <= $max;
1,989✔
1270
        }
1271
        // Still here? That means the function is unknown.
1272
        $this->die(get_string('error_unknownfunction', 'qtype_formulas', $funcname), $function);
17✔
1273
    }
1274

1275
    /**
1276
     * Lookup the value of a constant and return its value.
1277
     *
1278
     * @param token $token CONSTANT token containing the constant's name
1279
     * @return token value of the requested constant
1280
     */
1281
    private function resolve_constant($token): token {
1282
        if (array_key_exists($token->value, $this->constants)) {
170✔
1283
            return new token(token::NUMBER, $this->constants[$token->value], $token->row, $token->column);
153✔
1284
        }
1285
        $this->die(get_string('error_undefinedconstant', 'qtype_formulas', $token->value), $token);
17✔
1286
    }
1287

1288
    /**
1289
     * Execute a given function, taking the needed argument(s) from the stack.
1290
     *
1291
     * @param token $token FUNCTION token containing the function's name.
1292
     * @return token result
1293
     */
1294
    private function execute_function(token $token): token {
1295
        $funcname = $token->value;
2,006✔
1296

1297
        // Fetch the number of params from the stack. Keep the token in case of an error.
1298
        $numparamstoken = array_pop($this->stack);
2,006✔
1299
        $numparams = $numparamstoken->value;
2,006✔
1300

1301
        // Check if the number of params is valid for the given function. If it is not,
1302
        // die with an error message.
1303
        if (!$this->is_valid_num_of_params($token, $numparams)) {
2,006✔
NEW
1304
            $a = (object)['function' => $funcname, 'count' => $numparams];
×
NEW
1305
            $this->die(get_string('error_func_argcount', 'qtype_formulas', $a), $token);
×
1306
        }
1307

1308
        // Fetch the params from the stack and reverse their order, because the stack is LIFO.
1309
        $params = [];
1,989✔
1310
        for ($i = 0; $i < $numparams; $i++) {
1,989✔
1311
            $params[] = $this->pop_real_value()->value;
1,989✔
1312
        }
1313
        $params = array_reverse($params);
1,989✔
1314

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

1339
        // Some of our own functions may return a token. In those cases, we reset
1340
        // the row and column value, because they are no longer accurate. Once that
1341
        // is done, we return the token.
1342
        if ($result instanceof token) {
1,751✔
NEW
1343
            $result->row = -1;
×
NEW
1344
            $result->column = -1;
×
NEW
1345
            return $result;
×
1346
        }
1347

1348
        // Most of the time, the return value will not be a token. In those cases,
1349
        // we have to wrap it up before returning.
1350
        return token::wrap($result);
1,751✔
1351
    }
1352
}
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