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

FormulasQuestion / moodle-qtype_formulas / 17019138792

17 Aug 2025 09:01AM UTC coverage: 97.399% (-0.2%) from 97.629%
17019138792

Pull #264

github

web-flow
Merge f09dd629a into c107cc5e2
Pull Request #264: Allow parts to have empty fields

78 of 92 new or added lines in 10 files covered. (84.78%)

12 existing lines in 5 files now uncovered.

4381 of 4498 relevant lines covered (97.4%)

1618.81 hits per line

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

98.59
/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
defined('MOODLE_INTERNAL') || die();
×
23

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,612✔
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,612✔
207

208
        // If a context is given, we initialize our variables accordingly.
209
        if (key_exists('randomvariables', $context) && key_exists('variables', $context)) {
24,612✔
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,612✔
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,507✔
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,570✔
273
            return $this->variables[$varname];
3,192✔
274
        }
275
        $result = $this->get_variable_value(token::wrap($varname));
378✔
276
        return $result;
378✔
277
    }
278

279
    /**
280
     * FIXME
281
     *
282
     * @param string $name name of the variable
283
     * @param variable $variable variable instance
284
     */
285
    public function import_single_variable(string $name, variable $variable, bool $overwrite = false): void {
286
        if (array_key_exists($name, $this->variables) && !$overwrite) {
21✔
287
            return;
21✔
288
        }
289

290
        $this->variables[$name] = $variable;
21✔
291
    }
292

293
    /**
294
     * Calculate the number of possible variants according to the defined random variables.
295
     *
296
     * @return int
297
     */
298
    public function get_number_of_variants(): int {
299
        $result = 1;
273✔
300
        foreach ($this->randomvariables as $var) {
273✔
301
            $num = $var->how_many();
273✔
302
            if ($num > PHP_INT_MAX / $result) {
273✔
303
                return PHP_INT_MAX;
21✔
304
            }
305
            $result = $result * $num;
273✔
306
        }
307
        return $result;
252✔
308
    }
309

310
    /**
311
     * Instantiate random variables, i. e. assigning a fixed value to them and make them available
312
     * as regular global variables.
313
     *
314
     * @param int|null $seed initialization seed for the PRNG
315
     * @return void
316
     */
317
    public function instantiate_random_variables(?int $seed = null): void {
318
        if (isset($seed)) {
756✔
319
            mt_srand($seed);
21✔
320
        }
321
        foreach ($this->randomvariables as $var) {
756✔
322
            $value = $var->instantiate();
735✔
323
            $this->set_variable_to_value(token::wrap($var->name, token::VARIABLE), $value);
735✔
324
        }
325
    }
326

327
    /**
328
     * Import an existing variable context, e.g. from another evaluator class.
329
     * If the same variable exists in our context and the incoming context, the
330
     * incoming context will overwrite our data. This can be avoided by setting
331
     * the optional parameter to false.
332
     *
333
     * @param array $data serialized context for randomvariables and variables
334
     * @param bool $overwrite whether to overwrite existing data with incoming context
335
     * @return void
336
     */
337
    public function import_variable_context(array $data, bool $overwrite = true) {
338
        // If the data is invalid, unserialize() will issue an E_NOTICE. We suppress that,
339
        // because we have our own error message.
340
        $randomvariables = @unserialize(
21✔
341
            $data['randomvariables'],
21✔
342
            ['allowed_classes' => [random_variable::class, token::class, lazylist::class, range::class]],
21✔
343
        );
21✔
344
        $variables = @unserialize($data['variables'], ['allowed_classes' => [variable::class, token::class, lazylist::class]]);
21✔
345
        if ($randomvariables === false || $variables === false) {
21✔
346
            throw new Exception(get_string('error_invalidcontext', 'qtype_formulas'));
21✔
347
        }
348
        foreach ($variables as $name => $var) {
21✔
349
            // New variables are added.
350
            // Existing variables are only overwritten, if $overwrite is true.
351
            $notknownyet = !array_key_exists($name, $this->variables);
21✔
352
            if ($notknownyet || $overwrite) {
21✔
353
                $this->variables[$name] = $var;
21✔
354
            }
355
        }
356
        foreach ($randomvariables as $name => $var) {
21✔
357
            // New variables are added.
358
            // Existing variables are only overwritten, if $overwrite is true.
359
            $notknownyet = !array_key_exists($name, $this->randomvariables);
21✔
360
            if ($notknownyet || $overwrite) {
21✔
361
                $this->randomvariables[$name] = $var;
21✔
362
            }
363
        }
364
    }
365

366
    /**
367
     * Set the variable defined in $token to the value $value and correctly set
368
     * it's $type attribute.
369
     *
370
     * @param token $vartoken
371
     * @param token $value
372
     * @param bool $definingrandomvar
373
     * @return token
374
     */
375
    private function set_variable_to_value(token $vartoken, token $value, $definingrandomvar = false): token {
376
        // Get the "basename" of the variable, e.g. foo in case of foo[1][2].
377
        $basename = $vartoken->value;
8,316✔
378
        if (strpos($basename, '[') !== false) {
8,316✔
379
            $basename = strstr($basename, '[', true);
294✔
380
        }
381

382
        // Some variables are reserved and cannot be used as left-hand side in an assignment,
383
        // unless the evaluator is currently in god mode.
384
        // Note that _m is not a reserved name in itself, but the placeholder {_m} is accepted
385
        // by the renderer to mark the position of the feedback image. Allowing that variable
386
        // could lead to conflicts, so we do not allow it.
387
        $isreserved = in_array($basename, ['_err', '_relerr', '_a', '_r', '_d', '_u', '_m']);
8,316✔
388
        $isanswer = preg_match('/^_\d+$/', $basename);
8,316✔
389
        // We will -- at least for the moment -- block all variables starting with an underscore,
390
        // because we might one day need some internal variables or the like.
391
        $underscore = strpos($basename, '_') === 0;
8,316✔
392
        if ($underscore && $this->godmode === false) {
8,316✔
393
            $this->die(get_string('error_invalidvarname', 'qtype_formulas', $basename), $value);
21✔
394
        }
395

396
        // If there are no indices, we set the variable as requested.
397
        if ($basename === $vartoken->value) {
8,295✔
398
            // If we are assigning to a random variable, we create a new instance and
399
            // return the value of the first instantiation.
400
            if ($definingrandomvar) {
8,274✔
401
                $useshuffle = $value->type === variable::LIST;
798✔
402
                if (is_scalar($value->value)) {
798✔
403
                    $this->die(get_string('error_invalidrandvardef', 'qtype_formulas'), $value);
21✔
404
                }
405
                $randomvar = new random_variable($basename, $value->value, $useshuffle);
777✔
406
                $this->randomvariables[$basename] = $randomvar;
777✔
407
                return token::wrap($randomvar->reservoir);
777✔
408
            }
409

410
            // Otherwise we return the stored value. If the data is a SET, the variable is an
411
            // algebraic variable.
412
            if ($value->type === token::SET) {
8,232✔
413
                // Algebraic variables only accept a list of numbers; they must not contain
414
                // strings or nested lists.
415
                if (!$value->value->are_all_numeric()) {
1,890✔
416
                    $this->die(get_string('error_algvar_numbers', 'qtype_formulas'), $value);
105✔
417
                }
418
                $value->type = variable::ALGEBRAIC;
1,785✔
419
            }
420
            $var = new variable($basename, $value->value, $value->type, microtime(true));
8,127✔
421
            $this->variables[$basename] = $var;
8,127✔
422
            return token::wrap($var->value);
8,127✔
423
        }
424

425
        // If there is an index, we MUST NOT be in an assignment of random variables (in the random variables
426
        // section of the question). Also the target variable MUST NOT be a random variable, unless it is a
427
        // "shuffle" variable, i. e. it containis a shuffled array.
428
        $cannotsetelement = false;
294✔
429
        if (array_key_exists($basename, $this->randomvariables)) {
294✔
430
            $cannotsetelement = !$this->randomvariables[$basename]->shuffle;
21✔
431
        }
432
        if ($definingrandomvar || $cannotsetelement) {
294✔
433
            $this->die(get_string('error_setindividual_randvar', 'qtype_formulas'), $value);
42✔
434
        }
435

436
        // If there is an index and we are setting an algebraic variable, we throw an error.
437
        if ($this->variables[$basename]->type === variable::ALGEBRAIC) {
273✔
438
            $this->die(get_string('error_setindividual_algebraicvar', 'qtype_formulas'), $value);
21✔
439
        }
440

441
        // If there is an index, but the variable is a string, we throw an error. Setting
442
        // characters of a string in this way is not allowed.
443
        if ($this->variables[$basename]->type === variable::STRING) {
252✔
444
            $this->die(get_string('error_setindividual_string', 'qtype_formulas'), $value);
21✔
445
        }
446

447
        // Otherwise, we try to get the variable's value. The function will
448
        // - resolve indices correctly
449
        // - throw an error, if the variable does not exist
450
        // so we can just rely on that.
451
        $current = $this->get_variable_value($vartoken);
231✔
452

453
        // Array elements are stored as tokens rather than just values (because
454
        // each element can have a different type). That means, we received an
455
        // object or rather a reference to an object. Thus, if we change the value and
456
        // type attribute of that token object, it will automatically be changed
457
        // inside the array itself.
458
        $current->value = $value->value;
231✔
459
        $current->type = $value->type;
231✔
460
        // Update timestamp for the base variable.
461
        $this->variables[$basename]->timestamp = microtime(true);
231✔
462

463
        // Finally, we return what has been stored.
464
        return $current;
231✔
465
    }
466

467
    /**
468
     * Make sure the index is valid, i. e. an integer (as a number or string) and not out
469
     * of range. If needed, translate a negative index (count from end) to a 0-indexed value.
470
     *
471
     * @param mixed $arrayorstring array, lazylist or string that should be indexed
472
     * @param mixed $index the index
473
     * @param ?token $anchor anchor token used in case of error (may be the array or the index)
474
     * @return int
475
     */
476
    private function validate_array_or_string_index($arrayorstring, $index, ?token $anchor = null): int {
477
        // Check if the index is a number. If it is not, try to convert it.
478
        // If conversion fails, throw an error.
479
        if (!is_numeric($index)) {
882✔
480
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
42✔
481
        }
482
        $index = floatval($index);
840✔
483

484
        // If the index is not a whole number, throw an error. A whole number in float
485
        // representation is fine, though.
486
        if ($index - intval($index) != 0) {
840✔
487
            $this->die(get_string('error_expected_intindex', 'qtype_formulas', $index), $anchor);
42✔
488
        }
489
        $index = intval($index);
798✔
490

491
        // Fetch the length of the array or string.
492
        if (is_string($arrayorstring)) {
798✔
493
            $len = strlen($arrayorstring);
126✔
494
        } else if (is_array($arrayorstring) || $arrayorstring instanceof lazylist) {
672✔
495
            $len = count($arrayorstring);
630✔
496
        } else {
497
            $this->die(get_string('error_notindexable', 'qtype_formulas'), $anchor);
42✔
498
        }
499

500
        // Negative indices can be used to count "from the end". For strings, this is
501
        // directly supported in PHP, but not for arrays. So for the sake of simplicity,
502
        // we do our own preprocessing.
503
        if ($index < 0) {
756✔
504
            $index = $index + $len;
147✔
505
        }
506
        // Now check if the index is out of range. We use the original value from the token.
507
        if ($index > $len - 1 || $index < 0) {
756✔
508
            $this->die(get_string('error_indexoutofrange', 'qtype_formulas', $index), $anchor);
126✔
509
        }
510

511
        return $index;
672✔
512
    }
513

514
    /**
515
     * Get the value token that is stored in a variable. If the token is a literal
516
     * (number, string, array, set), just return the value directly.
517
     *
518
     * @param token $variable
519
     * @return token
520
     */
521
    private function get_variable_value(token $variable): token {
522
        // The raw name may contain indices, e.g. a[1][2]. We split at the [ and
523
        // take the first chunk as the true variable name. If there are no brackets,
524
        // there will be only one chunk and everything is fine.
525
        $rawname = $variable->value;
2,898✔
526
        $parts = explode('[', $rawname);
2,898✔
527
        $name = array_shift($parts);
2,898✔
528
        if (!array_key_exists($name, $this->variables)) {
2,898✔
529
            $this->die(get_string('error_unknownvarname', 'qtype_formulas', $name), $variable);
336✔
530
        }
531
        $result = $this->variables[$name];
2,646✔
532

533
        // If we access the variable as a whole, we return a new token
534
        // created from the stored value and type.
535
        if (count($parts) === 0) {
2,646✔
536
            $type = $result->type;
2,205✔
537
            // In algebraic mode, an algebraic variable will resolve to a random value
538
            // from its reservoir.
539
            if ($type === variable::ALGEBRAIC) {
2,205✔
540
                if ($this->algebraicmode) {
798✔
541
                    // We re-seed the random generator with a preset value and the CRC32 of the
542
                    // variable's name. The preset will be changed by the calculate_algebraic_expression()
543
                    // function. This makes sure that while evaluating one single expression, we will
544
                    // get the same value for the same variable. Adding the variable name into the seed
545
                    // gives the chance to not have the same value for different variables with the
546
                    // same reservoir, even though this is not guaranteed, especially if the reservoir is
547
                    // small.
548
                    mt_srand($this->seed + crc32($name));
714✔
549

550
                    $randomindex = mt_rand(0, count($result->value) - 1);
714✔
551
                    $randomelement = $result->value[$randomindex];
714✔
552
                    $value = $randomelement->value;
714✔
553
                    $type = $randomelement->type;
714✔
554
                } else {
555
                    // If we are not in algebraic mode, it does not make sense to get the value of an algebraic
556
                    // variable.
557
                    $this->die(get_string('error_cannotusealgebraic', 'qtype_formulas', $name), $variable);
390✔
558
                }
559
            } else {
560
                $value = $result->value;
1,449✔
561
            }
562
            return new token($type, $value, $variable->row, $variable->column);
2,163✔
563
        }
564

565
        // If we do have indices, we access them one by one. The ] at the end of each
566
        // part must be stripped.
567
        foreach ($parts as $part) {
651✔
568
            // Validate the index and, if necessary, convert a negative index to the corresponding
569
            // positive value.
570
            $index = $this->validate_array_or_string_index($result->value, substr($part, 0, -1), $variable);
651✔
571
            $result = $result->value[$index];
483✔
572
        }
573

574
        // When accessing an array, the elements are already stored as tokens, so we return them
575
        // as they are. This allows the receiver to change values inside the array, because
576
        // objects are passed by reference.
577
        // For strings, we must create a new token, because we only get a character.
578
        if (is_string($result)) {
483✔
579
            return new token(token::STRING, $result, $variable->row, $variable->column);
63✔
580
        }
581
        return $result;
420✔
582
    }
583

584
    /**
585
     * Stop evaluating and indicate the human readable position (row/column) where the error occurred.
586
     *
587
     * @param string $message error message
588
     * @param token $offendingtoken the token where the error occurred
589
     * @throws Exception
590
     */
591
    private function die(string $message, token $offendingtoken) {
592
        throw new Exception($offendingtoken->row . ':' . $offendingtoken->column . ':' . $message);
6,741✔
593
    }
594

595
    /**
596
     * Pop top element from the stack. If the token is a literal (number, string, list etc.), return it
597
     * directly. If it is a variable, resolve it and return its content.
598
     *
599
     * @return token
600
     */
601
    private function pop_real_value(): token {
602
        if (empty($this->stack)) {
21,693✔
603
            throw new Exception(get_string('error_emptystack', 'qtype_formulas'));
21✔
604
        }
605
        $token = array_pop($this->stack);
21,672✔
606
        if ($token->type === token::VARIABLE) {
21,672✔
607
            return $this->get_variable_value($token);
2,436✔
608
        }
609
        return $token;
21,462✔
610
    }
611

612
    /**
613
     * Take an algebraic expression, resolve its variables and calculate its value. For each
614
     * algebraic variable, a random value among its possible values will be taken.
615
     *
616
     * @param string $expression algebraic expression
617
     * @return token
618
     */
619
    public function calculate_algebraic_expression(string $expression): token {
620
        // Parse the expression. It will parsed by the answer parser, i. e. the ^ operator
621
        // will mean exponentiation rather than XOR, as per the documented behaviour.
622
        // As the expression might contain a PREFIX operator (from a model answer), we
623
        // set the fourth parameter of the constructor to TRUE.
624
        // Note that this step will also throw an error, if the expression is empty.
625
        $parser = new answer_parser($expression, $this->export_variable_list(), true, true);
966✔
626
        if (!$parser->is_acceptable_for_answertype(qtype_formulas::ANSWER_TYPE_ALGEBRAIC)) {
966✔
627
            throw new Exception(get_string('error_invalidalgebraic', 'qtype_formulas', $expression));
84✔
628
        }
629

630
        // Setting the evaluator's seed to the current time. If the function is called several
631
        // times in short intervals, we want to make sure the seed still changes.
632
        $lastseed = $this->seed;
903✔
633
        $this->seed = time();
903✔
634
        if ($lastseed >= $this->seed) {
903✔
635
            $this->seed = $lastseed + 1;
798✔
636
            $lastseed = $this->seed;
798✔
637
        }
638

639
        // Now evaluate the expression and return the result. By saving the stack and restoring
640
        // it afterwards, we create an empty substack for this evaluation only.
641
        $this->algebraicmode = true;
903✔
642
        $oldstack = $this->stack;
903✔
643
        $this->clear_stack();
903✔
644
        // Evaluation might fail. In that case, it is important to assure that the old stack
645
        // is re-established and that algebraic mode is turned off.
646
        try {
647
            $result = $this->evaluate($parser->get_statements()[0]);
903✔
648
        } catch (Exception $e) {
21✔
649
            ;
650
        } finally {
651
            $this->stack = $oldstack;
903✔
652
            $this->algebraicmode = false;
903✔
653
            // If we have an exception, we throw it again to pass the error upstream.
654
            if (isset($e)) {
903✔
655
                throw $e;
21✔
656
            }
657
        }
658

659
        return $result;
903✔
660
    }
661

662
    /**
663
     * For a given list of tokens, find the index of the closing bracket that marks the end of
664
     * the index definition, i. e. the part that says what element of the array should be accessed.
665
     *
666
     * @param array $tokens
667
     * @return int
668
     */
669
    private function find_end_of_array_access(array $tokens): int {
670
        $count = count($tokens);
21✔
671

672
        // If we don't have at least four tokens (variable, opening bracket, index, closing bracket)
673
        // or if the first token after the variable name is not an opening bracket, we can return
674
        // immediately.
675
        if ($count < 4 || $tokens[1]->type !== token::OPENING_BRACKET) {
21✔
676
            return 1;
21✔
677
        }
678

679
        for ($i = 1; $i < $count - 1; $i++) {
21✔
680
            $token = $tokens[$i];
21✔
681

682
            // As long as we are not at the closing bracket, we just keep advancing.
683
            if ($token->type !== token::CLOSING_BRACKET) {
21✔
684
                continue;
21✔
685
            }
686
            // We found a closing bracket. Now let's see whether the next token is
687
            // an opening bracket again. If it is, we have to keep searching for the end.
688
            if ($tokens[$i + 1]->type === token::OPENING_BRACKET) {
21✔
689
                continue;
21✔
690
            }
691
            // If it is not, we can return.
692
            return $i + 1;
21✔
693
        }
694

695
        // We have not found the closing bracket, so the end is ... at the end.
696
        return $count;
21✔
697
    }
698

699
    /**
700
     * Takes a string representation of an algebraic formula, e.g. "a*x^2 + b" and
701
     * replaces the non-algebraic variables by their numerical value. Returns the resulting
702
     * string.
703
     *
704
     * @param string $formula the algebraic formula
705
     * @return string
706
     */
707
    public function substitute_variables_in_algebraic_formula(string $formula): string {
708
        // We do not use the answer parser, because we do not actually evaluate the formula,
709
        // and if it is needed for later output (e.g. "the correct answer is ..."), there is
710
        // no need to replace ^ by **.
711
        $parser = new parser($formula, $this->export_variable_list());
21✔
712
        $tokens = $parser->get_tokens();
21✔
713
        $count = count($tokens);
21✔
714

715
        // Will will iterate over all tokens and build an output string bit by bit.
716
        $output = '';
21✔
717
        for ($i = 0; $i < $count; $i++) {
21✔
718
            $token = $tokens[$i];
21✔
719
            // The unary minus must be translated back to '-'.
720
            if ($token->type === token::OPERATOR && $token->value === '_') {
21✔
721
                $output .= '-';
21✔
722
                continue;
21✔
723
            }
724
            // For a nicer output, we add a space before and after the +, -, * and / operator.
725
            if ($token->type === token::OPERATOR && in_array($token->value, ['+', '-', '*', '/'])) {
21✔
726
                $output .= " {$token->value} ";
21✔
727
                continue;
21✔
728
            }
729
            // If the token is not a VARIABLE, it can be shipped out.
730
            if ($tokens[$i]->type !== token::VARIABLE) {
21✔
731
                $output .= $tokens[$i]->value;
21✔
732
                continue;
21✔
733
            }
734

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

743
            // If there was an error, e.g. invalid array index, there will have been no substitution.
744
            // In that case, we only send the variable token to the output and keep on working, because
745
            // there might be nested variables to substitute.
746
            if ($result === "{=$subexpression}") {
21✔
747
                $output .= $token->value;
21✔
748
                continue;
21✔
749
            }
750

751
            // If we are still here, the subexpression has been replaced. We append it to the output
752
            // and remove all tokens until the end of that subexpression from the queue.
753
            $output .= $result;
21✔
754
            array_splice($tokens, $i + 1, $numberoftokens - 1);
21✔
755
            $count = $count - $numberoftokens + 1;
21✔
756
        }
757

758
        return $output;
21✔
759
    }
760

761
    /**
762
     * The diff() function calculates absolute differences between numerical or algebraic
763
     * expressions.
764
     *
765
     * @param array $first first list
766
     * @param array $second second list
767
     * @param int|null $n number of points where algebraic expressions will be evaluated
768
     * @return array
769
     */
770
    public function diff($first, $second, ?int $n = null) {
771
        // First, we check that $first and $second are lists of the same size.
772
        if (!is_array($first)) {
1,092✔
773
            throw new Exception(get_string('error_diff_first', 'qtype_formulas'));
42✔
774
        }
775
        if (!is_array($second)) {
1,050✔
776
            throw new Exception(get_string('error_diff_second', 'qtype_formulas'));
21✔
777
        }
778
        $count = count($first);
1,029✔
779
        if (count($second) !== $count) {
1,029✔
780
            throw new Exception(get_string('error_diff_samesize', 'qtype_formulas'));
42✔
781
        }
782

783
        // Now make sure the lists do contain one single data type (only numbers or only strings).
784
        // This is needed for the diff() function, because strings are evaluated as algebraic
785
        // formulas, i. e. in a completely different way. Also, both lists must have the same data
786
        // type.
787
        $type = token::EMPTY;
987✔
788
        for ($i = 0; $i < $count; $i++) {
987✔
789
            // As long as we have not found a "real" (i. e. non-empty) element, we update the type.
790
            if ($type === token::EMPTY) {
987✔
791
                $type = $first[$i]->type;
987✔
792
            }
793
            // If the current element's type does not match, we throw an error, unless it is the
794
            // $EMPTY token, because it may appear in a list of numbers or strings.
795
            if ($first[$i]->type !== $type && $first[$i]->type !== token::EMPTY) {
987✔
796
                throw new Exception(get_string('error_diff_firstlist_mismatch', 'qtype_formulas', $i));
42✔
797
            }
798
            if ($second[$i]->type !== $type && $second[$i]->type !== token::EMPTY) {
987✔
799
                throw new Exception(get_string('error_diff_secondlist_mismatch', 'qtype_formulas', $i));
63✔
800
            }
801
        }
802
        // If all elements of the first list are $EMPTY, we treat the list as a list of numbers, because
803
        // that's the most straightforward way to calculate the difference. There's probably no real use
804
        // case to have only empty answers in a question, but there's no reason to forbid it, either.
805
        if ($type === token::EMPTY) {
882✔
NEW
UNCOV
806
            $type = token::NUMBER;
×
807
        }
808
        // If the type is not valid, we throw an error.
809
        if (!in_array($type, [token::NUMBER, token::STRING])) {
882✔
810
            throw new Exception(get_string('error_diff_firstlist_content', 'qtype_formulas'));
21✔
811
        }
812

813
        // If we are working with numbers, we can directly calculate the differences and return.
814
        if ($type === token::NUMBER) {
861✔
815
            // The user should not specify a third argument when working with numbers.
816
            if ($n !== null) {
63✔
817
                throw new Exception(get_string('error_diff_third', 'qtype_formulas'));
21✔
818
            }
819

820
            $result = [];
42✔
821
            for ($i = 0; $i < $count; $i++) {
42✔
822
                // This function is also used to calculate the difference between the model answers
823
                // and the student's response. In that case, the difference between an $EMPTY answer
824
                // and any other value shall always be PHP_FLOAT_MAX. The difference between an
825
                // $EMPTY answer and an empty response shall, of course, be 0. For "real" values,
826
                // the difference is calculated normally.
827
                if ($first[$i]->type === token::EMPTY || $second[$i]->type === token::EMPTY) {
42✔
NEW
828
                    $diff = ($second[$i]->type === $first[$i]->type ? 0 : PHP_FLOAT_MAX);
×
829
                } else {
830
                    $diff = abs($first[$i]->value - $second[$i]->value);
42✔
831
                }
832
                $result[$i] = token::wrap($diff, token::NUMBER);
42✔
833
            }
834
            return $result;
42✔
835
        }
836

837
        // If the user did not specify $n, we set it to 100, for backwards compatibility.
838
        if ($n === null) {
798✔
839
            $n = 100;
42✔
840
        }
841

842
        $result = [];
798✔
843
        // Iterate over all strings and calculate the root mean square difference between the two expressions.
844
        for ($i = 0; $i < $count; $i++) {
798✔
845
            // If both list elements are the $EMPTY token, the difference is zero and we do not have to
846
            // do any more calculations. Otherwise, we just carry on. The calculation will fail later
847
            // and the difference will automatically be PHP_FLOAT_MAX.
848
            if ($first[$i]->type === token::EMPTY && $second[$i]->type === token::EMPTY) {
798✔
NEW
UNCOV
849
                $result[$i] = token::wrap(0, token::NUMBER);
×
NEW
UNCOV
850
                continue;
×
851
            }
852

853
            $result[$i] = 0;
798✔
854
            $expression = "({$first[$i]}) - ({$second[$i]})";
798✔
855
            // FIXME: get rid of this again
856
            $expression = str_replace('"', '', $expression);
798✔
857

858
            // Flag that we will set to TRUE if a difference cannot be evaluated. This
859
            // is to make sure that the difference will be PHP_FLOAT_MAX and not
860
            // sqrt(PHP_FLOAT_MAX) divided by $n.
861
            $cannotevaluate = false;
798✔
862
            for ($j = 0; $j < $n; $j++) {
798✔
863
                try {
864
                    $difference = $this->calculate_algebraic_expression($expression);
798✔
865
                } catch (Exception $e) {
21✔
866
                    // If evaluation failed, there is no need to evaluate any further. Instead,
867
                    // we set the $cannotevaluate flag and will later set the result to
868
                    // PHP_FLOAT_MAX. By choosing PHP_FLOAT_MAX rather than INF, we make sure
869
                    // that the result is still a float.
870
                    $cannotevaluate = true;
21✔
871
                    // Note: index is $i, because every $j step adds to the $i-th difference.
872
                    $result[$i] = PHP_FLOAT_MAX;
21✔
873
                    break;
21✔
874
                }
875
                $result[$i] += $difference->value ** 2;
798✔
876
            }
877
            $result[$i] = token::wrap(sqrt($result[$i] / $n), token::NUMBER);
798✔
878
            if ($cannotevaluate) {
798✔
879
                $result[$i] = token::wrap(PHP_FLOAT_MAX, token::NUMBER);
21✔
880
            }
881
        }
882

883
        return $result;
798✔
884
    }
885

886
    /**
887
     * Evaluate the given thing, e. g. an expression or a for loop.
888
     *
889
     * @param expression|for_loop $input
890
     * @param bool $godmode whether one should be allowed to modify reserved variables like e.g. _a or _0
891
     * @return token|void
892
     */
893
    private function evaluate_the_right_thing($input, bool $godmode = false) {
894
        if ($input instanceof expression) {
24,549✔
895
            // If the expression is empty, we simply ignore it.
896
            if (empty($input->body)) {
24,507✔
897
                return;
21✔
898
            }
899
            return $this->evaluate_single_expression($input, $godmode);
24,507✔
900
        }
901
        if ($input instanceof for_loop) {
483✔
902
            return $this->evaluate_for_loop($input);
462✔
903
        }
904
        throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate_the_right_thing()'));
21✔
905
    }
906

907
    /**
908
     * Evaluate a single expression or an array of expressions.
909
     *
910
     * @param expression|for_loop|array|false $input
911
     * @param bool $godmode whether to run the evaluation in god mode
912
     * @return token|array
913
     */
914
    public function evaluate($input, bool $godmode = false) {
915
        if (($input instanceof expression) || ($input instanceof for_loop)) {
24,591✔
916
            return $this->evaluate_the_right_thing($input, $godmode);
1,155✔
917
        }
918
        // For convenience, the evaluator accepts FALSE as an input, This allows
919
        // passing reset($array) with a possibly empty array.
920
        if ($input === false) {
24,360✔
NEW
UNCOV
921
            return new token(token::EMPTY, '$EMPTY');
×
922
        }
923
        if (!is_array($input)) {
24,360✔
924
            throw new Exception(get_string('error_evaluate_invocation', 'qtype_formulas', 'evaluate()'));
21✔
925
        }
926
        $result = [];
24,360✔
927
        foreach ($input as $single) {
24,360✔
928
            $result[] = $this->evaluate_the_right_thing($single, $godmode);
24,318✔
929
        }
930
        return $result;
17,703✔
931
    }
932

933
    /**
934
     * Evaluate a for loop.
935
     *
936
     * @param for_loop $loop
937
     * @return void
938
     */
939
    private function evaluate_for_loop(for_loop $loop) {
940
        $rangetoken = $this->evaluate_single_expression($loop->range);
462✔
941
        $range = $rangetoken->value;
462✔
942
        $result = null;
462✔
943
        foreach ($range as $iterationvalue) {
462✔
944
            $this->set_variable_to_value($loop->variable, $iterationvalue);
462✔
945
            $result = $this->evaluate($loop->body);
462✔
946
        }
947
        $this->clear_stack();
462✔
948
        return end($result);
462✔
949
    }
950

951
    /**
952
     * Evaluate an expression, e. g. an assignment, a function call or a calculation.
953
     *
954
     * @param expression $expression
955
     * @param bool $godmode
956
     * @return token
957
     */
958
    private function evaluate_single_expression(expression $expression, bool $godmode = false): token {
959
        foreach ($expression->body as $token) {
24,549✔
960
            $type = $token->type;
24,549✔
961
            $value = $token->value;
24,549✔
962

963
            $isliteral = ($type & token::ANY_LITERAL);
24,549✔
964
            $isopening = ($type === token::OPENING_BRACE || $type === token::OPENING_BRACKET);
24,549✔
965
            $isvariable = ($type === token::VARIABLE);
24,549✔
966

967
            // Many tokens go directly to the stack.
968
            if ($isliteral || $isopening || $isvariable) {
24,549✔
969
                $this->stack[] = $token;
24,423✔
970
                continue;
24,423✔
971
            }
972

973
            // Constants are resolved and sent to the stack.
974
            if ($type === token::CONSTANT) {
24,339✔
975
                $this->stack[] = $this->resolve_constant($token);
252✔
976
                continue;
231✔
977
            }
978

979
            if ($type === token::OPERATOR) {
24,255✔
980
                if ($this->is_unary_operator($token)) {
16,926✔
981
                    $this->stack[] = $this->execute_unary_operator($token);
2,835✔
982
                }
983
                // The = operator is binary, but we treat it separately.
984
                if ($value === '=' || $value === 'r=') {
16,905✔
985
                    $israndomvar = ($value === 'r=');
8,316✔
986
                    $this->godmode = $godmode;
8,316✔
987
                    $this->stack[] = $this->execute_assignment($israndomvar);
8,316✔
988
                    $this->godmode = false;
8,106✔
989
                } else if ($this->is_binary_operator($token)) {
14,238✔
990
                    $this->stack[] = $this->execute_binary_operator($token);
5,418✔
991
                }
992
                // The %%ternary-sentinel pseudo-token goes on the stack where it will
993
                // help detect ternary expressions with too few arguments.
994
                if ($value === '%%ternary-sentinel') {
16,317✔
995
                    $this->stack[] = $token;
525✔
996
                }
997
                // When executing the ternary operator, we pass it the operator token
998
                // in order to have best possible error reporting.
999
                if ($value === '%%ternary') {
16,317✔
1000
                    $this->stack[] = $this->execute_ternary_operator($token);
546✔
1001
                }
1002
                if ($value === '%%arrayindex') {
16,317✔
1003
                    $this->stack[] = $this->fetch_array_element_or_char();
987✔
1004
                }
1005
                if ($value === '%%setbuild') {
16,296✔
1006
                    $this->stack[] = $this->build_set();
2,499✔
1007
                }
1008
                if ($value === '%%arraybuild') {
16,296✔
1009
                    $this->stack[] = $this->build_array($token);
7,581✔
1010
                }
1011
                if ($value === '%%rangebuild') {
16,275✔
1012
                    array_push($this->stack, $this->build_range());
2,541✔
1013
                }
1014
            }
1015

1016
            if ($type === token::FUNCTION) {
23,520✔
1017
                $this->stack[] = $this->execute_function($token);
16,716✔
1018
            }
1019

1020
        }
1021
        // If the stack contains more than one element, there must have been a problem somewhere.
1022
        if (count($this->stack) !== 1) {
18,228✔
1023
            throw new Exception(get_string('error_stacksize', 'qtype_formulas'));
21✔
1024
        }
1025
        // If the stack only contains one single variable token, return its content.
1026
        // Otherwise, return the token.
1027
        return $this->pop_real_value();
18,207✔
1028
    }
1029

1030
    /**
1031
     * Fetch an element from a list or a char from a string. The index and the list or string will
1032
     * be taken from the stack.
1033
     *
1034
     * @return token the desired list element or char
1035
     */
1036
    private function fetch_array_element_or_char(): token {
1037
        $indextoken = $this->pop_real_value();
987✔
1038
        $index = $indextoken->value;
987✔
1039
        $nexttoken = array_pop($this->stack);
987✔
1040

1041
        // Make sure there is only one index.
1042
        if ($nexttoken->type !== token::OPENING_BRACKET) {
987✔
1043
            $this->die(get_string('error_onlyoneindex', 'qtype_formulas'), $indextoken);
63✔
1044
        }
1045

1046
        // Fetch the array or string from the stack.
1047
        $arraytoken = array_pop($this->stack);
945✔
1048

1049
        // If it is a variable, we do lazy evaluation: just append the index and wait. It might be used
1050
        // as a left-hand side in an assignment. If it is not, it will be resolved later. Also, if
1051
        // the index is invalid, that will lead to an error later on.
1052
        if ($arraytoken->type === token::VARIABLE) {
945✔
1053
            $name = $arraytoken->value . "[$index]";
714✔
1054
            return new token(token::VARIABLE, $name, $arraytoken->row, $arraytoken->column);
714✔
1055
        }
1056

1057
        // Before accessing the array or string, we validate the index and, if necessary,
1058
        // we translate a negative index to the corresponding positive value.
1059
        $array = $arraytoken->value;
273✔
1060
        $index = $this->validate_array_or_string_index($array, $index, $nexttoken);
273✔
1061
        $element = $array[$index];
231✔
1062

1063
        // If we are accessing a string's char, we create a new string token.
1064
        if ($arraytoken->type === token::STRING) {
231✔
1065
            return new token(token::STRING, $element, $arraytoken->row, $arraytoken->column + $index);
21✔
1066
        }
1067
        // Otherwise, the element is already wrapped in a token.
1068
        return $element;
210✔
1069
    }
1070

1071
    /**
1072
     * Build a range of numbers. The lower and upper limit and, if present, the step will be taken from
1073
     * the stack.
1074
     *
1075
     * @return token
1076
     */
1077
    private function build_range(): token {
1078
        // Pop the number of parts. We generated it ourselves, so we know it will be 2 or 3.
1079
        $parts = array_pop($this->stack)->value;
2,541✔
1080

1081
        $step = 1;
2,541✔
1082
        // If we have 3 parts, extract the step size. Conserve the token in case of an error.
1083
        if ($parts === 3) {
2,541✔
1084
            $steptoken = $this->pop_real_value();
525✔
1085
            // Abort with nice error message, if step is not numeric.
1086
            $this->abort_if_not_scalar($steptoken);
525✔
1087
            $step = $steptoken->value;
525✔
1088
        }
1089

1090
        // Step must not be zero.
1091
        if ($step == 0) {
2,541✔
1092
            $this->die(get_string('error_stepzero', 'qtype_formulas'), $steptoken);
21✔
1093
        }
1094

1095
        // Fetch start and end of the range. Conserve token for the end value, in case of an error.
1096
        $endtoken = $this->pop_real_value();
2,520✔
1097
        $end = $endtoken->value;
2,520✔
1098
        $starttoken = $this->pop_real_value();
2,520✔
1099
        $start = $starttoken->value;
2,520✔
1100

1101
        // Abort with nice error message, if start or end is not numeric.
1102
        $this->abort_if_not_scalar($starttoken);
2,520✔
1103
        $this->abort_if_not_scalar($endtoken);
2,520✔
1104

1105
        if ($start === $end) {
2,520✔
1106
            $this->die(get_string('error_samestartend', 'qtype_formulas'), $endtoken);
42✔
1107
        }
1108

1109
        if (($end - $start) * $step < 0) {
2,478✔
1110
            if ($parts === 3) {
42✔
1111
                $a = (object)['start' => $start, 'end' => $end, 'step' => $step];
21✔
1112
                $this->die(get_string('error_emptyrange', 'qtype_formulas', $a), $steptoken);
21✔
1113
            }
1114
            $step = -$step;
21✔
1115
        }
1116

1117
        return new token(token::RANGE, new range($start, $end, $step));
2,457✔
1118
    }
1119

1120
    /**
1121
     * Create a SET token based on elements and ranges on the stack.
1122
     *
1123
     * @return token
1124
     */
1125
    private function build_set(): token {
1126
        // We use a lazy list for SET tokens in order to save memory.
1127
        $list = new lazylist();
2,499✔
1128

1129
        $head = end($this->stack);
2,499✔
1130
        while ($head !== false) {
2,499✔
1131
            if ($head->type === token::OPENING_BRACE) {
2,499✔
1132
                array_pop($this->stack);
2,499✔
1133
                break;
2,499✔
1134
            }
1135
            // As the stack is LIFO, we *pre*pend the new value or range.
1136
            $token = $this->pop_real_value();
2,499✔
1137
            if ($head->type === token::RANGE) {
2,499✔
1138
                $list->prepend_range($token->value);
1,638✔
1139
            } else {
1140
                $list->prepend_value($token);
966✔
1141
            }
1142
            $head = end($this->stack);
2,499✔
1143
        }
1144

1145
        return new token(token::SET, $list);
2,499✔
1146
    }
1147

1148
    /**
1149
     * Create a LIST token based on elements on the stack.
1150
     *
1151
     * @param token $opener opening bracket token, used for error reporting
1152
     * @return token
1153
     */
1154
    private function build_array(token $opener): token {
1155
        $elements = [];
7,581✔
1156
        $head = end($this->stack);
7,581✔
1157
        $count = 0;
7,581✔
1158
        while ($head !== false) {
7,581✔
1159
            if ($head->type === token::OPENING_BRACKET) {
7,581✔
1160
                array_pop($this->stack);
7,518✔
1161
                break;
7,518✔
1162
            }
1163
            $element = $this->pop_real_value();
7,455✔
1164
            if ($element->type === token::RANGE) {
7,455✔
1165
                // Check whether the count will exceed the limit of 1000 list elements.
1166
                $count += count($element->value);
819✔
1167
                if ($count > qtype_formulas::MAX_LIST_SIZE) {
819✔
1168
                    $this->die(get_string('error_list_too_large', 'qtype_formulas', qtype_formulas::MAX_LIST_SIZE), $opener);
42✔
1169
                }
1170

1171
                // Convert the range into an array that actually contains all the necessary values.
1172
                // As the stack is generally in LIFO order, we must reverse the generated array,
1173
                // in order to blend in with other values that might or might not have to be included
1174
                // in the list.
1175
                $rangearray = iterator_to_array($element->value);
798✔
1176
                $elements = array_merge($elements, array_reverse($rangearray));
798✔
1177
            } else {
1178
                $count += token::recursive_count($element);
6,783✔
1179
                if ($count > qtype_formulas::MAX_LIST_SIZE) {
6,783✔
1180
                    $this->die(get_string('error_list_too_large', 'qtype_formulas', qtype_formulas::MAX_LIST_SIZE), $opener);
42✔
1181
                }
1182

1183
                $elements[] = $element;
6,783✔
1184
            }
1185

1186
            $head = end($this->stack);
7,434✔
1187
        }
1188
        // Return reversed list, because the stack ist LIFO.
1189
        return new token(token::LIST, array_reverse($elements));
7,518✔
1190
    }
1191

1192
    /**
1193
     * Whether a given OPERATOR token is an unary operator.
1194
     *
1195
     * @param token $token
1196
     * @return bool
1197
     */
1198
    private function is_unary_operator(token $token): bool {
1199
        return in_array($token->value, ['_', '!', '~']);
16,926✔
1200
    }
1201

1202
    /**
1203
     * Whether a given OPERATOR token expects its argument(s) to be numbers.
1204
     *
1205
     * @param token $token
1206
     * @return bool
1207
     */
1208
    private function needs_numeric_input(token $token): bool {
1209
        $operators = ['_', '~', '**', '*', '/', '%', '-', '<<', '>>', '&', '^', '|', '&&', '||'];
7,308✔
1210
        return in_array($token->value, $operators);
7,308✔
1211
    }
1212

1213
    /**
1214
     * In many cases, operators need a numeric or at least a scalar operand to work properly.
1215
     * This function does the necessary check and prepares a human-friendly error message
1216
     * if the conditions are not met.
1217
     *
1218
     * @param token $token the token to check
1219
     * @param bool $enforcenumeric whether the value must be numeric in addition to being scalar
1220
     * @return void
1221
     * @throws Exception
1222
     */
1223
    private function abort_if_not_scalar(token $token, bool $enforcenumeric = true): void {
1224
        $found = '';
7,980✔
1225
        $a = (object)[];
7,980✔
1226
        if ($token->type !== token::NUMBER) {
7,980✔
1227
            if ($token->type === token::SET) {
546✔
1228
                $found = '_algebraicvar';
21✔
1229
            } else if ($token->type === token::LIST) {
525✔
1230
                $found = '_list';
63✔
1231
            } else if ($enforcenumeric) {
483✔
1232
                // Let's be lenient if the token is not a NUMBER, but its value is numeric.
1233
                if (is_numeric($token->value)) {
294✔
1234
                    return;
252✔
1235
                }
1236
                $a->found = "'{$token->value}'";
42✔
1237
            } else if ($token->type === token::STRING) {
189✔
1238
                return;
189✔
1239
            }
1240
            $expected = ($enforcenumeric ? 'number' : 'scalar');
126✔
1241

1242
            $this->die(get_string("error_expected_{$expected}_found{$found}", 'qtype_formulas', $a), $token);
126✔
1243
        }
1244
    }
1245

1246
    /**
1247
     * Whether a given OPERATOR token is a binary operator.
1248
     *
1249
     * @param token $token
1250
     * @return bool
1251
     */
1252
    public static function is_binary_operator(token $token): bool {
1253
        $binaryoperators = ['=', '**', '*', '/', '%', '+', '-', '<<', '>>', '&', '^',
14,238✔
1254
            '|', '&&', '||', '<', '>', '==', '>=', '<=', '!='];
14,238✔
1255

1256
        return in_array($token->value, $binaryoperators);
14,238✔
1257
    }
1258

1259
    /**
1260
     * Assign a value to a variable. The value and the variable name are taken from the stack.
1261
     *
1262
     * @param boolean $israndomvar
1263
     * @return token the assigned value
1264
     */
1265
    private function execute_assignment($israndomvar = false): token {
1266
        $what = $this->pop_real_value();
8,316✔
1267
        $destination = array_pop($this->stack);
8,316✔
1268

1269
        // When storing a value in a variable, the row and column should be
1270
        // set to the row and column of the variable token.
1271
        $what->row = $destination->row;
8,316✔
1272
        $what->column = $destination->column;
8,316✔
1273

1274
        // The destination must be a variable token.
1275
        if ($destination->type !== token::VARIABLE) {
8,316✔
1276
            $this->die(get_string('error_variablelhs', 'qtype_formulas'), $destination);
42✔
1277
        }
1278
        return $this->set_variable_to_value($destination, $what, $israndomvar);
8,274✔
1279
    }
1280

1281
    /**
1282
     * Evaluate a ternary expression, taking the arguments from the stack.
1283
     *
1284
     * @param token $optoken token that led to this function being called, for better error reporting
1285
     * @return token evaluation result
1286
     */
1287
    private function execute_ternary_operator(token $optoken) {
1288
        // For good error reporting, we first check, whether there are enough arguments on
1289
        // the stack. We subtract one, because there is a sentinel token.
1290
        if (count($this->stack) - 1 < 3) {
546✔
1291
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $optoken);
42✔
1292
        }
1293
        $else = array_pop($this->stack);
525✔
1294
        $then = array_pop($this->stack);
525✔
1295
        // The user might not have provided enough arguments for the ternary operator (missing 'else'
1296
        // part), but there might be other elements on the stack from earlier operations (or a LHS variable
1297
        // for an upcoming assignment). In that case, the intended 'then' token has been popped as
1298
        // the 'else' part and we have now read the '%%ternary-sentinel' pseudo-token.
1299
        if ($then->type === token::OPERATOR && $then->value === '%%ternary-sentinel') {
525✔
1300
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $then);
21✔
1301
        }
1302
        // If everything is OK, we should now arrive at the '%%ternary-sentinel' pseudo-token. Let's see...
1303
        $pseudotoken = array_pop($this->stack);
504✔
1304
        if ($pseudotoken->type !== token::OPERATOR && $pseudotoken->value !== '%%ternary-sentinel') {
504✔
1305
            $this->die(get_string('error_ternary_notenough', 'qtype_formulas'), $then);
21✔
1306
        }
1307

1308
        $condition = $this->pop_real_value();
483✔
1309
        return ($condition->value ? $then : $else);
483✔
1310
    }
1311

1312
    /**
1313
     * Apply an unary operator to the token that is currently on top of the stack.
1314
     *
1315
     * @param token $token operator token
1316
     * @return token result
1317
     */
1318
    private function execute_unary_operator($token) {
1319
        $input = $this->pop_real_value();
2,835✔
1320

1321
        // Check if the input is numeric. Boolean values are internally treated as 1 and 0 for
1322
        // backwards compatibility.
1323
        if ($this->needs_numeric_input($token)) {
2,835✔
1324
            $this->abort_if_not_scalar($input);
2,793✔
1325
        }
1326

1327
        $result = functions::apply_unary_operator($token->value, $input->value);
2,793✔
1328
        return token::wrap($result);
2,793✔
1329
    }
1330

1331
    /**
1332
     * Apply a binary operator to the two elements currently on top of the stack.
1333
     *
1334
     * @param token $optoken operator token
1335
     * @return token result
1336
     */
1337
    private function execute_binary_operator($optoken) {
1338
        // The stack is LIFO, so we pop the second operand first.
1339
        $secondtoken = $this->pop_real_value();
5,418✔
1340
        $firsttoken = $this->pop_real_value();
5,145✔
1341

1342
        // Abort with nice error message, if arguments should be numeric but are not.
1343
        if ($this->needs_numeric_input($optoken)) {
5,145✔
1344
            $this->abort_if_not_scalar($firsttoken);
3,759✔
1345
            $this->abort_if_not_scalar($secondtoken);
3,717✔
1346
        }
1347

1348
        $first = $firsttoken->value;
5,103✔
1349
        $second = $secondtoken->value;
5,103✔
1350

1351
        // For + (string concatenation or addition) we check the arguments here, even if another
1352
        // check is done in functions::apply_binary_operator(), because this allows for better
1353
        // error reporting.
1354
        if ($optoken->value === '+') {
5,103✔
1355
            // If at least one operand is a string, both values must be scalar, but
1356
            // not necessarily numeric; we use concatenation instead of addition.
1357
            // In all other cases, addition must (currently) be numeric, so we abort
1358
            // if the arguments are not numbers.
1359
            $acceptstring = is_string($first) || is_string($second);
2,415✔
1360
            $this->abort_if_not_scalar($firsttoken, !$acceptstring);
2,415✔
1361
            $this->abort_if_not_scalar($secondtoken, !$acceptstring);
2,394✔
1362
        }
1363

1364
        try {
1365
            $result = functions::apply_binary_operator($optoken->value, $first, $second);
5,061✔
1366
        } catch (Exception $e) {
357✔
1367
            $this->die($e->getMessage(), $optoken);
357✔
1368
        }
1369
        return token::wrap($result);
4,704✔
1370
    }
1371

1372
    /**
1373
     * Check whether the number of parameters is valid for a given function.
1374
     *
1375
     * @param token $function FUNCTION token containing the function name
1376
     * @param int $count number of arguments
1377
     * @return bool
1378
     */
1379
    private function is_valid_num_of_params(token $function, int $count): bool {
1380
        $funcname = $function->value;
16,716✔
1381
        $min = INF;
16,716✔
1382
        $max = -INF;
16,716✔
1383
        // Union gives precedence to first array, so we are able to override a
1384
        // built-in function.
1385
        $allfunctions = functions::FUNCTIONS + self::PHPFUNCTIONS;
16,716✔
1386
        if (array_key_exists($funcname, $allfunctions)) {
16,716✔
1387
            $min = $allfunctions[$funcname][0];
16,695✔
1388
            $max = $allfunctions[$funcname][1];
16,695✔
1389
            return $count >= $min && $count <= $max;
16,695✔
1390
        }
1391
        // Still here? That means the function is unknown.
1392
        $this->die(get_string('error_unknownfunction', 'qtype_formulas', $funcname), $function);
21✔
1393
    }
1394

1395
    /**
1396
     * Lookup the value of a constant and return its value.
1397
     *
1398
     * @param token $token CONSTANT token containing the constant's name
1399
     * @return token value of the requested constant
1400
     */
1401
    private function resolve_constant($token): token {
1402
        if (array_key_exists($token->value, $this->constants)) {
252✔
1403
            return new token(token::NUMBER, $this->constants[$token->value], $token->row, $token->column);
231✔
1404
        }
1405
        $this->die(get_string('error_undefinedconstant', 'qtype_formulas', $token->value), $token);
21✔
1406
    }
1407

1408
    /**
1409
     * Execute a given function, taking the needed argument(s) from the stack.
1410
     *
1411
     * @param token $token FUNCTION token containing the function's name.
1412
     * @return token result
1413
     */
1414
    private function execute_function(token $token): token {
1415
        $funcname = $token->value;
16,716✔
1416

1417
        // Fetch the number of params from the stack. Keep the token in case of an error.
1418
        $numparamstoken = array_pop($this->stack);
16,716✔
1419
        $numparams = $numparamstoken->value;
16,716✔
1420

1421
        // Check if the number of params is valid for the given function. If it is not,
1422
        // die with an error message.
1423
        if (!$this->is_valid_num_of_params($token, $numparams)) {
16,716✔
1424
            $a = (object)['function' => $funcname, 'count' => $numparams];
2,982✔
1425
            $this->die(get_string('error_func_argcount', 'qtype_formulas', $a), $token);
2,982✔
1426
        }
1427

1428
        // Fetch the params from the stack and reverse their order, because the stack is LIFO.
1429
        $params = [];
13,713✔
1430
        for ($i = 0; $i < $numparams; $i++) {
13,713✔
1431
            $params[] = $this->pop_real_value()->value;
13,692✔
1432
        }
1433
        $params = array_reverse($params);
13,692✔
1434

1435
        // If something goes wrong, e. g. wrong type of parameter, functions will throw a TypeError (built-in)
1436
        // or an Exception (custom functions). We catch the exception and build a nice error message.
1437
        try {
1438
            // If we have our own implementation, execute that one. Otherwise, use PHP's built-in function.
1439
            // The special function diff() is defined in the evaluator, so it needs special treatment.
1440
            $isown = array_key_exists($funcname, functions::FUNCTIONS);
13,692✔
1441
            $prefix = '';
13,692✔
1442
            if ($funcname === 'diff') {
13,692✔
1443
                $prefix = self::class . '::';
1,113✔
1444
            } else if ($isown) {
12,852✔
1445
                $prefix = functions::class . '::';
9,975✔
1446
            }
1447
            $result = call_user_func_array($prefix . $funcname, $params);
13,692✔
1448
            // Our own funtions should deal with all sorts of errors and invalid arguments. However,
1449
            // the PHP built-in functions will sometimes return NAN or ±INF, e.g. for sqrt(-2) or log(0).
1450
            // We will check for those return values and output a special error message.
1451
            // Note that for PHP the values NAN, INF and -INF are all numeric, but not finite.
1452
            if (is_numeric($result) && !is_finite($result)) {
11,676✔
1453
                throw new Exception(get_string('error_func_nan', 'qtype_formulas', $funcname));
11,676✔
1454
            }
1455
        } catch (Throwable $e) {
2,121✔
1456
            $this->die($e->getMessage(), $token);
2,121✔
1457
        }
1458

1459
        // Some of our own functions may return a token. In those cases, we reset
1460
        // the row and column value, because they are no longer accurate. Once that
1461
        // is done, we return the token.
1462
        if ($result instanceof token) {
11,592✔
1463
            $result->row = -1;
189✔
1464
            $result->column = -1;
189✔
1465
            return $result;
189✔
1466
        }
1467

1468
        // Most of the time, the return value will not be a token. In those cases,
1469
        // we have to wrap it up before returning.
1470
        return token::wrap($result);
11,403✔
1471
    }
1472
}
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