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

FormulasQuestion / moodle-qtype_formulas / 13883648902

16 Mar 2025 01:03PM UTC coverage: 77.897% (+2.9%) from 75.045%
13883648902

push

github

web-flow
backport unit test for backup and restore (#168)

2622 of 3366 relevant lines covered (77.9%)

176.73 hits per line

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

92.47
/variables.php
1
<?php
2
// This file is part of Moodle - http://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 <http://www.gnu.org/licenses/>.
16

17
/**
18
 * The qtype_formulas_variables class is used to parse and evaluate variables.
19
 *
20
 * @copyright &copy; 2010-2011 Hon Wai, Lau
21
 * @author Hon Wai, Lau <lau65536@gmail.com>
22
 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License version 3
23
 */
24

25
namespace qtype_formulas;
26
use Exception, Throwable;
27

28
defined('MOODLE_INTERNAL') || die();
76✔
29

30
/**
31
 * Helper function to emulate the behaviour of the count() function with PHP <7.2
32
 * Until then, count() returned 1 for e.g. a string. Since 8.0, it throws TypeError in such cases
33
 *
34
 * @author Jean-Michel Védrine
35
 * @param mixed $a
36
 * @return integer
37
 */
38
function mycount($a) {
39
    if ($a === null) {
1,900✔
40
        return 0;
×
41
    } else {
42
        if ($a instanceof \Countable || is_array($a)) {
1,900✔
43
            return count($a);
1,900✔
44
        } else {
45
            return 1;
285✔
46
        }
47
    }
48
}
49

50
function fact($n) {
51
    $n = (int) $n;
95✔
52
    if ($n < 2) {
95✔
53
        return 1;
38✔
54
    }
55
    $return = 1;
95✔
56
    for ($i = $n; $i > 1; $i--) {
95✔
57
        $return *= $i;
95✔
58
    }
59
    return $return;
95✔
60
}
61

62
/**
63
 * Return the plugin's version number.
64
 *
65
 * @return integer
66
 */
67
function fqversionnumber() {
68
    return get_config('qtype_formulas')->version;
19✔
69
}
70

71
/**
72
 * calculate standard normal probability density
73
 *
74
 * @author Philipp Imhof
75
 * @param float $z  value
76
 * @return float  standard normal density of $z
77
 */
78
function stdnormpdf($z) {
79
    return 1 / (sqrt(2) * M_SQRTPI) * exp(-.5 * $z ** 2);
76✔
80
}
81

82
/**
83
 * calculate standard normal cumulative distribution by approximation
84
 * using Simpson's rule, accurate to ~5 decimal places
85
 *
86
 * @param float $z  value
87
 *
88
 * @author Philipp Imhof
89
 * @return float  probability for a value of $z or less under standard normal distribution
90
 */
91
function stdnormcdf($z) {
92
    if ($z < 0) {
57✔
93
        return 1 - stdnormcdf(-$z);
38✔
94
    }
95
    $n = max(10, floor(10 * $z));
57✔
96
    $h = $z / $n;
57✔
97
    $res = stdnormpdf(0) + stdnormpdf($z);
57✔
98
    for ($i = 1; $i < $n; $i++) {
57✔
99
        $res += 2 * stdnormpdf($i * $h);
57✔
100
        $res += 4 * stdnormpdf(($i - 0.5) * $h);
57✔
101
    }
102
    $res += 4 * stdnormpdf(($n - 0.5) * $h);
57✔
103
    $res *= $h / 6;
57✔
104
    return $res + 0.5;
57✔
105
}
106

107
/**
108
 * calculate normal cumulative distribution by approximation
109
 * using Simpson's rule, accurate to ~5 decimal places
110
 *
111
 * @param float $x      value
112
 * @param float $mu     mean
113
 * @param float $sigma  standard deviation
114
 *
115
 * @author Philipp Imhof
116
 * @return float  probability for a value of $x or less
117
 */
118
function normcdf($x, $mu, $sigma) {
119
    return stdnormcdf(($x - $mu) / $sigma);
19✔
120
}
121

122
/**
123
 * raise $a to the $b-th power modulo $m using efficient
124
 * square and multiply
125
 *
126
 * @author Philipp Imhof
127
 * @param integer $a  base
128
 * @param integer $b  exponent
129
 * @param integer $m  modulus
130
 * @return integer  the result
131
 */
132
function modpow($a, $b, $m) {
133
    $bin = decbin($b);
38✔
134
    $res = $a;
38✔
135
    if ($b == 0) {
38✔
136
        return 1;
×
137
    }
138
    for ($i = 1; $i < strlen($bin); $i++) {
38✔
139
        if ($bin[$i] == "0") {
38✔
140
            $res = ($res * $res) % $m;
38✔
141
        } else {
142
            $res = ($res * $res) % $m;
19✔
143
            $res = ($res * $a) % $m;
19✔
144
        }
145
    }
146
    return $res;
38✔
147
}
148

149
/**
150
 * calculate the multiplicative inverse of $a modulo $m using the
151
 * extended euclidean algorithm
152
 *
153
 * @author Philipp Imhof
154
 * @param integer $a  the number whose inverse is to be found
155
 * @param integer $m  the modulus
156
 * @return integer  the result or 0 if the inverse does not exist
157
 */
158
function modinv($a, $m) {
159
    $orig_m = $m;
19✔
160
    if (gcd($a, $m) != 1) {
19✔
161
        // Inverse does not exist.
162
        return 0;
19✔
163
    }
164
    list($s, $t, $last_s, $last_t) = [1, 0, 0, 1];
19✔
165
    while ($m != 0) {
19✔
166
        $q = floor($a/$m);
19✔
167
        list($a, $m) = [$m, $a - $q * $m];
19✔
168
        list($s, $last_s) = [$last_s, $s - $q * $last_s];
19✔
169
        list($t, $last_t) = [$last_t, $t - $q * $last_t];
19✔
170
    }
171
    return ($s < 0) ? $s + $orig_m : $s;
19✔
172
}
173

174
/**
175
 * Calculate the floating point remainder of the division of
176
 * the arguments, i. e. x - m * floor(x / m). There is no
177
 * canonical definition for this function; some calculators
178
 * use flooring (round down to nearest integer) and others
179
 * use truncation (round to nearest integer, but towards zero).
180
 * This implementation gives the same results as e. g. Wolfram Alpha.
181
 *
182
 * @author Philipp Imhof
183
 * @param float $x the dividend
184
 * @param float $m the modulus
185
 * @return float remainder of $x modulo $m
186
 */
187
function fmod($x, $m) {
188
    if ($m === 0) {
38✔
189
        throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
×
190
    }
191
    return $x - $m * floor($x / $m);
38✔
192
}
193

194
/**
195
 * Calculate the probability of exactly $x successful outcomes for
196
 * $n trials under a binomial distribution with a probability of success
197
 * of $p.
198
 *
199
 * @param int $n number of trials
200
 * @param float $p probability of success for each trial
201
 * @param int $x number of successful outcomes
202
 *
203
 * @return float probability for exactly $x successful outcomes
204
 * @throws Exception
205
 */
206
function binomialpdf($n, $p, $x) {
207
    // Probability must be 0 <= p <= 1.
208
    if ($p < 0 || $p > 1) {
38✔
209
        throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
×
210
    }
211
    // Number of successful outcomes must be at least 0 and at most number of trials.
212
    if ($x < 0 || $x > $n) {
38✔
213
        throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
×
214
    }
215
    return ncr($n, $x) * $p ** $x * (1 - $p) ** ($n - $x);
38✔
216
}
217

218
/**
219
 * Calculate the probability of up to $x successful outcomes for
220
 * $n trials under a binomial distribution with a probability of success
221
 * of $p, known as the cumulative distribution function.
222
 *
223
 * @param int $n number of trials
224
 * @param float $p probability of success for each trial
225
 * @param int $x number of successful outcomes
226
 *
227
 * @return float probability for up to $x successful outcomes
228
 * @throws Exception
229
 */
230
function binomialcdf($n, $p, $x) {
231
    // Probability must be 0 <= p <= 1.
232
    if ($p < 0 || $p > 1) {
19✔
233
        throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
×
234
    }
235
    // Number of successful outcomes must be at least 0 and at most number of trials.
236
    if ($x < 0 || $x > $n) {
19✔
237
        throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
×
238
    }
239
    $res = 0;
19✔
240
    for ($i = 0; $i <= $x; $i++) {
19✔
241
        $res += binomialpdf($n, $p, $i);
19✔
242
    }
243
    return $res;
19✔
244
}
245

246
function npr($n, $r) {
247
    $n = (int)$n;
38✔
248
    $r = (int)$r;
38✔
249
    if ($r == 0 && $n == 0) {
38✔
250
        return 0;
19✔
251
    }
252
    return ncr($n, $r) * fact($r);
38✔
253
}
254

255
function ncr($n, $r) {
256
    $n = (int)$n;
95✔
257
    $r = (int)$r;
95✔
258
    if ($r > $n) {
95✔
259
        return 0;
38✔
260
    }
261
    if (($n - $r) < $r) {
95✔
262
        return ncr($n, ($n - $r));
76✔
263
    }
264
    $numerator = 1;
95✔
265
    $denominator = 1;
95✔
266
    for ($i = 1; $i <= $r; $i++) {
95✔
267
        $numerator *= ($n - $i + 1);
95✔
268
        $denominator *= $i;
95✔
269
    }
270
    return intdiv($numerator, $denominator);
95✔
271
}
272

273
function gcd($a, $b) {
274
    if ($a < 0) {
76✔
275
        $a = abs($a);
×
276
    }
277
    if ($b < 0) {
76✔
278
        $b = abs($b);
×
279
    }
280
    if ($a == 0 && $b == 0) {
76✔
281
        return 0;
19✔
282
    }
283
    if ($a == 0 || $b == 0) {
76✔
284
        return $a + $b;
19✔
285
    }
286
    if ($a == $b) {
76✔
287
        return $a;
38✔
288
    }
289
    do {
290
        $rest = (int) $a % $b;
76✔
291
        $a = $b;
76✔
292
        $b = $rest;
76✔
293
    } while ($rest > 0);
76✔
294
    return $a;
76✔
295
}
296

297
function lcm($a, $b) {
298
    if ($a == 0 || $b == 0) {
38✔
299
        return 0;
19✔
300
    }
301
    return $a * $b / gcd($a, $b);
38✔
302
}
303

304
function sigfig($number, $precision) {
305
    if ($number == 0) {
57✔
306
        $decimalplaces = $precision - 1;
×
307
    } else if ($number < 0) {
57✔
308
        $decimalplaces = $precision - floor(log10($number * -1)) - 1;
19✔
309
    } else {
310
        $decimalplaces = $precision - floor(log10($number)) - 1;
57✔
311
    }
312
    $answer = ($decimalplaces > 0) ?
57✔
313
            number_format($number, $decimalplaces, '.', '') : number_format(round($number, $decimalplaces), 0, '.', '');
57✔
314
    return $answer;
57✔
315
}
316

317
/**
318
 * format a polynomial to be display with LaTeX / MathJax
319
 * can also be used to force the plus sign for a single number
320
 * can also be used for arbitrary linear combinations
321
 *
322
 * @author Philipp Imhof
323
 * @param mixed $variables one variable (as a string) or a list of variables (array of strings)
324
 * @param mixed $coefficients one number or an array of numbers to be used as coefficients
325
 * @param string $forceplus symbol to be used for the normally invisible leading plus, optional
326
 * @param string $additionalseparator symbol to be used as separator between the terms, optional
327
 * @return string  the formatted string
328
 */
329
function poly($variables, $coefficients = null, $forceplus = '', $additionalseparator = '') {
330
    // If no variable is given and there is just one single number, simply force the plus sign
331
    // on positive numbers.
332
    if ($variables === '' && is_numeric($coefficients)) {
38✔
333
        if ($coefficients > 0) {
19✔
334
            return $forceplus . $coefficients;
19✔
335
        }
336
        return $coefficients;
19✔
337
    }
338

339
    $numberofterms = count($coefficients);
38✔
340
    // By default, we think that a final coefficient == 1 is not to be shown, because it is a true coefficient
341
    // and not a constant term. Also, terms with coefficient == zero should generally be completely omitted.
342
    $constantone = false;
38✔
343
    $omitzero = true;
38✔
344

345
    // If the variable is left empty, but there is a list of coefficients, we build an empty array
346
    // of the same size as the number of coefficients. This can be used to pretty-print matrix rows.
347
    // In that case, the numbers 1 and 0 should never be omitted.
348
    if ($variables === '') {
38✔
349
        $variables = array_fill(0, $numberofterms, '');
19✔
350
        $constantone = true;
19✔
351
        $omitzero = false;
19✔
352
    }
353

354
    // If there is just one variable, we blow it up to an array of the correct size and descending exponents.
355
    if (gettype($variables) === 'string' && $variables !== '') {
38✔
356
        // As we have just one variable, we are building a standard polynomial where the last coefficient
357
        // is not a real coefficient, but a constant term that has to be printed.
358
        $constantone = true;
38✔
359
        $tmp = $variables;
38✔
360
        $variables = array();
38✔
361
        for ($i = 0; $i < $numberofterms; $i++) {
38✔
362
            if ($i == $numberofterms - 2) {
38✔
363
                $variables[$i] = $tmp;
38✔
364
            } else if ($i == $numberofterms - 1) {
38✔
365
                $variables[$i] = '';
38✔
366
            } else {
367
                $variables[$i] = $tmp . '^{' . ($numberofterms - 1 - $i) . '}';
38✔
368
            }
369
        }
370
    }
371
    // If the list of variables is shorter than the list of coefficients, just start over again.
372
    if (count($variables) < $numberofterms) {
38✔
373
        $numberofvars = count($variables);
19✔
374
        for ($i = count($variables); $i < $numberofterms; $i++) {
19✔
375
            $variables[$i] = $variables[$i % $numberofvars];
19✔
376
        }
377
    }
378

379
    // If the separator is "doubled", e.g. &&, we put one half before and one half after the
380
    // operator. By default, we have the entire separator before the operator. Also, we do not
381
    // change anything if we are building a matrix row, because there are no operators. (They are signs.)
382
    $separatorlength = strlen($additionalseparator);
38✔
383
    $separatorbefore = $additionalseparator;
38✔
384
    $separatorafter = '';
38✔
385
    if ($separatorlength > 0 && $separatorlength % 2 === 0 && $omitzero) {
38✔
386
        $tmpbefore = substr($additionalseparator, 0, $separatorlength / 2);
19✔
387
        $tmpafter = substr($additionalseparator, $separatorlength / 2);
19✔
388
        // If the separator just has even length, but is not "doubled", we don't touch it.
389
        if ($tmpbefore === $tmpafter) {
19✔
390
            $separatorbefore = $tmpbefore;
19✔
391
            $separatorafter = $tmpafter;
19✔
392
        }
393
    }
394

395
    $result = '';
38✔
396
    // First term should not have a leading plus sign, unless user wants to force it.
397
    foreach ($coefficients as $i => $coef) {
38✔
398
        $thisseparatorbefore = ($i == 0 ? '' : $separatorbefore);
38✔
399
        $thisseparatorafter = ($i == 0 ? '' : $separatorafter);
38✔
400
        // Terms with coefficient == 0 are generally not shown. But if we use a separator, it must be printed anyway.
401
        if ($coef == 0) {
38✔
402
            if ($i > 0) {
19✔
403
                $result .= $thisseparatorbefore . $thisseparatorafter;
19✔
404
            }
405
            if ($omitzero) {
19✔
406
                continue;
19✔
407
            }
408
        }
409
        // Put a + or - sign according to value of coefficient and replace the coefficient
410
        // by its absolute value, as we don't need the sign anymore after this step.
411
        // If the coefficient is 0 and we force its output, do it now. However, do not put a sign,
412
        // as the only documented usage of this is for matrix rows and the like.
413
        if ($coef < 0) {
38✔
414
            $result .= $thisseparatorbefore . '-' . $thisseparatorafter;
19✔
415
            $coef = abs($coef);
19✔
416
        } else if ($coef > 0) {
38✔
417
            // If $omitzero is false, we are building a matrix row, so we don't put plus signs.
418
            $result .= $thisseparatorbefore . ($omitzero ? '+' : '') . $thisseparatorafter;
38✔
419
        }
420
        // Put the coefficient. If the coefficient is +1 or -1, we don't put the number,
421
        // unless we're at the last term. The sign is already there, so we use the absolute value.
422
        // Never omit 1's if building a matrix row.
423
        if ($coef == 1) {
38✔
424
            $coef = (!$omitzero || ($i == $numberofterms - 1 && $constantone) ? '1' : '');
38✔
425
        }
426
        $result .= $coef . $variables[$i];
38✔
427
    }
428
    // If the resulting string is empty (or empty with just alignment separators), add a zero at the end.
429
    if ($result === '' || $result === str_repeat($additionalseparator, $numberofterms - 1)) {
38✔
430
        $result .= '0';
19✔
431
    }
432
    // Strip leading + and replace by $forceplus (which will be '' or '+' most of the time).
433
    if ($result[0] == '+') {
38✔
434
        $result = $forceplus . substr($result, 1);
38✔
435
    }
436
    // If we have nothing but separators before the leading +, replace that + by $forceplus.
437
    if ($separatorbefore !== '' && preg_match("/^($separatorbefore+)\+/", $result)) {
38✔
438
        $result = preg_replace("/^($separatorbefore+)\+/", "\\1$forceplus", $result);
19✔
439
    }
440
    return $result;
38✔
441
}
442

443
/**
444
 * Class contains methods to parse variables text and evaluate variables. Results are stored in the $vstack
445
 * The functions can be roughly classified into 5 categories:
446
 *
447
 * - handle variable stack
448
 * - substitute number, string, function and variable name by placeholder, and the reverse functino
449
 * - parse and instantiate random variable
450
 * - evaluate assignments, general expression and numerical expression.
451
 * - evaluate algebraic formula
452
 */
453
class variables {
454
    private static $maxdataset = 2e9;      // It is the upper limit for the exhaustive enumeration.
455
    private static $listmaxsize = 1000;
456

457
    /* Defining legacy properties here for compatibility with PHP 8.2 */
458
    private $func_const = [];
459
    private $func_unary = [];
460
    private $func_binary = [];
461
    private $func_special = [];
462
    private $func_all = [];
463
    private $binary_op_map = [];
464
    private $func_algebraic = [];
465
    private $constlist = ['pi' => '3.14159265358979323846'];
466
    private $evalreplacelist = ['ln' => 'log', 'log10' => '(1./log(10.))*log'];
467

468
    private function initialize_function_list() {
469
        $this->func_const = array_flip( array('pi', 'fqversionnumber'));
2,261✔
470
        $this->func_unary = array_flip( array('abs', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atanh', 'ceil',
2,261✔
471
            'cos', 'cosh' , 'deg2rad', 'exp', 'expm1', 'floor', 'is_finite', 'is_infinite', 'is_nan',
2,261✔
472
            'log10', 'log1p', 'rad2deg', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'log', 'round', 'fact',
2,261✔
473
            'stdnormpdf', 'stdnormcdf', 'decbin', 'decoct', 'octdec', 'bindec') );
2,261✔
474
        $this->func_binary = array_flip(
2,261✔
475
          array('log', 'round', 'atan2', 'fmod', 'pow', 'min', 'max', 'ncr', 'npr', 'gcd', 'lcm', 'sigfig', 'modinv')
2,261✔
476
        );
2,261✔
477
        $this->func_special = array_flip(
2,261✔
478
          array('fill', 'len', 'pick', 'sort', 'sublist', 'inv', 'map', 'sum', 'concat', 'join', 'str', 'diff', 'poly', 'normcdf',
2,261✔
479
          'modpow', 'binomialpdf', 'binomialcdf')
2,261✔
480
        );
2,261✔
481
        $this->func_all = array_merge($this->func_const, $this->func_unary, $this->func_binary, $this->func_special);
2,261✔
482
        $this->binary_op_map = array_flip(
2,261✔
483
          array('+', '-', '*', '/', '%', '>', '<', '==', '!=', '&&', '||', '&', '|', '<<', '>>', '^')
2,261✔
484
        );
2,261✔
485
        // $this->binary_op_reduce = array_flip( array('||', '&&', '==', '+', '*') );
486

487
        // Note that the implementation is exactly the same as the client so the behaviour should be the same.
488
        $this->func_algebraic = array_flip( array('sin', 'cos', 'tan', 'asin', 'acos', 'atan',
2,261✔
489
                                                  'exp', 'log10', 'ln', 'sqrt', 'abs', 'ceil', 'floor', 'fact'));
2,261✔
490
        // Natural log and log with base 10, no log allowed to avoid ambiguity.
491
    }
492

493
    public function __construct() {
494
        $this->initialize_function_list();
2,261✔
495
    }
496

497
    /**
498
     * Data structure of the variables stack object, containing:
499
     * - all is an array with name (key) => data (value),
500
     *   - data is and object contains the type information and variable value.
501
     * - idcounter stores the largest id of temporary variables
502
     *
503
     * Note the basic type of the variables are:
504
     * n: number, s: string, ln: list of number, ls: list of string, a: algebraic variable
505
     *
506
     * Note also that the type used internally are:
507
     * f: function that can be used for algebraic formula, F: functions that will be used internally only
508
     * z(n, s, ln, ls): set of (number, string, list of number, list of string),
509
     * zh(ln, ls): shuffle (list of number, list of string)
510
     * Note the type number 'n' has a "constantness" associated to it. The value is of type string if it is constant
511
     */
512

513
    // This function must be called to initial a variable stack, and the returned variable is required by most function.
514
    public function vstack_create() {
515
        return (object)array('idcounter' => 0, 'all' => array());
1,881✔
516
    }
517

518
    // Return a serialized string of vstack with type n, s, ln, ls. It can be reconstructed by calling evaluate_assignments().
519
    public function vstack_get_serialization(&$vstack) {
520
        $ctype = array_flip(explode(',', 'n,s,ln,ls'));
456✔
521
        $vstr = '';
456✔
522
        foreach ($vstack->all as $name => $data) {
456✔
523
            if (array_key_exists($data->type, $ctype)) {
95✔
524
                // Convert all into arrays for homogeneous treatment.
525
                $values = $data->type[0] == 'l' ? $data->value : array($data->value);
95✔
526
                if ($data->type == 's' || $data->type == 'ls') {
95✔
527
                    for ($i = 0; $i < mycount($values); $i++) {
19✔
528
                        // String has a quotation.
529
                        $values[$i] = '"'.$values[$i].'"';
19✔
530
                    }
531
                }
532
                $vstr .= $name . '=' . ($data->type[0] == 'l' ? ('['.implode(',', $values).']') : $values[0]) . ';';
95✔
533
            }
534
        }
535
        return $vstr;
456✔
536
    }
537

538
    // Return the size of sample space, or null if it is too large. The purpose of this number is to instantiate all random dataset.
539
    public function vstack_get_number_of_dataset(&$vstack) {
540
        $numdataset = 1;
570✔
541
        foreach ($vstack->all as $name => $data) {
570✔
542
            if ($data->type[0] == 'z' && $data->type[1] != 'h') {
171✔
543
                // The 'shuffle' is not counted, as it always have large number of permutation...
544
                $numdataset *= $data->value->numelement;
171✔
545
                if ($numdataset > self::$maxdataset) {
171✔
546
                    return null;
×
547
                }
548
            }
549
        }
550
        return $numdataset;
570✔
551
    }
552

553
    // Return the size of sample space, or null if it is too large. The purpose of this number is to instantiate all random dataset.
554
    public function vstack_get_number_of_dataset_with_shuffle(&$vstack) {
555
        $numdataset = 1;
×
556
        foreach ($vstack->all as $name => $data) {
×
557
            if ($data->type[0] == 'z') {
×
558
                $numdataset *= $data->value->numelement;
×
559
                if ($numdataset > self::$maxdataset) {
×
560
                    return null;
×
561
                }
562
            }
563
        }
564
        return $numdataset;
×
565
    }
566

567
    // Return whether there is shuffled data.
568
    public function vstack_get_has_shuffle(&$vstack) {
569
        foreach ($vstack->all as $name => $data) {
×
570
            if ($data->type[0] == 'zh') {
×
571
                return true;
×
572
            }
573
        }
574
        return false;
×
575
    }
576

577
    // Return the list of variables stored in the vstack.
578
    public function vstack_get_names(&$vstack) {
579
        return array_keys($vstack->all);
×
580
    }
581

582
    public function vstack_get_variable(&$vstack, $name) {
583
        return array_key_exists($name, $vstack->all) ? $vstack->all[$name] : null;
1,767✔
584
    }
585

586
    public function vstack_update_variable(&$vstack, $name, $index, $type, $value) {
587
        if ($index === null) {
1,786✔
588
            if ($type[0] == 'l') {  // Error check for list.
1,786✔
589
                if (!is_array($value)) {
475✔
590
                    throw new Exception('Unknown error. vstack_update_variable()');
×
591
                }
592
                if (mycount($value) < 1 || mycount($value) > self::$listmaxsize) {
475✔
593
                    throw new Exception(get_string('error_vars_array_size', 'qtype_formulas'));
×
594
                }
595
                if (!is_numeric($value[0]) && !is_string($value[0])) {
475✔
596
                    throw new Exception(get_string('error_vars_array_type', 'qtype_formulas'));
19✔
597
                }
598
                if ($type[1] == 'n') {
475✔
599
                    for ($i = 0; $i < mycount($value); $i++) {
456✔
600
                        if (!is_numeric($value[$i])) {
456✔
601
                            throw new Exception(get_string('error_vars_array_type', 'qtype_formulas'));
38✔
602
                        }
603
                        $value[$i] = floatval($value[$i]);
456✔
604
                    }
605
                } else {
606
                    for ($i = 0; $i < mycount($value); $i++) {
152✔
607
                        if (!is_string($value[$i])) {
152✔
608
                            throw new Exception(get_string('error_vars_array_type', 'qtype_formulas'));
×
609
                        }
610
                    }
611
                }
612
            }
613
            $vstack->all[$name] = (object)array('type' => $type, 'value' => $value);
1,786✔
614
        } else {
615
            $list = &$vstack->all[$name];
38✔
616
            if ($list->type[0] != 'l') {
38✔
617
                throw new Exception(get_string('error_vars_array_unsubscriptable', 'qtype_formulas'));
×
618
            }
619
            $index = intval($index);
38✔
620
            if ($index < 0 || $index >= mycount($list->value)) {
38✔
621
                throw new Exception(get_string('error_vars_array_index_out_of_range', 'qtype_formulas'));
×
622
            }
623
            if ($list->type[1] != $type) {
38✔
624
                throw new Exception(get_string('error_vars_array_type', 'qtype_formulas'));
19✔
625
            }
626
            $list->value[$index] = $type == 'n' ? floatval($value) : $value;
19✔
627
        }
628
    }
629

630
    private function vstack_mark_current_top(&$vstack) {
631
        return (object)array('idcounter' => $vstack->idcounter, 'sz' => mycount($vstack->all));
950✔
632
    }
633

634
    private function vstack_restore_previous_top(&$vstack, $previoustop) {
635
        $vstack->all = array_slice($vstack->all, 0, $previoustop->sz);
931✔
636
        $vstack->idcounter = $previoustop->idcounter;
931✔
637
    }
638

639
    private function vstack_add_temporary_variable(&$vstack, $type, $value) {
640
        $name = '@' . $vstack->idcounter;
1,786✔
641
        $this->vstack_update_variable($vstack, $name, null, $type, $value);
1,786✔
642
        $vstack->idcounter++;
1,786✔
643
        return $name;
1,786✔
644
    }
645

646
    private function vstack_clean_temporary(&$vstack) {
647
        $tmp = $this->vstack_create();
1,064✔
648
        foreach ($vstack->all as $name => $data) {
1,064✔
649
            if ($name[0] != '@') {
931✔
650
                $tmp->all[$name] = $data;
931✔
651
            }
652
        }
653
        return $tmp;
1,064✔
654
    }
655

656
    /**
657
     * These functions replace the string, number, fixed range, function and variable name by placeholder (start with @)
658
     * Also, the reverse substitution function also available for different situation.
659
     * Note that string and fixed range are not treated as placeholder, so text with them cannot be fully recovered.
660
     */
661

662
    // Return the text with the variables, or evaluable expressions, substituted by their values.
663
    public function substitute_variables_in_text(&$vstack, $text) {
664
        $funcpattern = '/(\{=[^{}]+\}|\{([A-Za-z][A-Za-z0-9_]*)(\[([0-9]+)\])?\})/';
171✔
665
        $results = [];
171✔
666
        if (is_string($text)) {
171✔
667
            // @codingStandardsIgnoreLine
668
            $ts = explode("\n`", $text);     // The ` is the separator, so split it first.
171✔
669
        } else {
670
            $ts = [];
×
671
        }
672
        foreach ($ts as $text) {
171✔
673
            // @codingStandardsIgnoreLine
674
            $splitted = explode("\n`", preg_replace($funcpattern, "\n`$1\n`", $text));
171✔
675
            for ($i = 1; $i < mycount($splitted); $i += 2) {
171✔
676
                try {
677
                    $expr = substr($splitted[$i], $splitted[$i][1] == '=' ? 2 : 1 , -1);
57✔
678
                    $res = $this->evaluate_general_expression($vstack, $expr);
57✔
679
                    // Skip for other type.
680
                    if ($res->type != 'n' && $res->type != 's') {
57✔
681
                        throw new Exception();
38✔
682
                    }
683
                    $splitted[$i] = $res->value;
57✔
684
                } catch (Exception $e) { // @codingStandardsIgnoreLine
38✔
685
                    // Note that the expression will not be replaced if error occurs. Also, no error throw in any cases.
686
                }
687
            }
688
            $results[] = implode('', $splitted);
171✔
689
        }
690
        // @codingStandardsIgnoreLine
691
        return implode("\n`", $results);
171✔
692
    }
693

694
    // Return the original string by substituting back the placeholders (given by variables in $vstack) in the input $text.
695
    private function substitute_placeholders_in_text(&$vstack, $text) {
696
        // @codingStandardsIgnoreLine
697
        $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $text));
760✔
698
        // The length will always be odd, and the placeholder is stored in odd index.
699
        for ($i = 1; $i < mycount($splitted); $i += 2) {
760✔
700
            // Substitute back the strings.
701
            $splitted[$i] = $this->vstack_get_variable($vstack, $splitted[$i])->value;
760✔
702
        }
703
        return implode('', $splitted);
760✔
704
    }
705

706
    // If substitute_variables_by_placeholders() was used for $text,
707
    // then this function forward the value of type 'v' to the actual variable value.
708
    private function substitute_vname_by_variables(&$vstack, $text) {
709
        // @codingStandardsIgnoreLine
710
        $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $text));
969✔
711
        $appearedvars = array();     // Reuse the temporary variable if possible.
969✔
712
        // The length will always be odd, and the numbers are stored in odd index.
713
        for ($i = 1; $i < mycount($splitted); $i += 2) {
969✔
714
            $data = $this->vstack_get_variable($vstack, $splitted[$i]);
969✔
715
            if ($data->type == 'v') {
969✔
716
                $tmp = $this->vstack_get_variable($vstack, $data->value);
494✔
717
                if ($tmp === null) {
494✔
718
                    throw new Exception(
76✔
719
                      get_string('error_vars_undefined', 'qtype_formulas', $data->value) . ' in substitute_vname_by_variables'
76✔
720
                    );
76✔
721
                }
722
                if (!array_key_exists($data->value, $appearedvars)) {
494✔
723
                    $appearedvars[$data->value] = $this->vstack_add_temporary_variable($vstack, $tmp->type, $tmp->value);
494✔
724
                }
725
                $splitted[$i] = $appearedvars[$data->value];
494✔
726
            }
727
        }
728
        return implode('', $splitted);
969✔
729
    }
730

731
    // Replace the strings in the $text.
732
    private function substitute_strings_by_placholders(&$vstack, $text) {
733
        if (is_string($text)) {
1,102✔
734
            $text = stripcslashes($text);
1,102✔
735
        } else {
736
            $text = '';
19✔
737
        }
738
        $splitted = explode("\"", $text);
1,102✔
739
        if (mycount($splitted) % 2 == 0) {
1,102✔
740
            throw new Exception(get_string('error_vars_string', 'qtype_formulas'));
×
741
        }
742
        foreach ($splitted as $i => &$s) {
1,102✔
743
            if ($i % 2 == 1) {
1,102✔
744
                if (strpos($s, '\'') !== false || strpos($s, "\n") !== false) {
209✔
745
                    throw new Exception(get_string('error_vars_string', 'qtype_formulas'));
×
746
                }
747
                $s = $this->vstack_add_temporary_variable($vstack, 's', $s);
209✔
748
            }
749
            // Characters @ and ` can't be used in the main text.
750
            // @codingStandardsIgnoreLine
751
            else if (strpos($s, '@') !== false || strpos($s, '`') !== false) {
1,102✔
752
                throw new Exception(get_string('error_forbid_char', 'qtype_formulas'));
19✔
753
            }
754
        }
755
        return implode('', $splitted);
1,102✔
756
    }
757

758
    // Replace the fixed range of the form [a:b] in the $text by variables with new names in $tmpnames, and add it to the $vars.
759
    private function substitute_fixed_ranges_by_placeholders(&$vstack, $text) {
760
        $rangepattern = '/(\[[^\]]+:[^\]]+\])/';
1,045✔
761
        // @codingStandardsIgnoreLine
762
        $splitted = explode('`', preg_replace($rangepattern, '`$1`', $text));
1,045✔
763
        // The length will always be odd, and the numbers are stored in odd index.
764
        for ($i = 1; $i < mycount($splitted); $i += 2) {
1,045✔
765
            $res = $this->parse_fixed_range($vstack, substr($splitted[$i], 1, -1));
76✔
766
            if ($res === null) {
76✔
767
                throw new Exception(get_string('error_fixed_range', 'qtype_formulas'));
19✔
768
            }
769
            $data = array();
57✔
770
            for ($z = $res->element[0]; $z < $res->element[1]; $z += $res->element[2]) {
57✔
771
                $data[] = $z;
57✔
772
                if (mycount($data) > self::$listmaxsize) {
57✔
773
                    throw new Exception(get_string('error_vars_array_size', 'qtype_formulas'));
×
774
                }
775
            }
776
            if (mycount($data) < 1) {
57✔
777
                throw new Exception(get_string('error_vars_array_size', 'qtype_formulas'));
×
778
            }
779
            $splitted[$i] = $this->vstack_add_temporary_variable($vstack, 'ln', $data);
57✔
780
        }
781
        return implode('', $splitted);
1,045✔
782
    }
783

784
    // Return a string with all (positive) numbers substituted by placeholders. The information of placeholders is stored in v.
785
    private function substitute_numbers_by_placeholders(&$vstack, $text) {
786
        $numpattern = '/(^|[\]\[)(}{, ?:><=~!|&%^\/*+-])(([0-9]+\.?[0-9]*|[0-9]*\.?[0-9]+)([eE][-+]?[0-9]+)?)/';
1,881✔
787
        // @codingStandardsIgnoreLine
788
        $splitted = explode('`', preg_replace($numpattern, '$1`$2`', $text));
1,881✔
789
        // The length will always be odd, and the numbers are stored in odd index.
790
        for ($i = 1; $i < mycount($splitted); $i += 2) {
1,881✔
791
            $splitted[$i] = $this->vstack_add_temporary_variable($vstack, 'n', $splitted[$i]);
1,767✔
792
        }
793
        return implode('', $splitted);
1,881✔
794
    }
795

796
    // Return a string with all functions substituted by placeholders. The information of placeholders is stored in v.
797
    private function substitute_functions_by_placeholders(&$vstack, $text, $internal=false) {
798
        $funcpattern = '/([a-z][a-z0-9_]*)(\s*\()/';
1,881✔
799
        $funclists = $internal ? $this->func_all : $this->func_algebraic;
1,881✔
800
        $type = $internal ? 'F' : 'f';
1,881✔
801
        // @codingStandardsIgnoreLine
802
        $splitted = explode('`', preg_replace($funcpattern, '`$1`$2', $text));
1,881✔
803
        // The length will always be odd, and the variables are stored in odd index.
804
        for ($i = 1; $i < mycount($splitted); $i += 2) {
1,881✔
805
            if (!array_key_exists($splitted[$i], $funclists)) {
608✔
806
                continue;
76✔
807
            }
808
            $splitted[$i] = $this->vstack_add_temporary_variable($vstack, $type, $splitted[$i]);
589✔
809
        }
810
        return implode('', $splitted);
1,881✔
811
    }
812

813
    // Return a string with all variables substituted by placeholders. The information of placeholders is stored in v.
814
    private function substitute_constants_by_placeholders(&$vstack, $text, $preserve) {
815
        $varpattern = '/([A-Za-z][A-Za-z0-9_]*)/';
1,178✔
816
        // @codingStandardsIgnoreLine
817
        $splitted = explode('`', preg_replace($varpattern, '`$1`', $text));
1,178✔
818
        // The length will always be odd, and the variables are stored in odd index.
819
        for ($i = 1; $i < mycount($splitted); $i += 2) {
1,178✔
820
            if (!array_key_exists($splitted[$i], $this->constlist)) {
893✔
821
                continue;
893✔
822
            }
823
            $constnumber = $preserve ? $splitted[$i] : $this->constlist[$splitted[$i]];
57✔
824
            $splitted[$i] = $this->vstack_add_temporary_variable($vstack, 'n', $constnumber);
57✔
825
        }
826
        return implode('', $splitted);
1,178✔
827
    }
828

829
    // Return a string with all variables substituted by placeholders. The information of placeholders is stored in v.
830
    private function substitute_variables_by_placeholders(&$vstack, $text, $internal=false) {
831
        $varpattern = $internal ? '/([A-Za-z_][A-Za-z0-9_]*)/' : '/([A-Za-z][A-Za-z0-9_]*)/';
1,140✔
832
        $funclists = $internal ? $this->func_all : $this->func_algebraic;
1,140✔
833
        // @codingStandardsIgnoreLine
834
        $splitted = explode('`', preg_replace($varpattern, '`$1`', $text));
1,140✔
835
        // The length will always be odd, and the variables are stored in odd index.
836
        for ($i = 1; $i < mycount($splitted); $i += 2) {
1,140✔
837
            if (array_key_exists($splitted[$i], $funclists)) {
988✔
838
                throw new Exception(get_string('error_vars_reserved', 'qtype_formulas', $splitted[$i]));
38✔
839
            }
840
            $splitted[$i] = $this->vstack_add_temporary_variable($vstack, 'v', $splitted[$i]);
988✔
841
        }
842
        return implode('', $splitted);
1,140✔
843
    }
844

845
    // Parse the number or range in the format of start(:stop(:interval)). return null if error.
846
    private function parse_fixed_range(&$vstack, $expression) {
847
        $ex = explode(':', $expression);
285✔
848
        if (mycount($ex) > 3) {
285✔
849
            return null;
×
850
        }
851
        $numpart = mycount($ex);
285✔
852
        for ($i = 0; $i < $numpart; $i++) {
285✔
853
            $ex[$i] = trim($ex[$i]);
285✔
854
            if (mycount($ex[$i]) == 0) {
285✔
855
                return null;
×
856
            }
857
            $v = $ex[$i][0] == '-' ? trim(substr($ex[$i], 1)) : $ex[$i]; // Get the sign of the number.
285✔
858
            $num = $this->vstack_get_variable($vstack, $v);     // Num must be a constant number.
285✔
859
            if ($num === null || $num->type != 'n' || !is_string($num->value)) {
285✔
860
                return null;
38✔
861
            }
862
            $ex[$i] = strlen($ex[$i]) == strlen($v) ? floatval($num->value) : -floatval($num->value); // Multiply the sign back.
285✔
863
        }
864
        if (mycount($ex) == 1) {
266✔
865
            $ex = array($ex[0], $ex[0] + 0.5, 1.);
114✔
866
        }
867
        if (mycount($ex) == 2) {
266✔
868
            $ex = array($ex[0], $ex[1], 1.);
247✔
869
        }
870
        if ($ex[0] > $ex[1] || $ex[2] <= 0) {
266✔
871
            return null;
19✔
872
        }
873
        return (object)array('numelement' => ceil( ($ex[1] - $ex[0]) / $ex[2] ), 'element' => $ex, 'numpart' => $numpart);
266✔
874
    }
875

876
    /**
877
     * There are two main forms of random variables, specified in the form 'variable = expression;'
878
     * The first form is declared as a set of either number, string, list of number and list of string.
879
     * One element will be drawn from the set when instantiating. Note that it allow a range format of numbers
880
     * Another one is the shuffling of a list of number or string.
881
     * e.g. A={1,2,3}; B={1, 3:5, 8:9:.1}; C={"A","B"}; D={[1,4],[1,9]}; F=shuffle([0:10]);
882
     */
883

884
    // Parse the random variables $assignments for later instantiation of a dataset. Throw on parsing error.
885
    public function parse_random_variables($text) {
886
        $vstack = $this->vstack_create();
532✔
887
        $text = $this->substitute_strings_by_placholders($vstack, $text);
532✔
888
        $text = $this->trim_comments($text);
532✔
889
        $text = $this->substitute_numbers_by_placeholders($vstack, $text);
532✔
890

891
        // Check whether variables or some reserved variables are used, throw on error.
892
        $tmpvars = clone $vstack;
532✔
893
        $tmptext = $text;
532✔
894
        $tmptext = $this->substitute_functions_by_placeholders($tmpvars, $tmptext, true);
532✔
895
        $tmptext = $this->substitute_variables_by_placeholders($tmpvars, $tmptext, true);
532✔
896

897
        $assignments = explode(';', $text);
532✔
898
        foreach ($assignments as $acounter => $assignment) {
532✔
899
            try {
900
                // Split into variable name and expression.
901
                $ex = explode('=', $assignment, 2);
532✔
902
                $name = trim($ex[0]);
532✔
903
                if (mycount($ex) == 1 && strlen($name) == 0) {
532✔
904
                    continue;   // If empty assignment.
532✔
905
                }
906
                if (mycount($ex) != 2) {
152✔
907
                    throw new Exception(get_string('error_syntax', 'qtype_formulas'));
×
908
                }
909
                if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) {
152✔
910
                    throw new Exception(get_string('error_vars_name', 'qtype_formulas'));
×
911
                }
912
                $expression = trim($ex[1]);
152✔
913
                $expression = $this->substitute_fixed_ranges_by_placeholders($vstack, $expression);
152✔
914
                if (strlen($expression) == 0) {
152✔
915
                    throw new Exception(get_string('error_syntax', 'qtype_formulas'));
×
916
                }
917

918
                // Check whether the expression contains only the valid character set.
919
                $var = (object)array('numelement' => 0, 'elements' => array());
152✔
920
                if ($expression[0] == '{') {
152✔
921
                    $allowableoperatorchar = '-+*/:@0-9,\s}{\]\[';  // Restricted set, prevent too many calculations.
133✔
922
                    // The result expression should only contains simple characters.
923
                    if (!preg_match('~^['.$allowableoperatorchar.']*$~', $expression)) {
133✔
924
                        throw new Exception(get_string('error_forbid_char', 'qtype_formulas'));
19✔
925
                    }
926

927
                    $bracket = $this->get_expressions_in_bracket($expression, 0, '{');
133✔
928
                    if ($bracket === null) {
133✔
929
                        throw new Exception(get_string('error_vars_bracket_mismatch', 'qtype_formulas'));
×
930
                    }
931
                    if (!($bracket->openloc == 0 && $bracket->closeloc == strlen($expression) - 1)) {
133✔
932
                        throw new Exception(get_string('error_syntax', 'qtype_formulas'));
19✔
933
                    }
934

935
                    $type = null;
133✔
936
                    foreach ($bracket->expressions as $i => $ele) {
133✔
937
                        if ($i == 0 && strpos($ele, ':') !== false) {
133✔
938
                            $type = 'n';
95✔
939
                        }
940
                        if ($type != 'n') {
133✔
941
                            $result = $this->evaluate_general_expression_substituted_recursively($vstack, $ele);
57✔
942
                            if ($i == 0) {
57✔
943
                                $type = $result->type;
57✔
944
                            }
945
                            if ($i > 0 && $result->type != $type) {
57✔
946
                                throw new Exception(get_string('error_randvars_type', 'qtype_formulas'));
19✔
947
                            }
948
                            $element = $result->value;
57✔
949
                            $numelement = 1;
57✔
950
                        }
951
                        if ($type == 'n') { // Special handle for number, because it can be specified as a range.
133✔
952
                            $result = $this->parse_fixed_range($vstack, $ele);
133✔
953
                            if ($result === null) {
133✔
954
                                throw new Exception(get_string('error_syntax', 'qtype_formulas'));
19✔
955
                            }
956
                            $element = $result->element;
133✔
957
                            $numelement = $result->numelement;
133✔
958
                        }
959
                        if ($i == 0) {
133✔
960
                            $listsize = $type[0] == 'l' ? mycount($element) : 1;
133✔
961
                        }
962
                        if ($i > 0) {
133✔
963
                            if (($type[0] == 'l' ? mycount($element) : 1) != $listsize) {
57✔
964
                                throw new Exception(get_string('error_randvars_type', 'qtype_formulas'));
19✔
965
                            }
966
                        }
967
                        $var->elements[] = $element;
133✔
968
                        $var->numelement += $numelement;
133✔
969
                    }
970
                    $type = 'z'.$type;
133✔
971
                } else if ( preg_match('~^shuffle\s*\(([-+*/@0-9,\s\[\]]+)\)$~', $expression, $matches) ) {
76✔
972
                    $result = $this->evaluate_general_expression_substituted_recursively($vstack, $matches[1]);
19✔
973
                    if ($result === null || $result->type[0] != 'l') {
19✔
974
                        throw new Exception(get_string('error_syntax', 'qtype_formulas'));
×
975
                    }
976
                    $type = 'zh'.$result->type;
19✔
977
                    // Factorials can get pretty big, so it is worth limiting the true count to
978
                    // some reasonable number, e.g. 1000.
979
                    $var->numelement = min(1000, fact(mycount($result->value)));
19✔
980
                    $var->elements = $result->value;
19✔
981
                } else {
982
                    throw new Exception(get_string('error_syntax', 'qtype_formulas'));
57✔
983
                }
984

985
                // There must be at least two elements to draw from, otherwise it is not a random variable.
986
                if ($var->numelement < 2) {
133✔
987
                    throw new Exception(get_string('error_randvars_set_size', 'qtype_formulas'));
×
988
                }
989
                $this->vstack_update_variable($vstack, $name, null, $type, $var);
133✔
990
            } catch (Exception $e) {    // Append the error message by the line info.
76✔
991
                throw new Exception(($acounter + 1).': '.$name.': '.$e->getMessage());
76✔
992
            }
993
        }
994
        return $this->vstack_clean_temporary($vstack);
532✔
995
    }
996

997
    // Instantiate a particular variables set given by datasetid (-1 for random). Another vstack of will be returned.
998
    public function instantiate_random_variables(&$vstack, $datasetid = -1) {
999
        $numdataset = $this->vstack_get_number_of_dataset($vstack);
570✔
1000
        $datasetid = ($datasetid >= 0 && $datasetid < self::$maxdataset) ? $datasetid % $numdataset : -1;
570✔
1001
        $newstack = $this->vstack_create(); // The instantiated result will be stored in another vstack.
570✔
1002
        foreach ($vstack->all as $name => $data) {
570✔
1003
            if ( $data->type[0] == 'z') {
171✔
1004
                $v = &$data->value;
171✔
1005
                if ( $data->type[1] == 'h') {
171✔
1006
                    $tmp = $v->elements;
19✔
1007
                    shuffle($tmp);
19✔
1008
                    $this->vstack_update_variable($newstack, $name, null, 'l'.$data->type[3], $tmp);
19✔
1009
                } else {
1010
                    $id = ($datasetid >= 0) ? $datasetid % $v->numelement : mt_rand(0, $v->numelement - 1);
171✔
1011
                    $datasetid = ($datasetid >= 0) ? intval($datasetid / $v->numelement) : -1;
171✔
1012
                    // If type is 'set_number', then pick up the correct element using following algorithm.
1013
                    if ( $data->type[1] == 'n' ) {
171✔
1014
                        foreach ($v->elements as $elem) {
171✔
1015
                            $sz = ceil( ($elem[1] - $elem[0]) / $elem[2] );
171✔
1016
                            if ( $id < $sz) {
171✔
1017
                                $this->vstack_update_variable($newstack, $name, null, 'n', $elem[0] + $id * $elem[2]);
171✔
1018
                                break;
171✔
1019
                            }
1020
                            $id -= $sz;
61✔
1021
                        }
1022
                    } else {
1023
                        // Directly pick one element for type s,ln,ls.
1024
                        $this->vstack_update_variable($newstack, $name, null, substr($data->type, 1), $v->elements[$id]);
19✔
1025
                    }
1026
                }
1027
            }
1028
        }
1029
        return $newstack;
570✔
1030
    }
1031

1032
    // This function can evaluate mathematical formula, manipulate lists of number and concatenate strings
1033
    // The $vars contains variables evaluated previously and it will return the evaluated variables in $text.
1034
    public function evaluate_assignments($vars, $text) {
1035
        $vstack = clone $vars;
988✔
1036
        $text = $this->substitute_strings_by_placholders($vstack, $text);
988✔
1037
        $text = $this->trim_comments($text);
988✔
1038
        $text = $this->substitute_numbers_by_placeholders($vstack, $text);
988✔
1039
        $text = $this->substitute_fixed_ranges_by_placeholders($vstack, $text);
988✔
1040
        $text = $this->substitute_functions_by_placeholders($vstack, $text, true);
988✔
1041
        $text = $this->substitute_variables_by_placeholders($vstack, $text, true);
988✔
1042
        $acounter = 0;
988✔
1043
        try {
1044
            $this->evaluate_assignments_substituted($vstack, $text, $acounter);
988✔
1045
        } catch (Exception $e) {
285✔
1046
            throw new Exception($acounter.': '.$e->getMessage());
285✔
1047
        }
1048
        return $this->vstack_clean_temporary($vstack);
969✔
1049
    }
1050

1051
    // Return the evaluated general expression by calling evaluate_assignments().
1052
    public function evaluate_general_expression($vars, $expression) {
1053
        $vstack = clone $vars;
418✔
1054
        $expression = $this->substitute_strings_by_placholders($vstack, $expression);
418✔
1055
        $expression = $this->substitute_numbers_by_placeholders($vstack, $expression);
418✔
1056
        $expression = $this->substitute_fixed_ranges_by_placeholders($vstack, $expression);
418✔
1057
        $expression = $this->substitute_functions_by_placeholders($vstack, $expression, true);
418✔
1058
        $expression = $this->substitute_variables_by_placeholders($vstack, $expression, true);
418✔
1059
        $allowableoperatorchar = '-+/*%>:^\~<?=&|!,0-9\s)(\]\[' . '@';
418✔
1060
        // The result expression should only contains simple characters.
1061
        if (!preg_match('~^['.$allowableoperatorchar.']*$~', $expression)) {
418✔
1062
            throw new Exception(get_string('error_forbid_char', 'qtype_formulas'));
×
1063
        }
1064
        $expression = $this->substitute_vname_by_variables($vstack, $expression);
418✔
1065
        return $this->evaluate_general_expression_substituted_recursively($vstack, $expression);
418✔
1066
    }
1067

1068
    // Parse and evaluate the substituted assignments one by one.
1069
    private function evaluate_assignments_substituted(&$vstack, $subtext, &$acounter) {
1070
        $cursor = 0;
988✔
1071
        while ($cursor < strlen($subtext)) {
988✔
1072
            $acounter++;
722✔
1073
            if ($acounter > 20000) {
722✔
1074
                // Prevent infinite loop.
1075
                break;
×
1076
            }
1077

1078
            $first = $this->get_next_variable($vstack, $subtext, $cursor);
722✔
1079
            if ($first !== null && $first->var->type == 'v' && $first->var->value == 'for') {   // Handle the for loop.
722✔
1080
                // Get the for loop header: the variable name and the expression.
1081
                $header = $this->get_expressions_in_bracket($subtext, $first->endloc, '(');
19✔
1082
                if ($header === null) {
19✔
1083
                    throw new Exception('Unknown error: for loop');
×
1084
                }
1085
                $h = explode(':', implode('', $header->expressions), 2);
19✔
1086
                if (mycount($h) == 1) {
19✔
1087
                    throw new Exception(get_string('error_forloop', 'qtype_formulas'));
19✔
1088
                }
1089
                $loopvar = $this->vstack_get_variable($vstack, trim($h[0]));
19✔
1090
                if ($loopvar === null || $loopvar->type != 'v' || $loopvar->value[0] == '_') {
19✔
1091
                    throw new Exception(get_string('error_forloop_var', 'qtype_formulas'));
19✔
1092
                }
1093
                $expression = $this->substitute_vname_by_variables($vstack, $h[1]);
19✔
1094
                $list = $this->evaluate_general_expression_substituted_recursively($vstack, $expression);
19✔
1095
                if ($list->type[0] != 'l') {
19✔
1096
                    throw new Exception(get_string('error_forloop_expression', 'qtype_formulas'));
×
1097
                }
1098

1099
                // Get the assignments in the inner for loop.
1100
                $isopen = strpos($subtext, '{', $header->closeloc);
19✔
1101
                // There must have no other text between the for loop and open bracket '{'.
1102
                if ($isopen !== false) {
19✔
1103
                    $isopen = strlen(trim(substr($subtext, $header->closeloc + 1, max(0, $isopen - $header->closeloc - 2)))) == 0;
19✔
1104
                }
1105
                if ($isopen === true) {
19✔
1106
                    $bracket = $this->get_expressions_in_bracket($subtext, $header->closeloc, '{');
19✔
1107
                    $innertext = implode('', $bracket->expressions);
19✔
1108
                    $cursor = $bracket->closeloc + 1;
19✔
1109
                } else {
1110
                    $nextcursor = strpos($subtext, ';', $header->closeloc);
19✔
1111
                    // If no end separator, use all text until the end.
1112
                    if ($nextcursor === false) {
19✔
1113
                        $nextcursor = strlen($subtext);
19✔
1114
                    }
1115
                    $innertext = substr($subtext, $header->closeloc + 1, $nextcursor - $header->closeloc - 1);
19✔
1116
                    $cursor = $nextcursor + 1;
19✔
1117
                }
1118

1119
                // Loop over the assignments using loop counter one by one.
1120
                $curacounter = $acounter + 1;
19✔
1121
                foreach ($list->value as $e) {    // Call this function for the inner loop recursively.
19✔
1122
                    $acounter = $curacounter;
19✔
1123
                    $this->vstack_update_variable($vstack, $loopvar->value, null, $list->type[1], $e);
19✔
1124
                    $this->evaluate_assignments_substituted($vstack, $innertext, $acounter);
19✔
1125
                }
1126
            } else {
1127
                // Find the next assignment and then advance the cursor after the ';'.
1128
                $nextcursor = strpos($subtext, ';', $cursor);
722✔
1129
                // If no end separator, use all text until the end.
1130
                if ($nextcursor === false) {
722✔
1131
                    $nextcursor = strlen($subtext);
152✔
1132
                }
1133
                $assignment = substr($subtext, $cursor, $nextcursor - $cursor);
722✔
1134
                $cursor = $nextcursor + 1;
722✔
1135

1136
                // Check whether the assignment contains only the valid character set.
1137
                $allowableoperatorchar = '-+/*%>:^\~<?=&|!,0-9\s)(}{\]\[' . '@';
722✔
1138
                // The result expression should contains simple characters only.
1139
                if (!preg_match('~^['.$allowableoperatorchar.']*$~', $assignment)) {
722✔
1140
                    throw new Exception(get_string('error_forbid_char', 'qtype_formulas'));
×
1141
                }
1142

1143
                // Split into variable name and expression.
1144
                $ex = explode('=', $assignment, 2);
722✔
1145
                $name = trim($ex[0]);
722✔
1146
                if (mycount($ex) == 1 && strlen($name) == 0) {
722✔
1147
                    continue;   // If empty assignment.
57✔
1148
                }
1149
                if (mycount($ex) != 2) {
722✔
1150
                    throw new Exception(get_string('error_syntax', 'qtype_formulas'));
×
1151
                }
1152
                $expression = trim($ex[1]);
722✔
1153
                // Check variable name format.
1154
                $nameindex = $this->get_variable_name_index($vstack, $name);
722✔
1155
                if ($nameindex === null) {
722✔
1156
                    throw new Exception(get_string('error_vars_name', 'qtype_formulas'));
×
1157
                }
1158
                // Check whether all variables name are defined before and then replacing them by the value.
1159
                $expression = $this->substitute_vname_by_variables($vstack, $expression);
722✔
1160

1161
                // Check for algebraic variable, it must be a simple assignment.
1162
                $result = $this->parse_algebraic_variable($vstack, $expression);
722✔
1163
                // If it is not an algebraic variable, try to evaluate it.
1164
                if ($result === null) {
722✔
1165
                    $result = $this->evaluate_general_expression_substituted_recursively($vstack, $expression);
684✔
1166
                }
1167
                // Put the evaluated result into the variable name.
1168
                $this->vstack_update_variable($vstack, $nameindex[0], $nameindex[1], $result->type, $result->value);
703✔
1169
            }
1170
        }
1171
    }
1172

1173
    // Evaluate expression with list operation, special function and numerical expression.
1174
    private function evaluate_general_expression_substituted_recursively(&$vstack, $expression) {
1175
        $expression = trim($expression);
950✔
1176
        // Check whether expression is empty.
1177
        if (strlen($expression) == 0) {
950✔
1178
            throw new Exception(get_string('error_subexpression_empty', 'qtype_formulas'));
152✔
1179
        }
1180
        $curtop = $this->vstack_mark_current_top($vstack);
950✔
1181
        while (true) {
950✔
1182
            $result = $this->vstack_get_variable($vstack, $expression);
950✔
1183
            if ($result != null) {
950✔
1184
                break;
684✔
1185
            }
1186
            // Note that the square bracket and additional function needed to be handle recursively.
1187
            $match = $this->handle_special_functions($vstack, $expression);
912✔
1188
            if ($match) {
912✔
1189
                continue;
133✔
1190
            }
1191
            $match = $this->handle_square_bracket_syntax($vstack, $expression);
912✔
1192
            if ($match) {
912✔
1193
                continue;
266✔
1194
            }
1195
            // Assume the expression is purely numerical and then evaluate.
1196
            $nums = $this->evaluate_numerical_expression(array($vstack), $expression);
855✔
1197
            $result = (object)array('type' => 'n', 'value' => $nums[0]);
798✔
1198
            break;
798✔
1199
        }
1200
        $this->vstack_restore_previous_top($vstack, $curtop);
931✔
1201
        return $result;
931✔
1202
    }
1203

1204
    // Return the name and index (if any) on the left hand side of assignment. if error, return null.
1205
    private function get_variable_name_index(&$vstack, $name) {
1206
        if (!preg_match('/^(@[0-9]+)(\[(@[0-9]+)\])?$/', $name, $matches)) {
722✔
1207
            return null;
×
1208
        }
1209
        $n = $this->vstack_get_variable($vstack, $matches[1]);
722✔
1210
        // It must be a variable name and not prefixed by "_".
1211
        if ($n->type != 'v' || $n->value[0] == '_') {
722✔
1212
            return null;
×
1213
        }
1214
        if (!isset($matches[3])) {
722✔
1215
            return array($n->value, null);
722✔
1216
        }
1217
        $idx = $this->vstack_get_variable($vstack, $matches[3]);
38✔
1218
        // If it is a variable, get its value.
1219
        if ($idx->type == 'v') {
38✔
1220
            $idx = $this->vstack_get_variable($vstack, $idx->value);
38✔
1221
        }
1222
        if ($idx->type == 'n') {
38✔
1223
            return array($n->value, $idx->value);
38✔
1224
        } else {
1225
            return null;
×
1226
        }
1227
    }
1228

1229
    // Parse the algebraic variable, which is the same as the set of number for random variable.
1230
    public function parse_algebraic_variable(&$vstack, $expression) {
1231
        $expression = trim($expression);
722✔
1232
        if (strlen($expression) == 0) {
722✔
1233
            return null;
38✔
1234
        }
1235
        if ($expression[0] != '{') {
722✔
1236
            return null;
684✔
1237
        }
1238
        $bracket = $this->get_expressions_in_bracket($expression, 0, '{');
133✔
1239
        if ($bracket === null) {
133✔
1240
            throw new Exception('Unknown error: parse_algebraic_variable()');
×
1241
        }
1242
        if ($bracket->closeloc != strlen($expression) - 1) {
133✔
1243
            throw new Exception(get_string('error_algebraic_var', 'qtype_formulas'));
×
1244
        }
1245
        $numelement = 0;
133✔
1246
        $elements = array();
133✔
1247
        foreach ($bracket->expressions as $e) {
133✔
1248
            $res = $this->parse_fixed_range($vstack, $e);
133✔
1249
            if ($res === null) {
133✔
1250
                throw new Exception(get_string('error_algebraic_var', 'qtype_formulas'));
19✔
1251
            }
1252
            $numelement += $res->numelement;
133✔
1253
            $elements[] = $res->element;
133✔
1254
        }
1255
        return (object)array('type' => 'zn', 'value' => (object)array('numelement' => $numelement, 'elements' => $elements));
133✔
1256
    }
1257

1258
    // Handle the array by replacing it by variable, if necessary, evaluate subexpression by putting it in the $vstack.
1259
    // @return boolean of whether this syntax is found or not.
1260
    private function handle_square_bracket_syntax(&$vstack, &$expression) {
1261
        $res = $this->get_expressions_in_bracket($expression, 0, '[');
912✔
1262
        if ($res == null) {
912✔
1263
            return false;
855✔
1264
        }
1265
        if (mycount($res->expressions) < 1 || mycount($res->expressions) > self::$listmaxsize) {
266✔
1266
            throw new Exception(get_string('error_vars_array_size', 'qtype_formulas'));
×
1267
        }
1268
        $list = array();
266✔
1269
        foreach ($res->expressions as $e) {
266✔
1270
            $list[] = $this->evaluate_general_expression_substituted_recursively($vstack, $e);
266✔
1271
        }
1272
        $data = $this->get_previous_variable($vstack, $expression, $res->openloc);
266✔
1273
        // If the square bracket has a variable before it.
1274
        if ($data !== null) {
266✔
1275
            if ($data->var->type != 'ln' && $data->var->type != 'ls') {
76✔
1276
                throw new Exception(get_string('error_vars_array_unsubscriptable', 'qtype_formulas'));
38✔
1277
            }
1278
            if ($list[0]->type != 'n' || mycount($list) > 1) {
76✔
1279
                throw new Exception(get_string('error_vars_array_index_nonnumeric', 'qtype_formulas'));
19✔
1280
            }
1281
            if ($list[0]->value < 0 || $list[0]->value >= mycount($data->var->value)) {
76✔
1282
                throw new Exception(get_string('error_vars_array_index_out_of_range', 'qtype_formulas'));
×
1283
            }
1284
            $this->replace_middle(
76✔
1285
              $vstack,
76✔
1286
              $expression,
76✔
1287
              $data->startloc,
76✔
1288
              $res->closeloc + 1,
76✔
1289
              $data->var->type[1],
76✔
1290
              $data->var->value[$list[0]->value]
76✔
1291
            );
76✔
1292
            return true;
76✔
1293
        }
1294
        // Check the elements in the list is of the same type and then construct a new list.
1295
        $elementtype = $list[0]->type;
266✔
1296
        for ($i = 0; $i < mycount($list); $i++) {
266✔
1297
            $list[$i] = $list[$i]->value;
266✔
1298
        }
1299
        $this->replace_middle($vstack, $expression, $res->openloc, $res->closeloc + 1, $elementtype == 'n' ? 'ln' : 'ls', $list);
266✔
1300
        return true;
266✔
1301
    }
1302

1303
    // Handle the few function for the array of number or string
1304
    // @return boolean of whether this syntax is found or not.
1305
    private function handle_special_functions(&$vstack, &$expression) {
1306
        // @codingStandardsIgnoreLine
1307
        $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $expression));
912✔
1308
        $loc = 0;
912✔
1309
        for ($i = 1; $i < mycount($splitted); $i += 2) {
912✔
1310
            $data = $this->vstack_get_variable($vstack, $splitted[$i]);
874✔
1311
            if ($data->type == 'F' && array_key_exists($data->value, $this->func_special)) {
874✔
1312
                for ($j = 0; $j <= $i; $j++) {
209✔
1313
                    $loc += strlen($splitted[$j]);
209✔
1314
                }
1315
                break;
209✔
1316
            }
1317
        }
1318
        if ($loc === 0) {
912✔
1319
            return false;
836✔
1320
        }
1321
        $l = $loc - strlen($splitted[$i]);
209✔
1322

1323
        $bracket = $this->get_expressions_in_bracket($expression, $loc, '(');
209✔
1324
        if ($bracket == null) {
209✔
1325
            return false;
×
1326
        }
1327
        $r = $bracket->closeloc + 1;
209✔
1328
        $types = array();
209✔
1329
        $values = array();
209✔
1330
        foreach ($bracket->expressions as $e) {
209✔
1331
            $tmp = $this->evaluate_general_expression_substituted_recursively($vstack, $e);
209✔
1332
            $types[] = $tmp->type;
209✔
1333
            $values[] = $tmp->value;
209✔
1334
        }
1335
        $sz = mycount($types);
209✔
1336
        $typestr = implode(',', $types);
209✔
1337

1338
        switch ($data->value) {
209✔
1339
            case 'fill':
209✔
1340
                if (!($sz == 2 && ($typestr == 'n,n' || $typestr == 'n,s') && is_string($values[0]))) {
57✔
1341
                    break;
38✔
1342
                }
1343
                // Note that if $values[0]===string means that it is constant number.
1344
                $N = intval($values[0]);
57✔
1345
                if ($N < 1 || $N > self::$listmaxsize) {
57✔
1346
                    throw new Exception(get_string('error_vars_array_size', 'qtype_formulas'));
19✔
1347
                }
1348
                $this->replace_middle($vstack, $expression, $l, $r, 'l'.$types[1], array_fill(0, $N, $values[1]));
57✔
1349
                return true;
57✔
1350
            case 'len':
209✔
1351
                // Note: type 'n' with strval is treated as constant.
1352
                if (!($sz == 1 && $typestr[0] == 'l')) {
57✔
1353
                    break;
38✔
1354
                }
1355
                $this->replace_middle($vstack, $expression, $l, $r, 'n', strval(mycount($values[0])));
57✔
1356
                return true;
57✔
1357
            case 'pick':
209✔
1358
                if (!($sz >= 2 && $types[0] == 'n')) {
19✔
1359
                    break;
19✔
1360
                }
1361
                if ($sz == 2) {
19✔
1362
                    if ($types[1][0] != 'l') {
19✔
1363
                        break;
×
1364
                    }
1365
                    $type = $types[1][1];
19✔
1366
                    $pool = $values[1];
19✔
1367
                } else {
1368
                    $type = $types[1];
19✔
1369
                    $pool = array($values[1]);
19✔
1370
                    $allsametype = true;
19✔
1371
                    for ($i = 2; $i < $sz; $i++) {
19✔
1372
                        $allsametype = $allsametype && ($types[$i] == $type);
19✔
1373
                        $pool[] = $values[$i];
19✔
1374
                    }
1375
                    if (!$allsametype) {
19✔
1376
                        break;
19✔
1377
                    }
1378
                }
1379
                // Always choose 0 if index out of range.
1380
                $v = intval($values[0] >= 0 && $values[0] < mycount($pool) ? $values[0] : 0);
19✔
1381
                $this->replace_middle($vstack, $expression, $l, $r, $type, $pool[$v]);
19✔
1382
                return true;
19✔
1383
            case 'sort':
190✔
1384
                if (!($sz >= 1 && $sz <= 2 && $types[0][0] == 'l')) {
38✔
1385
                    break;
19✔
1386
                }
1387
                if ($sz == 2 && $types[1][0] != 'l') {
38✔
1388
                    break;
×
1389
                }
1390
                if ($sz == 1) {
38✔
1391
                    // If we have one list, we duplicate it.
1392
                    $values[1] = $values[0];
38✔
1393
                }
1394
                if (mycount($values[0]) != mycount($values[1])) {
38✔
1395
                    break;
×
1396
                }
1397
                // Still here? That means we have two lists of the same size. Use the latter
1398
                // as the sort order.
1399
                $tmp = $values[0];
38✔
1400
                $order = $values[1];
38✔
1401
                uksort($tmp, function($a, $b) use ($order) {
38✔
1402
                    $first = $order[$a];
38✔
1403
                    $second = $order[$b];
38✔
1404
                    // If both elements are numeric, we compare their numerical value.
1405
                    if (is_numeric($first) && is_numeric($second)) {
38✔
1406
                        return floatval($first) <=> floatval($second);
38✔
1407
                    }
1408
                    // Otherwise, we use natural sorting.
1409
                    return strnatcmp($first, $second);
19✔
1410
                });
38✔
1411
                $this->replace_middle($vstack, $expression, $l, $r, $types[0], array_values($tmp));
38✔
1412
                return true;
38✔
1413
            case 'sublist':
190✔
1414
                if (!($sz == 2 && ($typestr == 'ln,ln' || $typestr == 'ls,ln'))) {
57✔
1415
                    break;
19✔
1416
                }
1417
                $sub = array();
57✔
1418
                foreach ($values[1] as $idx) {
57✔
1419
                    $idx = intval($idx);
57✔
1420
                    if ($idx >= 0 && $idx < mycount($values[0])) {
57✔
1421
                        $sub[] = $values[0][$idx];
57✔
1422
                    } else {
1423
                        throw new Exception(get_string('error_vars_array_index_out_of_range', 'qtype_formulas'));
19✔
1424
                    }
1425
                }
1426
                $this->replace_middle($vstack, $expression, $l, $r, $types[0], $sub);
57✔
1427
                return true;
57✔
1428
            case 'inv':
190✔
1429
                if (!($sz == 1 && $typestr == 'ln')) {
57✔
1430
                    break;
19✔
1431
                }
1432
                $sub = $values[0];
57✔
1433
                foreach ($values[0] as $i => $idx) {
57✔
1434
                    $idx = intval($idx);
57✔
1435
                    if ($idx >= 0 && $idx < mycount($values[0])) {
57✔
1436
                        $sub[$idx] = $i;
57✔
1437
                    } else {
1438
                        throw new Exception(get_string('error_vars_array_index_out_of_range', 'qtype_formulas'));
19✔
1439
                    }
1440
                }
1441
                $this->replace_middle($vstack, $expression, $l, $r, 'ln', $sub);
57✔
1442
                return true;
57✔
1443
            case 'map':
152✔
1444
                if (!($sz >= 2 && $sz <= 3 && $types[0] == 's')) {
38✔
1445
                    break;
19✔
1446
                }
1447
                if ($sz == 2) {   // Two parameters, unary operator.
38✔
1448
                    if (!($typestr == 's,ln')) {
38✔
1449
                        break;
×
1450
                    }
1451
                    if (!array_key_exists($values[0], $this->func_unary)) {
38✔
1452
                        break;
19✔
1453
                    }
1454
                    // Check if the function is one of our own. If it is, prepend the namespace.
1455
                    if (is_callable(__NAMESPACE__ . '\\' . $values[0])) {
38✔
1456
                        $values[0] = __NAMESPACE__ . '\\' . $values[0];
19✔
1457
                    }
1458
                    $value = array_map(
38✔
1459
                        function ($a) use ($values) {
38✔
1460
                            return floatval($values[0]($a));
38✔
1461
                        }, $values[1]
38✔
1462
                    );
38✔
1463
                } else {
1464
                    if (!($typestr == 's,ln,n' || $typestr == 's,n,ln' || $typestr == 's,ln,ln')) {
38✔
1465
                        break;
×
1466
                    }
1467
                    if ($types[1] != 'ln') {
38✔
1468
                        $values[1] = array_fill(0, mycount($values[2]), $values[1]);
×
1469
                    }
1470
                    if ($types[2] != 'ln') {
38✔
1471
                        $values[2] = array_fill(0, mycount($values[1]), $values[2]);
38✔
1472
                    }
1473
                    if (array_key_exists($values[0], $this->binary_op_map)) {
38✔
1474
                        $value = array_map(
38✔
1475
                            function ($a, $b) use ($values) {
38✔
1476
                                return eval('return floatval(($a)'.$values[0].'($b));');
38✔
1477
                            }, $values[1], $values[2]);
38✔
1478
                    } else if (array_key_exists($values[0], $this->func_binary)) {
38✔
1479
                        // Check if the function is one of our own. If it is, prepend the namespace.
1480
                        if (is_callable(__NAMESPACE__ . '\\' . $values[0])) {
38✔
1481
                            $values[0] = __NAMESPACE__ . '\\' . $values[0];
19✔
1482
                        }
1483
                        $value = array_map(
38✔
1484
                            function ($a, $b) use ($values) {
38✔
1485
                                return floatval($values[0]($a, $b));
38✔
1486
                            }, $values[1], $values[2]);
38✔
1487
                    } else {
1488
                        break;
19✔
1489
                    }
1490
                }
1491
                $this->replace_middle($vstack, $expression, $l, $r, 'ln', $value);
38✔
1492
                return true;
38✔
1493
            case 'sum':
152✔
1494
                if (!($sz == 1 && $typestr == 'ln')) {
38✔
1495
                    break;
19✔
1496
                }
1497
                $sum = 0;
38✔
1498
                foreach ($values[0] as $v) {
38✔
1499
                    $sum += floatval($v);
38✔
1500
                }
1501
                $this->replace_middle($vstack, $expression, $l, $r, 'n', $sum);
38✔
1502
                return true;
38✔
1503
            case 'poly':
152✔
1504
                // For backwards compatibility: if called with just a list of numbers, use x as variable.
1505
                if (($sz == 1) && $typestr == 'ln') {
38✔
1506
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly('x', $values[0]));
19✔
1507
                    return true;
19✔
1508
                }
1509
                // If called with just a number, force the plus sign (if the number is positive) to be shown.
1510
                // Basically, there is no other reason one would call this function with just one number.
1511
                if (($sz == 1) && $typestr == 'n') {
38✔
1512
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly('', $values[0], '+'));
19✔
1513
                    return true;
19✔
1514
                }
1515
                // If called with a string and one number, combine them.
1516
                if (($sz == 2) && $typestr == 's,n') {
38✔
1517
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly(array($values[0]), array($values[1])));
38✔
1518
                    return true;
38✔
1519
                }
1520
                // Original functionality: if called with a string and a list of numbers, create a polynomial.
1521
                if (($sz == 2) && $typestr == 's,ln') {
38✔
1522
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly($values[0], $values[1]));
38✔
1523
                    return true;
38✔
1524
                }
1525
                // If called with a list of strings and a list of numbers, build a linear combination.
1526
                if (($sz == 2) && $typestr == 'ls,ln') {
38✔
1527
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly($values[0], $values[1]));
19✔
1528
                    return true;
19✔
1529
                }
1530
                // If called with a string, a number and another string, combine them while using the third argument
1531
                // to e. g. force a "+" on positive numbers.
1532
                if (($sz == 3) && $typestr == 's,n,s') {
38✔
1533
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly(array($values[0]), array($values[1]), $values[2]));
19✔
1534
                    return true;
19✔
1535
                }
1536
                // If called with a string (or list of strings), a list of numbers and another string, combine them
1537
                // while using the third argument as a separator, e. g. for a usage in LaTeX matrices or array-like constructions.
1538
                if (($sz == 3) && ($typestr == 's,ln,s' || $typestr == 'ls,ln,s')) {
38✔
1539
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly($values[0], $values[1], '', $values[2]));
19✔
1540
                    return true;
19✔
1541
                }
1542
                // If called with a list of numbers and a string, use x as default variable for the polynomial and use the
1543
                // third argument as a separator, e. g. for a usage in LaTeX matrices or array-like constructions.
1544
                if (($sz == 2) && $typestr == 'ln,s') {
38✔
1545
                    $this->replace_middle($vstack, $expression, $l, $r, 's', poly('x', $values[0], '', $values[1]));
19✔
1546
                    return true;
19✔
1547
                }
1548
                break;
38✔
1549
            case 'concat':
133✔
1550
                if (!($sz >= 2 && ($types[0][0] == 'l'))) {
38✔
1551
                    break;
38✔
1552
                }
1553
                $result = array();
38✔
1554
                $haserror = false;
38✔
1555
                foreach ($types as $i => $type) {
38✔
1556
                    if ($type != $types[0]) {
38✔
1557
                        $haserror = true;
19✔
1558
                        break;
19✔
1559
                    }
1560
                    foreach ($values[$i] as $v) {
38✔
1561
                        $result[] = $v;
38✔
1562
                    }
1563
                }
1564
                if ($haserror) {
38✔
1565
                    break;
19✔
1566
                }
1567
                $this->replace_middle($vstack, $expression, $l, $r, $types[0], $result);
38✔
1568
                return true;
38✔
1569
            case 'join':
133✔
1570
                if (!($sz >= 2 && $types[0] == 's')) {
38✔
1571
                    break;
19✔
1572
                }
1573
                $data = array();
38✔
1574
                for ($i = 1; $i < $sz; $i++) {
38✔
1575
                    $data[] = $types[$i][0] == 'l' ? implode($values[0], $values[$i]) : $values[$i];
38✔
1576
                }
1577
                $value = join($values[0], $data);
38✔
1578
                $this->replace_middle($vstack, $expression, $l, $r, 's', $value);
38✔
1579
                return true;
38✔
1580
            case 'str':
133✔
1581
                if (!($sz == 1 && $typestr == 'n')) {
38✔
1582
                    break;
19✔
1583
                }
1584
                $this->replace_middle($vstack, $expression, $l, $r, 's', strval($values[0]));
38✔
1585
                return true;
38✔
1586
            case 'diff':
133✔
1587
                if (!($typestr == 'ls,ls,n' || $typestr == 'ls,ls' || $typestr == 'ln,ln')) {
38✔
1588
                    break;
19✔
1589
                }
1590
                if (mycount($values[0]) != mycount($values[1])) {
38✔
1591
                    break;
19✔
1592
                }
1593
                if ($typestr == 'ln,ln') {
38✔
1594
                    $diff = $this->compute_numerical_formula_difference($values[0], $values[1], 1.0, 0);
38✔
1595
                } else {
1596
                    $diff = $this->compute_algebraic_formula_difference(
19✔
1597
                      $vstack,
19✔
1598
                      $values[0],
19✔
1599
                      $values[1],
19✔
1600
                      $typestr == 'ls,ls' ? 100 : $values[2]
19✔
1601
                    );
19✔
1602
                }
1603
                $this->replace_middle($vstack, $expression, $l, $r, 'ln', $diff);
38✔
1604
                return true;
38✔
1605
            default:
1606
                return false;   // If no match, then the expression will be evaluated as a mathematical expression.
95✔
1607
        }
1608
        throw new Exception(get_string('error_func_param', 'qtype_formulas', $data->value));
114✔
1609
    }
1610

1611
    /**
1612
     * Evaluate the $expression with all variables given in the $vstacks. May throw error
1613
     *
1614
     * @param array $vstacks array of vstack data structure. Each vstack will be used one by one
1615
     * @param string $expression The expression being evaluated
1616
     * @param string $functype the function type, either 'F' for internal use, or 'f' for external use
1617
     * @return The evaluated array of number, each number corresponds to one vstack
1618
     */
1619
    private function evaluate_numerical_expression($vstacks, $expression, $functype='F') {
1620
        // @codingStandardsIgnoreLine
1621
        $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $expression));
893✔
1622
        // Check and convert the vstacks into an array of array of numbers.
1623
        $all = array_fill(0, mycount($vstacks), array());
893✔
1624
        for ($i = 1; $i < mycount($splitted); $i += 2) {
893✔
1625
            $data = $vstacks[0]->all[$splitted[$i]];    // For optimization, bypassing function call.
855✔
1626
            if ($data === null || ($data->type != 'n' && $data->type != $functype)) {
855✔
1627
                throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
38✔
1628
            }
1629
            if ($data->type == $functype) {    // If it is a function, put it back into the expression.
855✔
1630
                $splitted[$i] = $data->value;
456✔
1631
            }
1632
            if ($data->type == 'n') {   // If it is a number, store in $a for later evaluation.
855✔
1633
                $all[0][$i] = floatval($data->value);
836✔
1634
                for ($j = 1; $j < mycount($vstacks); $j++) {  // If it need to evaluate the same expression with different values.
836✔
1635
                    $tmp = $vstacks[$j]->all[$splitted[$i]];    // For optimization, bypassing function call.
38✔
1636
                    if ($tmp === null || $tmp->type != 'n') {
38✔
1637
                        throw new Exception(
×
1638
                          'Unexpected error! evaluate_numerical_expression(): Variables in all $vstack must be of the same type'
×
1639
                        );
×
1640
                    }
1641
                    $all[$j][$i] = floatval($tmp->value);
38✔
1642
                }
1643
                $splitted[$i] = '$a['.$i.']';
836✔
1644
            }
1645
        }
1646

1647
        // Check for possible formula error for the substituted string, before directly calling eval().
1648
        $replaced = $splitted;
893✔
1649
        for ($i = 1; $i < mycount($replaced); $i += 2) {
893✔
1650
            // Substitute a dummy value for testing.
1651
            if ($replaced[$i][0] == '$') {
855✔
1652
                $replaced[$i] = 1;
836✔
1653
            }
1654
        }
1655
        $res = $this->find_formula_errors(implode(' ', $replaced));
893✔
1656
        if ($res) {
893✔
1657
            // Forward the error.
1658
            throw new Exception($res);
114✔
1659
        }
1660
        // Now, it should contains pure code of mathematical expression and all numerical variables are stored in $a.
1661
        $results = array();
893✔
1662
        foreach ($all as $a) {
893✔
1663
            $res = null;
893✔
1664
            // In PHP 7 eval() terminates the script if the evaluated code generate a fatal error.
1665
            try {
1666
                eval('namespace ' . __NAMESPACE__ . '; $res = ' . implode(' ', $splitted) . ';');
893✔
1667
            } catch (Throwable $t) {
152✔
1668
                throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
152✔
1669
            }
1670
            if (!isset($res)) {
836✔
1671
                throw new Exception(get_string('error_eval_numerical', 'qtype_formulas'));
×
1672
            }
1673
            $results[] = floatval($res);    // Make sure it is a number, not other data type such as bool.
836✔
1674
        }
1675

1676
        return $results;
836✔
1677
    }
1678

1679
    // Return the list of expression inside the matching open and close bracket, otherwise null.
1680
    // Changed to public so it can be tested from phpunit.
1681
    public function get_expressions_in_bracket($text, $start, $open, $bset=array('(' => ')', '[' => ']', '{' => '}')) {
1682
        $bflip = array_flip($bset);
1,007✔
1683
        $ostack = array();  // Stack of open bracket.
1,007✔
1684
        for ($i = $start; $i < strlen($text); $i++) {
1,007✔
1685
            if ($text[$i] == $open) {
1,007✔
1686
                $ostack[] = $open;
551✔
1687
            }
1688
            if (mycount($ostack) > 0) {
1,007✔
1689
                break;     // When the first open bracket is found.
551✔
1690
            }
1691
        }
1692
        if (mycount($ostack) == 0) {
1,007✔
1693
            return null;
874✔
1694
        }
1695
        $firstopenloc = $i;
551✔
1696
        $expressions = array();
551✔
1697
        $ploc = $i + 1;
551✔
1698
        for ($i = $i + 1; $i < strlen($text); $i++) {
551✔
1699
            if (array_key_exists($text[$i], $bset)) {
551✔
1700
                $ostack[] = $text[$i];
209✔
1701
            }
1702
            if ($text[$i] == ',' && mycount($ostack) == 1) {
551✔
1703
                $expressions[] = substr($text, $ploc, $i - $ploc);
437✔
1704
                $ploc = $i + 1;
437✔
1705
            }
1706
            if (array_key_exists($text[$i], $bflip)) {
551✔
1707
                if (array_pop($ostack) != $bflip[$text[$i]]) {
551✔
1708
                    break;
×
1709
                }
1710
            }
1711
            if (mycount($ostack) == 0) {
551✔
1712
                $expressions[] = substr($text, $ploc, $i - $ploc);
551✔
1713
                return (object)array('openloc' => $firstopenloc, 'closeloc' => $i, 'expressions' => $expressions);
551✔
1714
            }
1715
        }
1716
        throw new Exception(get_string('error_vars_bracket_mismatch', 'qtype_formulas'));
38✔
1717
    }
1718

1719
    // Get the variable immediately before the location $loc.
1720
    private function get_previous_variable(&$vstack, $text, $loc) {
1721
        if (!preg_match('/((@[0-9]+)\s*)$/', substr($text, 0, $loc), $m)) {
304✔
1722
            return null;
304✔
1723
        }
1724
        $var = $this->vstack_get_variable($vstack, $m[2]);
114✔
1725
        if ($var === null) {
114✔
1726
            return null;
×
1727
        }
1728
        return (object)array('startloc' => $loc - strlen($m[1]), 'var' => $var);
114✔
1729
    }
1730

1731
    // Get the variable immediately at and after the location $loc (inclusive).
1732
    private function get_next_variable(&$vstack, $text, $loc) {
1733
        if (!preg_match('/^(\s*(@[0-9]+))/', substr($text, $loc), $m)) {
741✔
1734
            return null;
95✔
1735
        }
1736
        $var = $this->vstack_get_variable($vstack, $m[2]);
741✔
1737
        if ($var === null) {
741✔
1738
            return null;
×
1739
        }
1740
        return (object)array('startloc' => $loc + (strlen($m[1]) - strlen($m[2])), 'endloc' => $loc + strlen($m[1]), 'var' => $var);
741✔
1741
    }
1742

1743
    // Replace the expression[left..right] by the variable with $value.
1744
    private function replace_middle(&$vstack, &$expression, $left, $right, $type, $value) {
1745
        $name = $this->vstack_add_temporary_variable($vstack, $type, $value);
266✔
1746
        $expression = substr($expression, 0, max(0, $left)) . $name . substr($expression, $right);
266✔
1747
    }
1748

1749
    // Remove the user comments, that is the string between # and the end of line.
1750
    private function trim_comments($text) {
1751
        return preg_replace('/'.chr(35).'.*$/m', "\n", $text);
1,083✔
1752
    }
1753

1754
    // Return the information of the formula by substituting numbers, variables and functions.
1755
    public function get_formula_information($vars, $text) {
1756
        // Formula can only contains these characters.
1757
        if (!preg_match('/^[A-Za-z0-9._ )(^\/*+-]*$/', $text)) {
418✔
1758
            return null;
38✔
1759
        }
1760
        $vstack = clone $vars;
418✔
1761
        $sub = $text;
418✔
1762
        $sub = $this->substitute_numbers_by_placeholders($vstack, $sub);
418✔
1763
        $sub = $this->substitute_functions_by_placeholders($vstack, $sub);
418✔
1764
        $sub = $this->substitute_constants_by_placeholders($vstack, $sub, false);
418✔
1765
        $sub = $this->substitute_variables_by_placeholders($vstack, $sub);
418✔
1766
        $vstack->lengths = array_fill_keys(explode(',', 'n,v,F,f,s,ln,ls,zn'), 0);
418✔
1767
        foreach ($vstack->all as $data) {
418✔
1768
            $vstack->lengths[$data->type]++;
418✔
1769
        }
1770
        $vstack->original = $text;
418✔
1771
        $vstack->sub = $sub;
418✔
1772
        $vstack->remaining = preg_replace('/@[0-9]+/', '', $sub);
418✔
1773
        return $vstack;
418✔
1774
    }
1775

1776
    // Split the input into number/numeric/numerical formula and unit.
1777
    public function split_formula_unit($text) {
1778
        // Note: these symbols is reserved to split str.
1779
        // @codingStandardsIgnoreLine
1780
        if (preg_match('/[`@]/', $text)) {
760✔
1781
            return array('', $text);
19✔
1782
        }
1783
        $vstack = $this->vstack_create();
760✔
1784
        $sub = $text;
760✔
1785
        $sub = $this->substitute_numbers_by_placeholders($vstack, $sub);
760✔
1786
        $sub = $this->substitute_functions_by_placeholders($vstack, $sub);
760✔
1787
        $sub = $this->substitute_constants_by_placeholders($vstack, $sub, true);
760✔
1788
        // Split at the point that does not contain characters @ 0-9 + - * / ^ ( ) space.
1789
        // @codingStandardsIgnoreLine
1790
        $spl = explode('`', preg_replace('/([^@0-9 )(^\/*+-])(.*)$/', '`$1$2', $sub));
760✔
1791
        $num = $this->substitute_placeholders_in_text($vstack, $spl[0]);
760✔
1792
        $unit = (!isset($spl[1])) ? '' : $this->substitute_placeholders_in_text($vstack, $spl[1]);
760✔
1793
        return array($num, $unit);  // Don't trim them, otherwise the recombination may differ by a space.
760✔
1794
    }
1795

1796
    // Translate the input formula $text into the corresponding evaluable mathematical formula in php.
1797
    public function replace_evaluation_formula(&$vstack, $text) {
1798
        $text = $this->insert_multiplication_for_juxtaposition($vstack, $text);
57✔
1799
        $text = $this->replace_caret_by_power($vstack, $text);
57✔
1800
        $text = preg_replace('/\s*([)(\/*+-])\s*/', '$1', $text);
57✔
1801
        return $text;
57✔
1802
    }
1803

1804
    // Replace the user input function in the vstack by another function.
1805
    public function replace_vstack_variables($vstack, $replacementlist) {
1806
        $res = clone $vstack;   // The $vstack->all will be used so it needs to clone deeply.
228✔
1807
        foreach ($res->all as $name => $v) {
228✔
1808
            if (is_string($v->value)) {
228✔
1809
                $res->all[$name] = (object)array(
228✔
1810
                    'type' => $v->type,
228✔
1811
                    'value' => array_key_exists($v->value, $replacementlist) ? $replacementlist[$v->value] : $v->value
228✔
1812
                );
228✔
1813
            }
1814
        }
1815
        return $res;
228✔
1816
    }
1817

1818
    // Insert the multiplication symbol whenever juxtaposition occurs.
1819
    public function insert_multiplication_for_juxtaposition($vstack, $text) {
1820
        // @codingStandardsIgnoreLine
1821
        $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $text));
57✔
1822
        // The length will always be odd: placeholder in odd index, operators in even index.
1823
        for ($i = 3; $i < mycount($splitted); $i += 2) {
57✔
1824
            // The operator(s) between this and the previous variable.
1825
            $op = trim($splitted[$i - 1]);
57✔
1826
            if ($this->vstack_get_variable($vstack, $splitted[$i - 2])->type == 'f') {
57✔
1827
                // No need to add '*' if the left is function.
1828
                continue;
57✔
1829
            }
1830
            if (strlen($op) == 0) {
57✔
1831
                // Add multiplication if no operator.
1832
                $op = ' * ';
38✔
1833
            } else if ($op[0] == '(') {
57✔
1834
                $op = ' * '.$op;
38✔
1835
            } else if ($op[strlen($op) - 1] == ')') {
57✔
1836
                $op = $op.' * ';
38✔
1837
            } else {
1838
                $op = preg_replace('/^(\))(\s*)(\()/', '$1 * $3', $op);
57✔
1839
            }
1840
            $splitted[$i - 1] = $op;
57✔
1841
        }
1842
        return implode('', $splitted);
57✔
1843
    }
1844

1845
    // Replace the expression x^y by pow(x, y).
1846
    public function replace_caret_by_power($vstack, $text) {
1847
        while (true) {
57✔
1848
            $loc = strrpos($text, '^');    // From right to left.
57✔
1849
            if ($loc === false) {
57✔
1850
                break;
57✔
1851
            }
1852

1853
            // Search for the expression of the exponent.
1854
            $rloc = $loc;
57✔
1855
            if ($rloc + 1 < strlen($text) && $text[$rloc + 1] == '-') {
57✔
1856
                $rloc += 1;
38✔
1857
            }
1858
            $r = $this->get_next_variable($vstack, $text, $rloc + 1);
57✔
1859
            if ($r != null) {
57✔
1860
                $rloc = $r->endloc - 1;
57✔
1861
            }
1862
            if ($r == null || ($r != null && $r->var->type == 'f')) {
57✔
1863
                $rtmp = $this->get_expressions_in_bracket($text, $rloc + 1, '(', array('(' => ')'));
38✔
1864
                if ($rtmp == null || $rtmp->openloc != $rloc + 1) {
38✔
1865
                    throw new Exception('Expression expected');
19✔
1866
                }
1867
                $rloc = $rtmp->closeloc;
38✔
1868
            }
1869

1870
            // Search for the expression of the base.
1871
            $lloc = $loc;
57✔
1872
            $l = $this->get_previous_variable($vstack, $text, $loc);
57✔
1873
            if ($l != null) {
57✔
1874
                $lloc = $l->startloc;
57✔
1875
            } else {
1876
                $reverse = strrev($text);
38✔
1877
                $ltmp = $this->get_expressions_in_bracket($reverse, strlen($text) - 1 - $loc + 1, ')', array(')' => '('));
38✔
1878
                if ($ltmp == null || $ltmp->openloc != strlen($text) - 1 - $loc + 1) {
38✔
1879
                    throw new Exception('Expression expected');
19✔
1880
                }
1881
                $lfunc = $this->get_previous_variable($vstack, $text, strlen($text) - 1 - $ltmp->closeloc);
38✔
1882
                $lloc = ($lfunc == null || $lfunc->var->type != 'f') ? strlen($text) - 1 - $ltmp->closeloc : $lfunc->startloc;
38✔
1883
            }
1884

1885
            // Replace the exponent notation by the pow function.
1886
            $name = $this->vstack_add_temporary_variable($vstack, 'f', 'pow');
57✔
1887
            $text = substr($text, 0, $lloc) . $name . '(' . substr($text, $lloc, $loc - $lloc) . ', '
57✔
1888
                . substr($text, $loc + 1, $rloc - $loc) . ')' . substr($text, $rloc + 1);
57✔
1889
        }
1890
        return $text;
57✔
1891
    }
1892

1893
    // Return the float value of number, numeric, or numerical formula, null when format incorrect.
1894
    public function compute_numerical_formula_value($str, $gradingtype) {
1895
        $info = $this->get_formula_information($this->vstack_create(), $str);
323✔
1896
        // If the students' formula contains any disallowed characters.
1897
        if ($info === null) {
323✔
1898
            return null;
19✔
1899
        }
1900
        try {
1901
            if ($gradingtype == 100) {        // For numerical formula format.
323✔
1902
                if (preg_match('/^[ )(^\/*+-]*$/', $info->remaining) == false) {
19✔
1903
                    return null;
×
1904
                }
1905
                if (!($info->lengths['v'] == 0)) {
19✔
1906
                    return null;
19✔
1907
                }
1908
                $info = $this->replace_vstack_variables($info, $this->evalreplacelist);
19✔
1909
                $tmp = $this->replace_evaluation_formula($info, $info->sub);
19✔
1910
                $nums = $this->evaluate_numerical_expression(array($info), $tmp, 'f');
19✔
1911
                return $nums[0];
19✔
1912
            } else if ($gradingtype == 10) {  // For numeric format.
323✔
1913
                if (preg_match('/^[ )(^\/*+-]*$/', $info->remaining) == false) {
19✔
1914
                    return null;
×
1915
                }
1916
                if (!($info->lengths['v'] == 0 && $info->lengths['f'] == 0)) {
19✔
1917
                    return null;
19✔
1918
                }
1919
                $info = $this->replace_vstack_variables($info, $this->evalreplacelist);
19✔
1920
                $tmp = $this->replace_evaluation_formula($info, $info->sub);
19✔
1921
                $nums = $this->evaluate_numerical_expression(array($info), $tmp, 'f');
19✔
1922
                return $nums[0];
19✔
1923
            } else {  // When $gradingtype != {10, 100, 1000}, for unknown type, all are treated as number.
1924
                if (preg_match('/^[-+]?@0$/', $info->sub) == false) {
304✔
1925
                    return null;
76✔
1926
                }
1927
                if (!($info->lengths['v'] == 0 && $info->lengths['f'] == 0 && $info->lengths['n'] == 1)) {
304✔
1928
                    return null;
×
1929
                }
1930
                return floatval($str);
304✔
1931
            }
1932
        } catch (Exception $e) {
19✔
1933
            return null; // Any error means that the $str cannot be evaluated to a number.
19✔
1934
        }
1935
    }
1936

1937
    // Find the numerical value of students response $B and compute the difference between the modelanswer and students response.
1938
    public function compute_numerical_formula_difference(&$A, &$B, $cfactor, $gradingtype) {
1939
        $diffs = array();
285✔
1940
        for ($i = 0; $i < mycount($B); $i++) {
285✔
1941
            $value = $this->compute_numerical_formula_value($B[$i], $gradingtype);
285✔
1942
            // If the coordinate cannot convert to a number.
1943
            if ($value === null) {
285✔
1944
                return null;
57✔
1945
            }
1946
            $B[$i] = $value * $cfactor;         // Rescale students' response to match unit of model answer.
285✔
1947
            $diffs[$i] = abs($A[$i] - $B[$i]);  // Calculate the difference between students' response and model answer.
285✔
1948
            if (is_nan($A[$i])) {
285✔
1949
                $A[$i] = INF;
×
1950
            }
1951
            if (is_nan($B[$i])) {
285✔
1952
                $B[$i] = INF;
×
1953
            }
1954
            if (is_nan($diffs[$i])) {
285✔
1955
                $diffs[$i] = INF;
×
1956
            }
1957
        }
1958
        return $diffs;
285✔
1959
    }
1960

1961
    // Compute the average L1-norm between $A and $B, evaluated at $N random points given by the random variables in $vars.
1962
    public function compute_algebraic_formula_difference(&$vars, $A, $B, $N=100) {
1963
        if ($N < 1) {
38✔
1964
            $N = 100;
×
1965
        }
1966
        $diffs = array();
38✔
1967
        for ($idx = 0; $idx < mycount($A); $idx++) {
38✔
1968
            if (!is_string($A[$idx]) || !is_string($B[$idx])) {
38✔
1969
                return null;
×
1970
            }
1971
            $A[$idx] = trim($A[$idx]);
38✔
1972
            $B[$idx] = trim($B[$idx]);
38✔
1973
            if (strlen($A[$idx]) == 0 || strlen($B[$idx]) == 0) {
38✔
1974
                return null;
×
1975
            }
1976
            $AsubB = 'abs('.$A[$idx].'-('.$B[$idx].'))';
38✔
1977
            $info = $this->get_formula_information($vars, $AsubB);
38✔
1978
            if ($info === null) {
38✔
1979
                return null;
19✔
1980
            }
1981
            if (preg_match('/^[ )(^\/*+-]*$/', $info->remaining) == false) {
38✔
1982
                return null;
×
1983
            }
1984
            $info = $this->replace_vstack_variables($info, $this->evalreplacelist);
38✔
1985
            $d = $this->replace_evaluation_formula($info, $info->sub);
38✔
1986
            $d = $this->substitute_vname_by_variables($info, $d);
38✔
1987

1988
            // Create a vstack contains purely the variables that appears in the formula.
1989
            // @codingStandardsIgnoreLine
1990
            $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $d));
38✔
1991
            $vstack = $this->vstack_create();
38✔
1992
            for ($i = 1; $i < mycount($splitted); $i += 2) {
38✔
1993
                $data = $this->vstack_get_variable($info, $splitted[$i]);
38✔
1994
                if ($data === null || ($data->type != 'f' && $data->type != 'n' && $data->type != 'zn')) {
38✔
1995
                    return null;
×
1996
                }
1997
                // If it is a function, put it back into the expression.
1998
                if ($data->type == 'f') {
38✔
1999
                    $splitted[$i] = $data->value;
38✔
2000
                }
2001
                if ($data->type == 'n' || $data->type == 'zn') {
38✔
2002
                    // Don't add other temp variable!
2003
                    $this->vstack_update_variable($vstack, $splitted[$i], null, $data->type, $data->value);
38✔
2004
                }
2005
            }
2006
            $newexpr = trim(implode('', $splitted));
38✔
2007

2008
            // Create the vstack for different realization of algebraic variable.
2009
            $vstacks = array();
38✔
2010
            for ($z = 0; $z < $N; $z++) {
38✔
2011
                $vstacks[$z] = clone $vstack;
38✔
2012
                $instantiation = $this->instantiate_random_variables($vstack);
38✔
2013
                foreach ($instantiation->all as $name => $inst) {
38✔
2014
                    $this->vstack_update_variable($vstacks[$z], $name, null, 'n', $inst->value);
38✔
2015
                }
2016
            }
2017

2018
            // Evaluate and find the root mean square of the difference over all instantiation.
2019
            if (strlen($newexpr) == 0) {
38✔
2020
                return null;
×
2021
            }
2022
            $nums = $this->evaluate_numerical_expression($vstacks, $newexpr, 'f');
38✔
2023
            for ($i = 0; $i < mycount($nums); $i++) {
38✔
2024
                $nums[$i] = $nums[$i] * $nums[$i];
38✔
2025
            }
2026
            $res = sqrt(array_sum($nums) / $N);    // It must be a positive integer, Nan or inf.
38✔
2027
            if (is_nan($res)) {
38✔
2028
                $res = INF;
19✔
2029
            }
2030
            $diffs[] = $res;
38✔
2031
        }
2032
        return $diffs;
38✔
2033
    }
2034

2035
    // Substitute the variable with numeric value in the list of algebraic formulas,
2036
    // it is used to show correct answer with random numeric value.
2037
    public function substitute_partial_formula(&$vars, $formulas) {
2038
        $res = array();
171✔
2039
        for ($idx = 0; $idx < mycount($formulas); $idx++) {
171✔
2040
            // Internal error for calling this function.
2041
            if (!is_string($formulas[$idx])) {
171✔
2042
                return null;
×
2043
            }
2044
            $formulas[$idx] = trim($formulas[$idx]);
171✔
2045
            $vstack = $this->get_formula_information($vars, $formulas[$idx]);
171✔
2046
            if ($vstack === null || preg_match('/^[ )(^\/*+-]*$/', $vstack->remaining) == false) {
171✔
2047
                throw new Exception(get_string('error_forbid_char', 'qtype_formulas'));
×
2048
            }
2049
            $vstack = $this->replace_vstack_variables($vstack, $this->evalreplacelist);
171✔
2050

2051
            // Replace the variable with numeric value by the number.
2052
            // @codingStandardsIgnoreLine
2053
            $splitted = explode('`', preg_replace('/(@[0-9]+)/', '`$1`', $vstack->sub));
171✔
2054
            for ($i = 1; $i < mycount($splitted); $i += 2) {
171✔
2055
                $data = $this->vstack_get_variable($vstack, $splitted[$i]);
171✔
2056
                if ($data->type == 'v') {
171✔
2057
                    $tmp = $this->vstack_get_variable($vstack, $data->value);
×
2058
                    if ($tmp === null) {
×
2059
                        throw new Exception(
×
2060
                          get_string('error_vars_undefined', 'qtype_formulas', $data->value) . ' in substitute_partial_formula'
×
2061
                        );
×
2062
                    }
2063
                    if ($tmp->type == 'n') {
×
2064
                        $data = $tmp;
×
2065
                    }
2066
                }
2067
                $splitted[$i] = $data->value;
171✔
2068
            }
2069
            $res[] = implode('', $splitted);
171✔
2070
        }
2071
        return $res;
171✔
2072
    }
2073

2074
    /**
2075
     * Check the validity of formula. From calculated question type. Modified.
2076
     *
2077
     * @param string $formula The input formula
2078
     * @return false for possible valid formula, otherwise error message
2079
     */
2080
    public function find_formula_errors($formula) {
2081
        // Validates the formula submitted from the question edit page.
2082
        // Returns false if everything is alright.
2083
        // Otherwise it constructs an error message
2084
        // Strip away empty space and lowercase it.
2085
        $formula = str_replace(' ', '', $formula);
893✔
2086

2087
        $safeoperatorchar = '-+/*%>:^\~<?=&|!'; /* */
893✔
2088
        $operatorornumber = "[$safeoperatorchar.0-9eE]";
893✔
2089

2090
        while (
2091
          preg_match(
893✔
2092
            "~(^|[$safeoperatorchar,(])([a-z0-9_]*)\\(($operatorornumber+(,$operatorornumber+(,$operatorornumber+((,$operatorornumber+)+)?)?)?)?\\)~",
893✔
2093
            $formula,
893✔
2094
            $regs
893✔
2095
          )
893✔
2096
        ) {
2097
            for ($i = 0; $i < 7; $i++) {
513✔
2098
                if (!isset($regs[$i])) {
513✔
2099
                    $regs[] = '';
513✔
2100
                }
2101
            }
2102
            switch ($regs[2]) {
513✔
2103
                // Simple parenthesis.
2104
                case '':
513✔
2105
                    if (strlen($regs[4]) != 0 || strlen($regs[3]) == 0) {
76✔
2106
                        return get_string('illegalformulasyntax', 'qtype_formulas', $regs[0]);
19✔
2107
                    }
2108
                    break;
76✔
2109

2110
                // Zero argument functions.
2111
                case 'fqversionnumber':
494✔
2112
                case 'pi':
475✔
2113
                    if (strlen($regs[3]) != 0) {
38✔
2114
                        return get_string('functiontakesnoargs', 'qtype_formulas', $regs[2]);
19✔
2115
                    }
2116
                    break;
38✔
2117

2118
                // Single argument functions (the most common case).
2119
                case 'abs':
475✔
2120
                case 'acos':
475✔
2121
                case 'acosh':
475✔
2122
                case 'asin':
475✔
2123
                case 'asinh':
475✔
2124
                case 'atan':
475✔
2125
                case 'atanh':
475✔
2126
                case 'bindec':
475✔
2127
                case 'ceil':
475✔
2128
                case 'cos':
475✔
2129
                case 'cosh':
475✔
2130
                case 'decbin':
475✔
2131
                case 'decoct':
475✔
2132
                case 'deg2rad':
475✔
2133
                case 'exp':
475✔
2134
                case 'expm1':
475✔
2135
                case 'floor':
475✔
2136
                case 'is_finite':
475✔
2137
                case 'is_infinite':
475✔
2138
                case 'is_nan':
475✔
2139
                case 'log10':
475✔
2140
                case 'log1p':
475✔
2141
                case 'octdec':
475✔
2142
                case 'rad2deg':
456✔
2143
                case 'sin':
456✔
2144
                case 'sinh':
418✔
2145
                case 'sqrt':
418✔
2146
                case 'tan':
380✔
2147
                case 'tanh':
380✔
2148
                case 'fact':
380✔
2149
                case 'stdnormpdf':
361✔
2150
                case 'stdnormcdf':
342✔
2151
                    if (strlen($regs[4]) != 0 || strlen($regs[3]) == 0) {
247✔
2152
                        return get_string('functiontakesonearg', 'qtype_formulas', $regs[2]);
57✔
2153
                    }
2154
                    break;
247✔
2155

2156
                // Functions that take one or two arguments.
2157
                case 'log':
323✔
2158
                case 'round':
323✔
2159
                    if (strlen($regs[5]) != 0 || strlen($regs[3]) == 0) {
57✔
2160
                        return get_string('functiontakesoneortwoargs', 'qtype_formulas', $regs[2]);
19✔
2161
                    }
2162
                    break;
57✔
2163

2164
                // Functions that must have two arguments.
2165
                case 'atan2':
323✔
2166
                case 'fmod':
304✔
2167
                case 'pow':
285✔
2168
                case 'ncr':
247✔
2169
                case 'npr':
228✔
2170
                case 'lcm':
190✔
2171
                case 'gcd':
171✔
2172
                case 'sigfig':
152✔
2173
                case 'modinv':
133✔
2174
                    if (strlen($regs[5]) != 0 || strlen($regs[4]) == 0) {
247✔
2175
                        return get_string('functiontakestwoargs', 'qtype_formulas', $regs[2]);
57✔
2176
                    }
2177
                    break;
247✔
2178

2179
                // Functions that take two or more arguments.
2180
                case 'min':
114✔
2181
                case 'max':
114✔
2182
                    if (strlen($regs[4]) == 0) {
19✔
2183
                        return get_string('functiontakesatleasttwo', 'qtype_formulas', $regs[2]);
19✔
2184
                    }
2185
                    break;
19✔
2186

2187
                // Functions that take three arguments.
2188
                case 'normcdf':
114✔
2189
                case 'binomialpdf':
95✔
2190
                case 'binomialcdf':
76✔
2191
                case 'modpow':
57✔
2192
                    if (strlen($regs[6]) != 0 || strlen($regs[5]) == 0) {
95✔
2193
                        return get_string('functiontakesthreeargs', 'qtype_formulas', $regs[2]);
19✔
2194
                    }
2195
                    break;
95✔
2196

2197
                default:
2198
                    return get_string('unsupportedformulafunction', 'qtype_formulas', $regs[2]);
19✔
2199
            }
2200

2201
            // Exchange the function call with '1' and then check for
2202
            // another function call...
2203
            if ($regs[1]) {
513✔
2204
                // The function call is proceeded by an operator.
2205
                $formula = str_replace($regs[0], $regs[1] . '1', $formula);
76✔
2206
            } else {
2207
                // The function call starts the formula.
2208
                $formula = preg_replace("~^$regs[2]\\([^)]*\\)~", '1', $formula);
513✔
2209
            }
2210
        }
2211

2212
        if (preg_match("~[^$safeoperatorchar.0-9eE]+~", $formula, $regs)) {
893✔
2213
            return get_string('illegalformulasyntax', 'qtype_formulas', $regs[0]);
19✔
2214
        } else {
2215
            // Formula just might be valid.
2216
            return false;
893✔
2217
        }
2218

2219
    }
2220
}
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

© 2025 Coveralls, Inc