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

FormulasQuestion / moodle-qtype_formulas / 13217446514

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

Pull #62

github

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

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

146 existing lines in 6 files now uncovered.

3006 of 3909 relevant lines covered (76.9%)

438.31 hits per line

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

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

17
namespace qtype_formulas\local;
18
use Exception;
19

20
// TODO: add function randint.
21
// TODO: add some string functions, e.g. upper/lower case, repeat char.
22

23
/**
24
 * Additional functions qtype_formulas
25
 *
26
 * @package    qtype_formulas
27
 * @copyright  2022 Philipp Imhof
28
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29
 */
30
class functions {
31
    /** @var int */
32
    const NONE = 0;
33

34
    /** @var int */
35
    const INTEGER = 1;
36

37
    /** @var int */
38
    const NON_NEGATIVE = 2;
39

40
    /** @var int */
41
    const NON_ZERO = 4;
42

43
    /** @var int */
44
    const NEGATIVE = 8;
45

46
    /** @var int */
47
    const POSITIVE = 16;
48

49
    /**
50
     * List of all functions exported by this class.
51
     *
52
     * The function name (as is) is used as the array key. The array value
53
     * is another array of two numbers, i. e. the minimum and the maximum number
54
     * of parameters arguments supported by this function. If there is no
55
     * maximum, INF is used.
56
     *
57
     * Examples:
58
     * - function foo() with no arguments: 'foo' => [0, 0]
59
     * - function bar() with at least 1 argument: 'bar' => [1, INF]
60
     * - function baz() with 2 or 3 arguments: 'baz' => [2, 3]
61
     *
62
     * @var array
63
     */
64
    const FUNCTIONS = [
65
        'binomialcdf' => [3, 3],
66
        'binomialpdf' => [3, 3],
67
        'concat' => [2, INF],
68
        // Note: The special function diff() is defined in the evaluator class.
69
        'diff' => [2, 3],
70
        'fact' => [1, 1],
71
        'fill' => [2, 2],
72
        'fmod' => [2, 2],
73
        'fqversionnumber' => [0, 0],
74
        'gcd' => [2, 2],
75
        'inv' => [1, 1],
76
        'join' => [2, INF],
77
        'lcm' => [2, 2],
78
        'len' => [1, 1],
79
        'ln' => [1, 1],
80
        'map' => [2, 3],
81
        'modinv' => [2, 2],
82
        'modpow' => [3, 3],
83
        'ncr' => [2, 2],
84
        'normcdf' => [3, 3],
85
        'npr' => [2, 2],
86
        'pick' => [2, INF],
87
        'poly' => [1, 3],
88
        'rshuffle' => [1, 1],
89
        'shuffle' => [1, 1],
90
        'sigfig' => [2, 2],
91
        'sort' => [1, 2],
92
        'stdnormcdf' => [1, 1],
93
        'stdnormpdf' => [1, 1],
94
        'str' => [1, 1],
95
        'sublist' => [2, 2],
96
        'sum' => [1, 1],
97
    ];
98

99
    /**
100
     * Return the plugin's version number. This is intended for users without
101
     * administration access who want to check whether their installation offers
102
     * a certain feature or is affected by a certain bug.
103
     *
104
     * @return string
105
     */
106
    public static function fqversionnumber(): string {
107
        return get_config('qtype_formulas')->version;
17✔
108
    }
109

110
    /**
111
     * Apply an unary operator or function to one array or a binary operator or function
112
     * to two arrays and return the result. When working with binary operators or functions,
113
     * one of the two arrays may be a constant and will be inflated to a list of the
114
     * correct size.
115
     *
116
     * Examples:
117
     * - map("+", [1, 2, 3], 1) -> [2, 3, 4]
118
     * - map("+", [1, 2, 3], [4, 5, 6]) -> [5, 7, 9]
119
     * - map("sqrt", [1, 4, 9]) -> [1, 2, 3]
120
     *
121
     * @param string $what operator or function to be applied
122
     * @param mixed $first list or constant (number, string)
123
     * @param mixed $second list of the same size or constant
124
     * @return array
125
     */
126
    public static function map(string $what, $first, $second = null): array {
127
        // List of allowed binary operators, i. e. all but the assignment.
128
        $binaryops = ['**', '*', '/', '%', '+', '-', '<<', '>>', '&', '^',
391✔
129
            '|', '&&', '||', '<', '>', '==', '>=', '<=', '!='];
391✔
130

131
        // List of allowed unary operators.
132
        $unaryops = ['_', '!', '~'];
391✔
133

134
        // List of all functions.
135
        $allfunctions = self::FUNCTIONS + evaluator::PHPFUNCTIONS;
391✔
136

137
        // If the operator is '-', we first check the parameters to find out whether
138
        // it is subtraction (encoded as '-') or negation (encoded as '_').
139
        if ($what === '-' && $second === null) {
391✔
140
            $what = '_';
34✔
141
        }
142

143
        // In order to perform the necessary pre-checks, we have to determine what operation
144
        // type is requested: binary operation, unary operation, function with one argument
145
        // or function with two arguments.
146
        $usebinaryop = in_array($what, $binaryops);
391✔
147
        $useunaryop = in_array($what, $unaryops);
391✔
148
        $useunaryfunc = false;
391✔
149
        $usebinaryfunc = false;
391✔
150

151
        // If $what is neither a valid operator nor a function, throw an error.
152
        if (!$usebinaryop && !$useunaryop) {
391✔
153
            if (!array_key_exists($what, $allfunctions) || $what === 'diff') {
170✔
154
                self::die('error_diff_first_invalid', $what);
17✔
155
            }
156
            // Fetch the number of arguments for the given function name.
157
            $min = $allfunctions[$what][0];
153✔
158
            $max = $allfunctions[$what][1];
153✔
159
            if ($max < 1) {
153✔
160
                self::die('error_diff_function_no_args', $what);
17✔
161
            }
162
            if ($min > 2) {
136✔
163
                self::die('error_diff_function_more_args', $what);
17✔
164
            }
165
            // Some functions are clearly unary.
166
            if ($min <= 1 && $max === 1) {
119✔
167
                $useunaryfunc = true;
68✔
168
                $usebinaryfunc = false;
68✔
169
            }
170
            // Other functions are clearly binary.
171
            if ($min === 2 && $max >= 2) {
119✔
172
                $useunaryfunc = false;
17✔
173
                $usebinaryfunc = true;
17✔
174
            }
175
            // If the function can be unary or binary, we have to check the arguments.
176
            if ($min <= 1 && $max >= 2) {
119✔
177
                $useunaryfunc = ($second === null);
34✔
178
                $usebinaryfunc = !$useunaryfunc;
34✔
179
            }
180
        }
181

182
        // Check arguments for unary operators or functions: we need exactly one list.
183
        if ($useunaryop || $useunaryfunc) {
340✔
184
            $type = $useunaryop ? 'operator' : 'function';
119✔
185
            if ($second !== null) {
119✔
186
                self::die("error_diff_unary_$type", $what);
17✔
187
            }
188
            if (!is_array($first)) {
102✔
189
                // The unary minus is internally represented as '_', but it should be shown as '-' in
190
                // an error message.
191
                if ($what === '_') {
17✔
192
                    $what = '-';
17✔
193
                }
194
                self::die('error_diff_unary_needslist', $what);
17✔
195
            }
196
        }
197

198
        // Check arguments for binary operators or functions: we expect (a) one scalar and one list or
199
        // (b) two lists of the same size.
200
        if ($usebinaryop || $usebinaryfunc) {
306✔
201
            $type = $usebinaryop ? 'operator' : 'function';
221✔
202
            if ($second === null) {
221✔
203
                self::die("error_diff_binary_{$type}_two", $what);
17✔
204
            }
205
            if (is_scalar($first) && is_scalar($second)) {
204✔
206
                self::die("error_diff_binary_{$type}_needslist", $what);
17✔
207
            }
208
            if (is_array($first) && is_array($second) && count($first) != count($second)) {
187✔
209
                self::die('error_diff_binary_samesize');
17✔
210
            }
211
            // We do now know that we are using a binary operator or function and that we have at least one list.
212
            // If the other argument is a scalar, we blow it up to an array of the same size as the other list.
213
            if (is_scalar($first)) {
170✔
214
                $first = array_fill(0, count($second), token::wrap($first));
34✔
215
            }
216
            if (is_scalar($second)) {
170✔
217
                $second = array_fill(0, count($first), token::wrap($second));
68✔
218
            }
219
        }
220

221
        // Now we are all set to apply the operator or execute the function. We are going to use
222
        // our own for loop instead of PHP's array_walk() or array_map(). For better error reporting,
223
        // we use a try-catch construct.
224
        $result = [];
255✔
225
        try {
226
            $count = count($first);
255✔
227
            for ($i = 0; $i < $count; $i++) {
255✔
228
                if ($useunaryop) {
255✔
229
                    $tmp = self::apply_unary_operator($what, $first[$i]->value);
17✔
230
                } else if ($usebinaryop) {
238✔
231
                    $tmp = self::apply_binary_operator($what, $first[$i]->value, $second[$i]->value);
136✔
232
                } else if ($useunaryfunc || $usebinaryfunc) {
102✔
233
                    // For function calls, we distinguish between our own functions and PHP's built-in functions.
234
                    $prefix = '';
102✔
235
                    if (array_key_exists($what, self::FUNCTIONS)) {
102✔
236
                        $prefix = self::class . '::';
51✔
237
                    }
238
                    // The params must be wrapped in an array. There is at least one parameter ...
239
                    $params = [$first[$i]->value];
102✔
240
                    // ... and there's a second one for binary functions.
241
                    if ($usebinaryfunc) {
102✔
242
                        $params[] = $second[$i]->value;
34✔
243
                    }
244
                    $tmp = call_user_func_array($prefix . $what, $params);
102✔
245
                }
246
                $result[] = token::wrap($tmp);
221✔
247
            }
248
        } catch (Exception $e) {
34✔
249
            self::die('error_map_unknown', $e->getMessage());
34✔
250
        }
251

252
        return $result;
221✔
253
    }
254

255
    /**
256
     * Given a permutation, find its inverse.
257
     *
258
     * Example:
259
     * - The permutation [2, 0, 1] would transform ABC to CAB.
260
     * - Its inverse is [1, 2, 0] which transforms CAB to ABC again.
261
     *
262
     * @param array $list list of consecutive integers, starting at 0
263
     * @return array inverse permutation
264
     */
265
    public static function inv($list): array {
266
        // First, we check that the argument is actually a list.
267
        if (!is_array($list)) {
102✔
268
            self::die('error_inv_list');
17✔
269
        }
270
        // Now, we check that the array contains only numbers. If necessary,
271
        // floats will be converted to integers by truncation. Note: number tokens
272
        // always store their value as float, so we have to apply the conversion to
273
        // all numbers, because we cannot know whether they really are of type float or int.
274
        foreach ($list as $entry) {
85✔
275
            $value = $entry->value;
85✔
276
            // Not setting INTEGER as condition, because we actually do accept floats and truncate them.
277
            self::assure_numeric($value, get_string('error_inv_integers', 'qtype_formulas'));
85✔
278
            $entry->value = intval($value);
85✔
279
        }
280

281
        // Now we check that the same number does not appear twice.
282
        $tmp = array_unique($list);
85✔
283
        if (count($tmp) !== count($list)) {
85✔
284
            self::die('error_inv_nodup');
17✔
285
        }
286
        // Finally, we make sure the numbers are consecutive from 0 to n-1 or from 1 to n with
287
        // n being the number of elements in the list. We can use min() and max(), because the
288
        // token has a __tostring() method and numeric strings are compared numerically.
289
        $min = min($list);
68✔
290
        $max = max($list);
68✔
291
        if ($min->value > 1 || $min->value < 0) {
68✔
292
            self::die('error_inv_smallest');
17✔
293
        }
294
        if ($max->value - $min->value + 1 !== count($list)) {
51✔
295
            self::die('error_inv_consec');
17✔
296
        }
297

298
        // Create array from minimum to maximum value and then use the given list as the sort order.
299
        // Note: number tokens should have their value stored as floats.
300
        $result = [];
34✔
301
        for ($i = $min->value; $i <= $max->value; $i++) {
34✔
302
            $result[] = new token(token::NUMBER, floatval($i));
34✔
303
        }
304
        uksort($result, function($a, $b) use ($list) {
34✔
305
            return $list[$a] <=> $list[$b];
34✔
306
        });
34✔
307

308
        // Forget about the keys and re-index the sorted array from 0.
309
        return array_values($result);
34✔
310
    }
311

312
    /**
313
     * Concatenate multiple lists into one.
314
     *
315
     * @param array ...$arrays two or more lists
316
     * @return array concetanation of all given lists
317
     */
318
    public static function concat(...$arrays): array {
319
        $result = [];
85✔
320

321
        // Iterate over each array ...
322
        foreach ($arrays as $array) {
85✔
323
            if (!is_array($array)) {
85✔
324
                self::die('error_func_all_lists', 'concat()');
17✔
325
            }
326
            // ... and over each element of every array.
327
            foreach ($array as $element) {
68✔
328
                $result[] = $element;
68✔
329
            }
330
        }
331

332
        return $result;
68✔
333
    }
334

335
    /**
336
     * Sort a given list using natural sort order. Optionally, a second list may be given
337
     * to indicate the sort order.
338
     *
339
     * Examples:
340
     * - sort([1,10,5,3]) --> [1, 3, 5, 10]
341
     * - sort([-3,-2,4,2,3,1,0,-1,-4,5]) --> [-4, -3, -2, -1, 0, 1, 2, 3, 4, 5]
342
     * - sort(["A1","A10","A2","A100"]) --> ['A1', 'A2', 'A10', 'A100']
343
     * - sort(["B","A2","A1"]) --> ['A1', 'A2', 'B']
344
     * - sort(["B","C","A"],[0,2,1]) --> ['B', 'A', 'C']
345
     * - sort(["-3","-2","B","2","3","1","0","-1","b","a","A"]) --> ['-3', '-2', '-1', '0', '1', '2', '3', 'A', 'B', 'a', 'b']
346
     * - sort(["B","3","1","0","A","C","c","b","2","a"]) --> ['0', '1', '2', '3', 'A', 'B', 'C', 'a', 'b', 'c']
347
     * - sort(["B","A2","A1"],[2,4,1]) --> ['A1', 'B', 'A2']
348
     * - sort([1,2,3], ["A10","A1","A2"]) --> [2, 3, 1]
349
     *
350
     * @param array $tosort list to be sorted
351
     * @param ?array $order sort order
352
     * @return array sorted list
353
     */
354
    public static function sort($tosort, $order = null): array {
355
        // The first argument must be an array.
356
        if (!is_array($tosort)) {
170✔
357
            self::die('error_func_first_list', 'sort()');
68✔
358
        }
359

360
        // If we have one list only, we duplicate it.
361
        if ($order === null) {
102✔
362
            $order = $tosort;
51✔
363
        }
364

365
        // If two arguments are given, the second must be an array.
366
        if (!is_array($order)) {
102✔
367
            self::die('error_sort_twolists');
34✔
368
        }
369

370
        // If we have two lists, they must have the same number of elements.
371
        if (count($tosort) !== count($order)) {
68✔
372
            self::die('error_sort_samesize');
17✔
373
        }
374

375
        // Now sort the first array, using the second as the sort order.
376
        $tmp = $tosort;
51✔
377
        uksort($tmp, function($a, $b) use ($order) {
51✔
378
            $first = $order[$a]->value;
51✔
379
            $second = $order[$b]->value;
51✔
380
            // If both elements are numeric, we compare their numerical value.
381
            if (is_numeric($first) && is_numeric($second)) {
51✔
382
                return floatval($first) <=> floatval($second);
51✔
383
            }
384
            // Otherwise, we use natural sorting.
NEW
385
            return strnatcmp($first, $second);
×
386
        });
51✔
387
        return array_values($tmp);
51✔
388
    }
389

390
    /**
391
     * Wrapper for the poly() function which can be invoked in many different ways:
392
     * - (1) list of numbers => polynomial with variable x
393
     * - (1) number => force + sign if number > 0
394
     * - (2) string, number => combine
395
     * - (2) string, list of numbers => polynomial with variable from string
396
     * - (2) list of strings, list of numbers => linear combination
397
     * - (2) list of numbers, string => polynomial with x using second argument as separator (e.g. &)
398
     * - (3) string, number, string => combine them and, if appropriate, force + sign
399
     * - (3) string, list of numbers, string => polynomial (one var) using third argument as separator (e.g. &)
400
     * - (3) list of strings, list of numbers, string => linear combination using third argument as separator
401
     *
402
     * This will call the poly_formatter() function accordingly.
403
     */
404
    public static function poly(...$args) {
405
        $numargs = count($args);
1,428✔
406
        switch ($numargs) {
407
            case 1:
1,428✔
408
                $argument = token::unpack($args[0]);
289✔
409
                // For backwards compatibility: if called with just a list of numbers, use x as variable.
410
                if (self::is_numeric_array($argument)) {
289✔
411
                    return self::poly_formatter('x', $argument);
153✔
412
                }
413
                // If called with just a number, force the plus sign (if the number is positive) to be shown.
414
                // Basically, there is no other reason one would call this function with just one number.
415
                if (is_numeric($argument)) {
136✔
416
                    return self::poly_formatter('', $argument, '+');
102✔
417
                }
418
                // If the single argument is neither an array, nor a number or numeric string,
419
                // we throw an error.
420
                self::die('error_poly_one');
34✔
421
            case 2:
1,139✔
422
                $first = token::unpack($args[0]);
612✔
423
                $second = token::unpack($args[1]);
612✔
424
                // If the first argument is a string, we distinguish to cases: (a) the second is a number
425
                // and (b) the second is a list of numbers.
426
                if (is_string($first)) {
612✔
427
                    // If we have a string and a number, we wrap them in arrays and build a linear combination.
428
                    if (is_float($second)) {
425✔
429
                        return self::poly_formatter([$first], [$second]);
119✔
430
                    }
431
                    // If it is a string and a list of numbers, we build a polynomial.
432
                    if (self::is_numeric_array($second)) {
306✔
433
                        return self::poly_formatter($first, $second);
289✔
434
                    }
435
                    self::die('error_poly_string');
17✔
436
                }
437
                // If called with a list of numbers and a string, use x as default variable for the polynomial and use the
438
                // third argument as a separator, e. g. for a usage in LaTeX matrices or array-like constructions.
439
                if (self::is_numeric_array($first) && is_string($second)) {
187✔
440
                    return self::poly_formatter('x', $first, '', $second);
68✔
441
                }
442
                // If called with a list of strings, the next argument must be a list of numbers.
443
                if (is_array($first)) {
119✔
444
                    if (self::is_numeric_array($second)) {
102✔
445
                        return self::poly_formatter($first, $second);
85✔
446
                    }
447
                    self::die('error_poly_stringlist');
17✔
448
                }
449
                // Any other invocations with two arguments is invalid.
450
                self::die('error_poly_two');
17✔
451
            case 3:
527✔
452
                $first = token::unpack($args[0]);
527✔
453
                $second = token::unpack($args[1]);
527✔
454
                $third = token::unpack($args[2]);
527✔
455
                // If called with a string, a number and another string, combine them while using the third argument
456
                // to e. g. force a "+" on positive numbers.
457
                if (is_string($first) && is_float($second) && is_string($third)) {
527✔
458
                    return self::poly_formatter([$first], [$second], $third);
34✔
459
                }
460
                // If called with a string (or list of strings), a list of numbers and another string, combine them
461
                // while using the third argument as a separator, e. g. for a usage in LaTeX matrices or array-like constructions.
462
                return self::poly_formatter($first, $second, '', $third);
493✔
463
        }
464
    }
465

466
    /**
467
     * Format a polynomial to be display with LaTeX / MathJax. The function can also be
468
     * used to force the plus sign for a single number or to format arbitrary linear combinations.
469
     *
470
     * This function will be called by the public poly() function.
471
     *
472
     * @param mixed $variables one variable (as a string) or a list of variables (array of strings)
473
     * @param mixed $coefficients one number or an array of numbers to be used as coefficients
474
     * @param string $forceplus symbol to be used for the normally invisible leading plus, optional
475
     * @param string $additionalseparator symbol to be used as separator between the terms, optional
476
     * @return string  the formatted string
477
     */
478
    private static function poly_formatter($variables, $coefficients = null, $forceplus = '', $additionalseparator = '') {
479
        // If no variable is given and there is just one single number, simply force the plus sign
480
        // on positive numbers.
481
        if ($variables === '' && is_numeric($coefficients)) {
1,343✔
482
            if ($coefficients > 0) {
102✔
483
                return $forceplus . $coefficients;
51✔
484
            }
485
            return $coefficients;
51✔
486
        }
487

488
        $numberofterms = count($coefficients);
1,241✔
489
        // By default, we think that a final coefficient == 1 is not to be shown, because it is a true coefficient
490
        // and not a constant term. Also, terms with coefficient == zero should generally be completely omitted.
491
        $constantone = false;
1,241✔
492
        $omitzero = true;
1,241✔
493

494
        // If the variable is left empty, but there is a list of coefficients, we build an empty array
495
        // of the same size as the number of coefficients. This can be used to pretty-print matrix rows.
496
        // In that case, the numbers 1 and 0 should never be omitted.
497
        if ($variables === '') {
1,241✔
498
            $variables = array_fill(0, $numberofterms, '');
102✔
499
            $constantone = true;
102✔
500
            $omitzero = false;
102✔
501
        }
502

503
        // If there is just one variable, we blow it up to an array of the correct size and descending exponents.
504
        if (gettype($variables) === 'string' && $variables !== '') {
1,241✔
505
            // As we have just one variable, we are building a standard polynomial where the last coefficient
506
            // is not a real coefficient, but a constant term that has to be printed.
507
            $constantone = true;
578✔
508
            $tmp = $variables;
578✔
509
            $variables = [];
578✔
510
            for ($i = 0; $i < $numberofterms; $i++) {
578✔
511
                if ($i == $numberofterms - 2) {
578✔
512
                    $variables[$i] = $tmp;
578✔
513
                } else if ($i == $numberofterms - 1) {
578✔
514
                    $variables[$i] = '';
578✔
515
                } else {
516
                    $variables[$i] = $tmp . '^{' . ($numberofterms - 1 - $i) . '}';
459✔
517
                }
518
            }
519
        }
520
        // If the list of variables is shorter than the list of coefficients, just start over again.
521
        if (count($variables) < $numberofterms) {
1,241✔
522
            $numberofvars = count($variables);
51✔
523
            for ($i = count($variables); $i < $numberofterms; $i++) {
51✔
524
                $variables[$i] = $variables[$i % $numberofvars];
51✔
525
            }
526
        }
527

528
        // If the separator is "doubled", e.g. &&, we put one half before and one half after the
529
        // operator. By default, we have the entire separator before the operator. Also, we do not
530
        // change anything if we are building a matrix row, because there are no operators, just signs.
531
        $separatorlength = strlen($additionalseparator);
1,241✔
532
        $separatorbefore = $additionalseparator;
1,241✔
533
        $separatorafter = '';
1,241✔
534
        if ($separatorlength > 0 && $separatorlength % 2 === 0 && $omitzero) {
1,241✔
535
            $tmpbefore = substr($additionalseparator, 0, $separatorlength / 2);
187✔
536
            $tmpafter = substr($additionalseparator, $separatorlength / 2);
187✔
537
            // If the separator just has even length, but is not "doubled", we don't touch it.
538
            if ($tmpbefore === $tmpafter) {
187✔
539
                $separatorbefore = $tmpbefore;
170✔
540
                $separatorafter = $tmpafter;
170✔
541
            }
542
        }
543

544
        $result = '';
1,241✔
545
        // First term should not have a leading plus sign, unless user wants to force it.
546
        foreach ($coefficients as $i => $coef) {
1,241✔
547
            $thisseparatorbefore = ($i == 0 ? '' : $separatorbefore);
1,241✔
548
            $thisseparatorafter = ($i == 0 ? '' : $separatorafter);
1,241✔
549
            // Terms with coefficient == 0 are generally not shown. But if we use a separator, it must be printed anyway.
550
            if ($coef == 0) {
1,241✔
551
                if ($i > 0) {
510✔
552
                    $result .= $thisseparatorbefore . $thisseparatorafter;
391✔
553
                }
554
                if ($omitzero) {
510✔
555
                    continue;
459✔
556
                }
557
            }
558
            // Put a + or - sign according to value of coefficient and replace the coefficient
559
            // by its absolute value, as we don't need the sign anymore after this step.
560
            // If the coefficient is 0 and we force its output, do it now. However, do not put a sign,
561
            // as the only documented usage of this is for matrix rows and the like.
562
            if ($coef < 0) {
1,122✔
563
                $result .= $thisseparatorbefore . '-' . $thisseparatorafter;
476✔
564
                $coef = abs($coef);
476✔
565
            } else if ($coef > 0) {
782✔
566
                // If $omitzero is false, we are building a matrix row, so we don't put plus signs.
567
                $result .= $thisseparatorbefore . ($omitzero ? '+' : '') . $thisseparatorafter;
765✔
568
            }
569
            // Put the coefficient. If the coefficient is +1 or -1, we don't put the number,
570
            // unless we're at the last term. The sign is already there, so we use the absolute value.
571
            // Never omit 1's if building a matrix row.
572
            if ($coef == 1) {
1,122✔
573
                $coef = (!$omitzero || ($i == $numberofterms - 1 && $constantone) ? '1' : '');
901✔
574
            }
575
            $result .= $coef . $variables[$i];
1,122✔
576
        }
577
        // If the resulting string is empty (or empty with just alignment separators), add a zero at the end.
578
        if ($result === '' || $result === str_repeat($additionalseparator, $numberofterms - 1)) {
1,241✔
579
            $result .= '0';
119✔
580
        }
581
        // Strip leading + and replace by $forceplus (which will be '' or '+' most of the time).
582
        if ($result[0] == '+') {
1,241✔
583
            $result = $forceplus . substr($result, 1);
629✔
584
        }
585
        // If we have nothing but separators before the leading +, replace that + by $forceplus.
586
        if ($separatorbefore !== '' && preg_match("/^($separatorbefore+)\+/", $result)) {
1,241✔
587
            $result = preg_replace("/^($separatorbefore+)\+/", "\\1$forceplus", $result);
51✔
588
        }
589
        return $result;
1,241✔
590
    }
591

592
    /**
593
     * Given a list $list, return the elements at the positions defined by the list $indices,
594
     * e. g. sublist([1, 2, 3], [0, 0, 2, 2, 1, 1]) yields [1, 1, 3, 3, 2, 2].
595
     *
596
     * @param mixed $list
597
     * @param mixed $indices
598
     * @return array
599
     */
600
    public static function sublist($list, $indices): array {
601
        if (!is_array($list) || !is_array($indices)) {
238✔
602
            self::die('error_func_all_lists', 'sublist()');
85✔
603
        }
604

605
        $result = [];
153✔
606
        foreach ($indices as $i) {
153✔
607
            $i = $i->value;
136✔
608
            $i = self::assure_numeric($i, get_string('error_sublist_indices', 'qtype_formulas', $i), self::INTEGER);
136✔
609
            if ($i > count($list) - 1 || $i < 0) {
102✔
610
                self::die('error_sublist_outofrange', $i);
34✔
611
            }
612
            $result[] = $list[$i];
68✔
613
        }
614
        return $result;
85✔
615
    }
616

617
    /**
618
     * Round a given number to an indicated number of significant figures. The function
619
     * returns a string in order to allow trailing zeroes.
620
     *
621
     * @param mixed $number
622
     * @param mixed $precision
623
     * @return string
624
     */
625
    public static function sigfig($number, $precision): string {
626
        self::assure_numeric($number, get_string('error_func_first_number', 'qtype_formulas', 'sigfig()'));
442✔
627
        self::assure_numeric(
442✔
628
            $precision,
442✔
629
            get_string('error_func_second_posint', 'qtype_formulas', 'sigfig()'),
442✔
630
            self::POSITIVE | self::INTEGER
442✔
631
        );
442✔
632
        $number = floatval($number);
442✔
633
        $precision = intval($precision);
442✔
634

635
        // First, we calculate how many digits we have before the decimal point.
636
        $digitsbefore = 1;
442✔
637
        if ($number != 0) {
442✔
638
            $digitsbefore = floor(log10(abs($number))) + 1;
442✔
639
        }
640
        // Now, we determine the number of decimals (digits after the point). This
641
        // number might be negative, e.g. if we want to have 12345 with 3 significant
642
        // figures. Or it might be zero, e.g. if 12345 must be brought to 5 significant
643
        // figures.
644
        $digitsafter = $precision - $digitsbefore;
442✔
645

646
        // We round the number as desired. This might add zeroes, e.g. 12345 will become
647
        // 12300 when rounded to -2 digits.
648
        $number = round($number, $digitsafter);
442✔
649

650
        // We only request decimals if $digitsafter is greater than zero.
651
        $digitsafter = max(0, $digitsafter);
442✔
652

653
        return number_format($number, $digitsafter, '.', '');
442✔
654
    }
655

656
    /**
657
     * Return the number of elements in a list or the length of a string.
658
     *
659
     * @param array|string $arg list or string
660
     * @return int number of elements or length
661
     */
662
    public static function len($arg): int {
663
        if (is_array($arg)) {
187✔
664
            return count($arg);
136✔
665
        }
666
        if (is_string($arg)) {
51✔
667
            return strlen($arg);
17✔
668
        }
669
        self::die('error_len_argument');
34✔
670
    }
671

672
    /**
673
     * Create an array of a given size, filled with a given value.
674
     *
675
     * Examples:
676
     * - fill(5, 1) -> [1, 1, 1, 1, 1]
677
     * - fill(3, "a") -> ["a", "a", "a"]
678
     * - fill(4, [1, 2]) -> [[1, 2], [1, 2], [1, 2], [1, 2]]
679
     *
680
     * @param int $count number of elements
681
     * @param mixed $value value to use
682
     * @return array
683
     */
684
    public static function fill($count, $value): array {
685
        // If $count is invalid, it will be converted to 0 which will then lead to an error.
686
        $count = intval($count);
34✔
687
        if ($count < 1) {
34✔
NEW
688
            self::die('error_func_first_posint', 'fill()');
×
689
        }
690
        return array_fill(0, $count, token::wrap($value));
34✔
691
    }
692

693
    /**
694
     * Calculate the sum of all elements in an array.
695
     *
696
     * @param array $array list of numbers
697
     * @return float sum
698
     */
699
    public static function sum($array): float {
700
        if (!is_array($array)) {
170✔
701
            self::die('error_sum_argument');
51✔
702
        }
703

704
        $result = 0;
119✔
705
        foreach ($array as $token) {
119✔
706
            $value = $token->value;
102✔
707
            if (!is_numeric($value)) {
102✔
708
                self::die('error_sum_argument');
17✔
709
            }
710
            $result += floatval($value);
85✔
711
        }
712
        return $result;
102✔
713
    }
714

715
    /**
716
     * Convert number to string.
717
     *
718
     * @param float $value number
719
     * @return string
720
     */
721
    public static function str($value): string {
722
        if (!is_scalar($value)) {
85✔
723
            self::die('error_str_argument');
51✔
724
        }
725
        return strval($value);
34✔
726
    }
727

728
    /**
729
     * Concatenate the given strings, separating them by the given separator, e. g.
730
     * join('-', 'a', 'b') gives 'a-b'. The strings to be joined can also be passed as
731
     * a list, e. g. join('-', ['a', 'b']).
732
     *
733
     * @param string $separator
734
     * @param string ...$values
735
     * @return string
736
     */
737
    public static function join($separator, ...$values): string {
738
        $result = [];
272✔
739
        // Using array_walk_recursive() makes it easy to accept a list of strings as the second
740
        // argument instead of giving all strings individually.
741
        array_walk_recursive($values, function($val) use (&$result) {
272✔
742
            $result[] = $val;
272✔
743
        });
272✔
744
        return implode($separator, $result);
272✔
745
    }
746

747
    /**
748
     * Return the n-th element of a list or multiple arguments. If the index is out of range,
749
     * the function will always return the *first* element in order to maintain backwards
750
     * compatibility.
751
     *
752
     * @param mixed $index
753
     * @param mixed ...$data
754
     * @return void
755
     */
756
    public static function pick($index, ...$data) {
757
        // The index must be a number (or a numeric string). We do not enforce it to be integer.
758
        // If it is not, it will be truncated for backwards compatibility.
759
        self::assure_numeric($index, get_string('error_func_first_number', 'qtype_formulas', 'pick()'));
459✔
760
        $index = intval($index);
442✔
761

762
        $count = count($data);
442✔
763

764
        // The $data parameter will always be an array and will contain
765
        // - one single array for the pick(index, list) usage
766
        // - the various values for the pick(index, val1, val2, val3, ...) usage.
767
        if ($count === 1) {
442✔
768
            if (!is_array($data[0])) {
153✔
769
                self::die('error_pick_two');
17✔
770
            }
771
            // We set $data to the given array and update the count.
772
            $data = $data[0];
136✔
773
            $count = count($data);
136✔
774
        }
775

776
        // For backwards compatibility, we always take the first element if the index is
777
        // out of range. Indexing "from the end" is not allowed.
778
        if ($index > $count - 1 || $index < 0) {
425✔
779
            $index = 0;
204✔
780
        }
781

782
        // We can either return a a token or a value and let the caller wrap it into a token.
783
        return $data[$index];
425✔
784
    }
785

786
    /**
787
     * Shuffle the elements of an array.
788
     *
789
     * @param array $ar
790
     * @return array
791
     */
792
    public static function shuffle(array $ar): array {
793
        shuffle($ar);
34✔
794
        return $ar;
34✔
795
    }
796

797
    /**
798
     * Recursively shuffle a given array.
799
     *
800
     * @param array $ar
801
     * @return array
802
     */
803
    public static function rshuffle(array $ar): array {
804
        // First, we shuffle the array.
805
        shuffle($ar);
17✔
806

807
        // Now, we iterate over all elements and check whether they are nested arrays.
808
        // If they are, we shuffle them recursively.
809
        foreach ($ar as $element) {
17✔
810
            if (is_array($element->value)) {
17✔
811
                $element->value = self::shuffle($element->value);
17✔
812
            }
813
        }
814

815
        return $ar;
17✔
816
    }
817

818
    /**
819
     * Calculate the factorial n! of a non-negative integer.
820
     * Note: technically, the function accepts a float, because in some
821
     * PHP versions, if one passes a float to a function that expectes an int,
822
     * the float will be converted. We'd rather detect that and print an error.
823
     *
824
     * @param float $n the number
825
     * @return int
826
     */
827
    public static function fact(float $n): int {
828
        $n = self::assure_numeric(
306✔
829
            $n,
306✔
830
            get_string('error_func_nnegint', 'qtype_formulas', 'fact()'),
306✔
831
            self::NON_NEGATIVE | self::INTEGER
306✔
832
        );
306✔
833
        if ($n < 2) {
272✔
834
            return 1;
85✔
835
        }
836
        $result = 1;
187✔
837
        for ($i = 1; $i <= $n; $i++) {
187✔
838
            if ($result > PHP_INT_MAX / $i) {
187✔
839
                self::die('error_fact_toolarge', $n);
17✔
840
            }
841
            $result *= $i;
187✔
842
        }
843
        return $result;
170✔
844
    }
845

846
    /**
847
     * calculate standard normal probability density
848
     *
849
     * @param float $z value
850
     * @return float standard normal density of $z
851
     */
852
    public static function stdnormpdf(float $z): float {
853
        return 1 / (sqrt(2) * M_SQRTPI) * exp(-.5 * $z ** 2);
119✔
854
    }
855

856
    /**
857
     * Calculate standard normal cumulative distribution by approximation,
858
     * accurate at least to 1e-12. The approximation uses the complementary
859
     * error function and some magic numbers that can be found in Wikipedia:
860
     * https://en.wikipedia.org/wiki/Error_function
861
     *
862
     * @param float $z value
863
     * @return float probability for a value of $z or less under standard normal distribution
864
     */
865
    public static function stdnormcdf(float $z): float {
866
        // We use the relationship Phi(z) = (1 + erf(z/sqrt(2))) / 2 with
867
        // erf() being the error function. Instead of erf(), we will approximate
868
        // the complementary error function erfc() and use erf(x) = 1 - erfc(x).
869
        // The approximation formula for erfc is valid for $z >= 0. For $z < 0,
870
        // we can use the identity erfc(x) = 2 - erfc(-x), so we store the sign
871
        // and transform $z |-> abs($z) / sqrt(2).
872
        $sign = $z >= 0 ? 1 : -1;
306✔
873
        $z = abs($z) / sqrt(2);
306✔
874

875
        // Magic coefficients from Wikipedia.
876
        $p = [
306✔
877
            [0, 0, 0.56418958354775629],
306✔
878
            [1, 2.71078540045147805, 5.80755613130301624],
306✔
879
            [1, 3.47469513777439592, 12.07402036406381411],
306✔
880
            [1, 4.00561509202259545, 9.30596659485887898],
306✔
881
            [1, 5.16722705817812584, 9.12661617673673262],
306✔
882
            [1, 5.95908795446633271, 9.19435612886969243],
306✔
883
        ];
306✔
884
        $q = [
306✔
885
            [0, 1, 2.06955023132914151],
306✔
886
            [1, 3.47954057099518960, 12.06166887286239555],
306✔
887
            [1, 3.72068443960225092, 8.44319781003968454],
306✔
888
            [1, 3.90225704029924078, 6.36161630953880464],
306✔
889
            [1, 4.03296893109262491, 5.13578530585681539],
306✔
890
            [1, 4.11240942957450885, 4.48640329523408675],
306✔
891
        ];
306✔
892

893
        // Calculate approximation of erfc()...
894
        $erfc = exp(-$z ** 2);
306✔
895
        for ($i = 0; $i < count($p); $i++) {
306✔
896
            $erfc *= ($p[$i][0] * $z ** 2 + $p[$i][1] * $z + $p[$i][2]) / ($q[$i][0] * $z ** 2 + $q[$i][1] * $z + $q[$i][2]);
306✔
897
        }
898
        // If needed, transform for negative input.
899
        if ($sign === -1) {
306✔
900
            $erfc = 2 - $erfc;
119✔
901
        }
902
        // We need (1 + erf) / 2 with erf = 1 - erfc.
903
        return (2 - $erfc) / 2;
306✔
904
    }
905

906
    /**
907
     * Calculate normal cumulative distribution based on stdnormcdf(). The
908
     * approxmation is accurate at least to 1e-12.
909
     *
910
     * @param float $x value
911
     * @param float $mu mean
912
     * @param float $sigma standard deviation
913
     * @return float probability for a value of $x or less
914
     */
915
    public static function normcdf(float $x, float $mu, float $sigma): float {
916
        return self::stdnormcdf(($x - $mu) / $sigma);
68✔
917
    }
918

919
    /**
920
     * raise $a to the $b-th power modulo $m using efficient
921
     * square and multiply
922
     *
923
     * @param int $a base
924
     * @param int $b exponent
925
     * @param int $m modulus
926
     * @return int
927
     */
928
    public static function modpow($a, $b, $m): int {
929
        $a = self::assure_numeric(
238✔
930
            $a,
238✔
931
            get_string('error_func_first_int', 'qtype_formulas', 'modpow()'),
238✔
932
            self::INTEGER
238✔
933
        );
238✔
934
        $b = self::assure_numeric(
238✔
935
            $b,
238✔
936
            get_string('error_func_second_int', 'qtype_formulas', 'modpow()'),
238✔
937
            self::INTEGER
238✔
938
        );
238✔
939
        $m = self::assure_numeric(
238✔
940
            $m,
238✔
941
            get_string('error_func_third_posint', 'qtype_formulas', 'modpow()'),
238✔
942
            self::INTEGER | self::POSITIVE
238✔
943
        );
238✔
944

945
        $bin = decbin($b);
238✔
946
        $res = $a;
238✔
947
        if ($b == 0) {
238✔
948
            return 1;
17✔
949
        }
950
        for ($i = 1; $i < strlen($bin); $i++) {
221✔
951
            if ($bin[$i] == "0") {
221✔
952
                $res = ($res * $res) % $m;
85✔
953
            } else {
954
                $res = ($res * $res) % $m;
187✔
955
                $res = ($res * $a) % $m;
187✔
956
            }
957
        }
958
        return $res;
221✔
959
    }
960

961
    /**
962
     * Calculate the multiplicative inverse of $a modulo $m using the
963
     * extended euclidean algorithm.
964
     *
965
     * @param int $a the number whose inverse is to be found
966
     * @param int $m the modulus
967
     * @return int the result or 0 if the inverse does not exist
968
     */
969
    public static function modinv(int $a, int $m): int {
970
        $a = self::assure_numeric(
204✔
971
            $a,
204✔
972
            get_string('error_func_first_nzeroint', 'qtype_formulas', 'modinv()'),
204✔
973
            self::INTEGER | self::NON_ZERO
204✔
974
        );
204✔
975
        $m = self::assure_numeric(
187✔
976
            $m,
187✔
977
            get_string('error_func_second_posint', 'qtype_formulas', 'modinv()'),
187✔
978
            self::INTEGER | self::POSITIVE
187✔
979
        );
187✔
980

981
        $origm = $m;
153✔
982
        if (self::gcd($a, $m) != 1) {
153✔
983
            // Inverse does not exist.
984
            return 0;
17✔
985
        }
986
        list($s, $t, $lasts, $lastt) = [1, 0, 0, 1];
136✔
987
        while ($m != 0) {
136✔
988
            $q = floor($a / $m);
136✔
989
            list($a, $m) = [$m, $a - $q * $m];
136✔
990
            list($s, $lasts) = [$lasts, $s - $q * $lasts];
136✔
991
            list($t, $lastt) = [$lastt, $t - $q * $lastt];
136✔
992
        }
993
        return $s < 0 ? $s + $origm : $s;
136✔
994
    }
995

996
    /**
997
     * Calculate the floating point remainder of the division of
998
     * the arguments, i. e. x - m * floor(x / m). There is no
999
     * canonical definition for this function; some calculators
1000
     * use flooring (round down to nearest integer) and others
1001
     * use truncation (round to nearest integer, but towards zero).
1002
     * This implementation gives the same results as e. g. Wolfram Alpha.
1003
     *
1004
     * @param float $x the dividend
1005
     * @param float $m the modulus
1006
     * @return float remainder of $x modulo $m
1007
     * @throws Exception
1008
     */
1009
    public static function fmod($x, $m): float {
1010
        self::assure_numeric($x, get_string('error_func_first_number', 'qtype_formulas', 'fmod()'));
221✔
1011
        self::assure_numeric($m, get_string('error_func_second_nzeronum', 'qtype_formulas', 'fmod()'), self::NON_ZERO);
187✔
1012
        return $x - $m * floor($x / $m);
153✔
1013
    }
1014

1015
    /**
1016
     * Calculate the probability of exactly $x successful outcomes for
1017
     * $n trials under a binomial distribution with a probability of success
1018
     * of $p.
1019
     *
1020
     * @param float $n number of trials
1021
     * @param float $p probability of success for each trial
1022
     * @param float $x number of successful outcomes
1023
     *
1024
     * @return float probability for exactly $x successful outcomes
1025
     * @throws Exception
1026
     */
1027
    public static function binomialpdf(float $n, float $p, float $x): float {
1028
        // Probability must be 0 <= p <= 1.
1029
        if ($p < 0 || $p > 1) {
272✔
1030
            self::die('error_probability', 'binomialpdf()');
34✔
1031
        }
1032
        // Number of tries must be at least 0.
1033
        $n = self::assure_numeric(
238✔
1034
            $n,
238✔
1035
            get_string('error_distribution_tries', 'qtype_formulas', 'binomialpdf()'),
238✔
1036
            self::NON_NEGATIVE | self::INTEGER
238✔
1037
        );
238✔
1038
        // Number of successful outcomes must be at least 0.
1039
        $x = self::assure_numeric(
238✔
1040
            $x,
238✔
1041
            get_string('error_distribution_outcomes', 'qtype_formulas', 'binomialpdf'),
238✔
1042
            self::NON_NEGATIVE | self::INTEGER
238✔
1043
        );
238✔
1044
        // If the number of successful outcomes is greater than the number of trials, the probability
1045
        // is zero.
1046
        if ($x > $n) {
238✔
1047
            return 0;
17✔
1048
        }
1049
        return self::ncr($n, $x) * $p ** $x * (1 - $p) ** ($n - $x);
221✔
1050
    }
1051

1052
    /**
1053
     * Calculate the probability of up to $x successful outcomes for
1054
     * $n trials under a binomial distribution with a probability of success
1055
     * of $p, known as the cumulative distribution function.
1056
     *
1057
     * @param int $n number of trials
1058
     * @param float $p probability of success for each trial
1059
     * @param int $x number of successful outcomes
1060
     *
1061
     * @return float probability for up to $x successful outcomes
1062
     * @throws Exception
1063
     */
1064
    public static function binomialcdf(float $n, float $p, float $x): float {
1065
        // Probability must be 0 <= p <= 1.
1066
        if ($p < 0 || $p > 1) {
204✔
1067
            self::die('error_probability', 'binomialcdf()');
34✔
1068
        }
1069
        // Number of tries must be at least 0.
1070
        $n = self::assure_numeric(
170✔
1071
            $n,
170✔
1072
            get_string('error_distribution_tries', 'qtype_formulas', 'binomialcdf()'),
170✔
1073
            self::NON_NEGATIVE | self::INTEGER
170✔
1074
        );
170✔
1075
        // Number of successful outcomes must be at least 0.
1076
        $x = self::assure_numeric(
170✔
1077
            $x,
170✔
1078
            get_string('error_distribution_outcomes', 'qtype_formulas', 'binomialcdf'),
170✔
1079
            self::NON_NEGATIVE | self::INTEGER
170✔
1080
        );
170✔
1081
        // The probability for *up to* $n or more successful outcomes is 1.
1082
        if ($x >= $n) {
170✔
1083
            return 1;
85✔
1084
        }
1085
        $res = 0;
85✔
1086
        for ($i = 0; $i <= $x; $i++) {
85✔
1087
            $res += self::binomialpdf($n, $p, $i);
85✔
1088
        }
1089
        return $res;
85✔
1090
    }
1091

1092
    /**
1093
     * Calculate the natural logarithm of a number.
1094
     *
1095
     * @param float $x number
1096
     * @return float
1097
     */
1098
    public static function ln(float $x): float {
NEW
1099
        if ($x <= 0) {
×
NEW
1100
            self::die('error_func_positive', 'ln()');
×
1101
        }
NEW
1102
        return log($x);
×
1103
    }
1104

1105
    /**
1106
     * Calculate the number of permutations when taking $r elements
1107
     * from a set of $n elements. The arguments must be integers.
1108
     * Note: technically, the function accepts floats, because in some
1109
     * PHP versions, if one passes a float to a function that expectes an int,
1110
     * the float will be converted. We'd rather detect that and print an error.
1111
     *
1112
     * @param float $n the number of elements to choose from
1113
     * @param float $r the number of elements to be chosen
1114
     * @return int
1115
     */
1116
    public static function npr(float $n, float $r): int {
1117
        $n = self::assure_numeric(
221✔
1118
            $n,
221✔
1119
            get_string('error_func_first_nnegint', 'qtype_formulas', 'npr()'),
221✔
1120
            self::NON_NEGATIVE | self::INTEGER
221✔
1121
        );
221✔
1122
        $r = self::assure_numeric(
187✔
1123
            $r,
187✔
1124
            get_string('error_func_second_nnegint', 'qtype_formulas', 'npr()'),
187✔
1125
            self::NON_NEGATIVE | self::INTEGER
187✔
1126
        );
187✔
1127

1128
        return self::ncr($n, $r) * self::fact($r);
153✔
1129
    }
1130

1131
    /**
1132
     * Calculate the number of combination when taking $r elements
1133
     * from a set of $n elements. The arguments must be integers.
1134
     * Note: technically, the function accepts floats, because in some
1135
     * PHP versions, if one passes a float to a function that expectes an int,
1136
     * the float will be converted. We'd rather detect that and print an error.
1137
     *
1138
     * @param float $n the number of elements to choose from
1139
     * @param float $r the number of elements to be chosen
1140
     * @return int
1141
     */
1142
    public static function ncr(float $n, float $r): int {
1143
        $n = self::assure_numeric($n, get_string('error_func_first_int', 'qtype_formulas', 'ncr()'), self::INTEGER);
612✔
1144
        $r = self::assure_numeric($r, get_string('error_func_second_int', 'qtype_formulas', 'ncr()'), self::INTEGER);
595✔
1145

1146
        // The binomial coefficient is calculated for 0 <= r < n. For all
1147
        // other cases, the result is zero.
1148
        if ($r < 0 || $n < 0 || $n < $r) {
578✔
1149
            return 0;
102✔
1150
        }
1151
        // Take the shortest path.
1152
        if (($n - $r) < $r) {
476✔
1153
            return self::ncr($n, ($n - $r));
187✔
1154
        }
1155
        $numerator = 1;
476✔
1156
        $denominator = 1;
476✔
1157
        for ($i = 1; $i <= $r; $i++) {
476✔
1158
            $numerator *= ($n - $i + 1);
221✔
1159
            $denominator *= $i;
221✔
1160
        }
1161
        return intdiv($numerator, $denominator);
476✔
1162
    }
1163

1164
    /**
1165
     * Calculate the greatest common divisor of two integers $a and $b
1166
     * via the Euclidean algorithm. The arguments must be integers.
1167
     * Note: technically, the function accepts floats, because in some
1168
     * PHP versions, if one passes a float to a function that expectes an int,
1169
     * the float will be converted. We'd rather detect that and print an error.
1170
     *
1171
     * @param float $a first number
1172
     * @param float $b second number
1173
     * @return int
1174
     */
1175
    public static function gcd(float $a, float $b): int {
1176
        $a = self::assure_numeric($a, get_string('error_func_first_int', 'qtype_formulas', 'gcd()'), self::INTEGER);
629✔
1177
        $b = self::assure_numeric($b, get_string('error_func_second_int', 'qtype_formulas', 'gcd()'), self::INTEGER);
612✔
1178

1179
        if ($a < 0) {
595✔
1180
            $a = abs($a);
85✔
1181
        }
1182
        if ($b < 0) {
595✔
1183
            $b = abs($b);
68✔
1184
        }
1185
        if ($a == 0 && $b == 0) {
595✔
1186
            return 0;
17✔
1187
        }
1188
        if ($a == 0 || $b == 0) {
578✔
1189
            return $a + $b;
51✔
1190
        }
1191
        if ($a == $b) {
527✔
1192
            return $a;
34✔
1193
        }
1194
        do {
1195
            $remainder = (int) $a % $b;
493✔
1196
            $a = $b;
493✔
1197
            $b = $remainder;
493✔
1198
        } while ($remainder > 0);
493✔
1199
        return $a;
493✔
1200
    }
1201

1202
    /**
1203
     * Calculate the least (non-negative) common multiple of two integers $a and $b
1204
     * via the Euclidean algorithm. The arguments must be integers.
1205
     * Note: technically, the function accepts floats, because in some
1206
     * PHP versions, if one passes a float to a function that expectes an int,
1207
     * the float will be converted. We'd rather detect that and print an error.
1208
     *
1209
     * @param float $a first number
1210
     * @param float $b second number
1211
     * @return int
1212
     */
1213
    public static function lcm(float $a, float $b): int {
1214
        $a = self::assure_numeric($a, get_string('error_func_first_int', 'qtype_formulas', 'lcm()'), self::INTEGER);
289✔
1215
        $b = self::assure_numeric($b, get_string('error_func_second_int', 'qtype_formulas', 'lcm()'), self::INTEGER);
272✔
1216

1217
        if ($a == 0 || $b == 0) {
255✔
1218
            return 0;
68✔
1219
        }
1220
        return abs($a * $b) / self::gcd($a, $b);
187✔
1221
    }
1222

1223
    /**
1224
     * In many cases, operators need a numeric or at least a scalar operand to work properly.
1225
     * This function does the necessary check and prepares a human-friendly error message
1226
     * if the conditions are not met.
1227
     *
1228
     * @param mixed $value the value to check
1229
     * @param string $who the operator or function we perform the check for
1230
     * @param bool $enforcenumeric whether the value must be numeric in addition to being scalar
1231
     * @return void
1232
     * @throws Exception
1233
     */
1234
    private static function abort_if_not_scalar($value, string $who = '', bool $enforcenumeric = true): void {
1235
        $a = (object)[];
1,496✔
1236
        $variant = 'expected';
1,496✔
1237
        if ($who !== '') {
1,496✔
1238
            $a->who = $who;
1,496✔
1239
            $variant = 'expects';
1,496✔
1240
        }
1241
        $expectation = ($enforcenumeric ? 'number' : 'scalar');
1,496✔
1242

1243
        if (!is_scalar($value)) {
1,496✔
1244
            self::die("error_{$variant}_{$expectation}", $a);
17✔
1245
        }
1246
        $isnumber = is_float($value) || is_int($value);
1,479✔
1247
        if ($enforcenumeric && !$isnumber) {
1,479✔
1248
            $a->found = $value;
17✔
1249
            self::die("error_{$variant}_{$expectation}_found", $a);
17✔
1250
        }
1251
    }
1252

1253
    /**
1254
     * Apply an unary operator to a given argument.
1255
     *
1256
     * @param string $op operator, e.g. - or !
1257
     * @param mixed $first argument
1258
     * @return mixed
1259
     */
1260
    public static function apply_unary_operator($op, $input) {
1261
        // Abort with nice error message, if argument should be numeric but is not.
1262
        if ($op === '_' || $op === '~') {
1,343✔
1263
            self::abort_if_not_scalar($input, $op);
1,343✔
1264
        }
1265

1266
        $output = null;
1,343✔
1267
        switch ($op) {
1268
            // If we already know that an unary operator was requested, we accept - instead of _
1269
            // for negation.
1270
            case '-':
1,343✔
1271
            case '_':
1,343✔
1272
                $output = (-1) * $input;
1,343✔
1273
                break;
1,343✔
NEW
1274
            case '!':
×
NEW
1275
                $output = ($input ? 0 : 1);
×
NEW
1276
                break;
×
NEW
1277
            case '~':
×
NEW
1278
                $output = ~ $input;
×
NEW
1279
                break;
×
1280
        }
1281
        return $output;
1,343✔
1282
    }
1283

1284
    /**
1285
     * Apply a binary operator to two given arguments.
1286
     *
1287
     * @param string $op operator, e.g. + or **
1288
     * @param mixed $first first argument
1289
     * @param mixed $second second argument
1290
     * @return mixed
1291
     */
1292
    public static function apply_binary_operator($op, $first, $second) {
1293
        // Binary operators that need numeric input. Note: + is not here, because it
1294
        // can be used to concatenate strings.
1295
        $neednumeric = ['**', '*', '/', '%', '-', '<<', '>>', '&', '^', '|', '&&', '||'];
187✔
1296

1297
        // Abort with nice error message, if arguments should be numeric but are not.
1298
        if (in_array($op, $neednumeric)) {
187✔
1299
            self::abort_if_not_scalar($first, $op);
102✔
1300
            self::abort_if_not_scalar($second, $op);
68✔
1301
        }
1302

1303
        $output = null;
153✔
1304
        // Many results will be numeric, so we set this as the default here.
1305
        switch ($op) {
1306
            case '**':
153✔
1307
                // Only check for equality, because 0.0 == 0 but not 0.0 === 0.
1308
                if ($first == 0 && $second == 0) {
17✔
NEW
1309
                    self::die('error_power_zerozero');
×
1310
                }
1311
                if ($first == 0 && $second < 0) {
17✔
NEW
1312
                    self::die('error_power_negbase_expzero');
×
1313
                }
1314
                if ($first < 0 && intval($second) != $second) {
17✔
NEW
1315
                    self::die('error_power_negbase_expfrac');
×
1316
                }
1317
                $output = $first ** $second;
17✔
1318
                break;
17✔
1319
            case '*':
136✔
1320
                $output = $first * $second;
34✔
1321
                break;
34✔
1322
            case '/':
102✔
1323
            case '%':
102✔
NEW
1324
                if ($second == 0) {
×
NEW
1325
                    self::die('error_divzero');
×
1326
                }
NEW
1327
                if ($op === '/') {
×
NEW
1328
                    $output = $first / $second;
×
1329
                } else {
NEW
1330
                    $output = $first % $second;
×
1331
                }
NEW
1332
                break;
×
1333
            case '+':
102✔
1334
                // If at least one operand is a string, we use concatenation instead
1335
                // of addition.
1336
                if (is_string($first) || is_string($second)) {
68✔
1337
                    self::abort_if_not_scalar($first, '+', false);
17✔
1338
                    self::abort_if_not_scalar($second, '+', false);
17✔
1339
                    $output = $first . $second;
17✔
1340
                    break;
17✔
1341
                }
1342
                // In all other cases, addition must (currently) be numeric, so we abort
1343
                // if the arguments are not numbers.
1344
                self::abort_if_not_scalar($first, '+');
51✔
1345
                self::abort_if_not_scalar($second, '+');
51✔
1346
                $output = $first + $second;
51✔
1347
                break;
51✔
1348
            case '-':
34✔
1349
                $output = $first - $second;
17✔
1350
                break;
17✔
1351
            case '<<':
17✔
1352
            case '>>':
17✔
NEW
1353
                if (intval($first) != $first || intval($second) != $second) {
×
NEW
1354
                    self::die('error_bitshift_integer');
×
1355
                }
NEW
1356
                if ($second < 0) {
×
NEW
1357
                    self::die('error_bitshift_negative', $second);
×
1358
                }
NEW
1359
                if ($op === '<<') {
×
NEW
1360
                    $output = (int)$first << (int)$second;
×
1361
                } else {
NEW
1362
                    $output = (int)$first >> (int)$second;
×
1363
                }
NEW
1364
                break;
×
1365
            case '&':
17✔
NEW
1366
                if (intval($first) != $first || intval($second) != $second) {
×
NEW
1367
                    self::die('error_bitwand_integer');
×
1368
                }
NEW
1369
                $output = $first & $second;
×
NEW
1370
                break;
×
1371
            case '^':
17✔
NEW
1372
                if (intval($first) != $first || intval($second) != $second) {
×
NEW
1373
                    self::die('error_bitwxor_integer');
×
1374
                }
NEW
1375
                $output = $first ^ $second;
×
NEW
1376
                break;
×
1377
            case '|':
17✔
NEW
1378
                if (intval($first) != $first || intval($second) != $second) {
×
NEW
1379
                    self::die('error_bitwor_integer');
×
1380
                }
NEW
1381
                $output = $first | $second;
×
NEW
1382
                break;
×
1383
            case '&&':
17✔
NEW
1384
                $output = ($first && $second ? 1 : 0);
×
NEW
1385
                break;
×
1386
            case '||':
17✔
NEW
1387
                $output = ($first || $second ? 1 : 0);
×
NEW
1388
                break;
×
1389
            case '<':
17✔
NEW
1390
                $output = ($first < $second ? 1 : 0);
×
NEW
1391
                break;
×
1392
            case '>':
17✔
NEW
1393
                $output = ($first > $second ? 1 : 0);
×
NEW
1394
                break;
×
1395
            case '==':
17✔
1396
                $output = ($first == $second ? 1 : 0);
17✔
1397
                break;
17✔
NEW
1398
            case '>=':
×
NEW
1399
                $output = ($first >= $second ? 1 : 0);
×
NEW
1400
                break;
×
NEW
1401
            case '<=':
×
NEW
1402
                $output = ($first <= $second ? 1 : 0);
×
NEW
1403
                break;
×
NEW
1404
            case '!=':
×
NEW
1405
                $output = ($first != $second ? 1 : 0);
×
NEW
1406
                break;
×
1407
        }
1408
        // One last safety check: numeric results must not be NAN or INF.
1409
        // This should never be triggered.
1410
        if (is_numeric($output) && (is_nan($output) || is_infinite($output))) {
153✔
NEW
1411
            self::die('error_evaluation_unknown_nan_inf', $op);
×
1412
        }
1413
        return $output;
153✔
1414
    }
1415

1416
    /**
1417
     * Check whether a given value is numeric and, if desired, meets other criteria like
1418
     * being non-negative or integer etc. If the conditions are not met, the function will
1419
     * throw an Exception. If the value is valid, the function will return a float or int,
1420
     * depending on the given conditions.
1421
     *
1422
     * @param mixed $n
1423
     * @param string $message
1424
     * @param int $additionalcondition
1425
     * @throws Exception
1426
     * @return int|float
1427
     */
1428
    public static function assure_numeric($n, string $message = '', int $additionalcondition = self::NONE) {
1429
        // For compatibility with PHP 7.4: check if it is a string. If it is, remove trailing
1430
        // space before trying to convert to number.
1431
        if (is_string($n)) {
4,420✔
1432
            $n = trim($n);
289✔
1433
        }
1434
        if (is_numeric($n) === false) {
4,420✔
1435
            throw new Exception($message);
187✔
1436
        }
1437
        if ($additionalcondition & self::NON_NEGATIVE) {
4,250✔
1438
            if ($n < 0) {
901✔
1439
                throw new Exception($message);
85✔
1440
            }
1441
        }
1442
        if ($additionalcondition & self::NEGATIVE) {
4,182✔
1443
            if ($n >= 0) {
204✔
1444
                throw new Exception($message);
153✔
1445
            }
1446
        }
1447
        if ($additionalcondition & self::POSITIVE) {
4,029✔
1448
            if ($n <= 0) {
1,071✔
1449
                throw new Exception($message);
136✔
1450
            }
1451
        }
1452
        if ($additionalcondition & self::NON_ZERO) {
3,927✔
1453
            if ($n == 0) {
578✔
1454
                throw new Exception($message);
68✔
1455
            }
1456
        }
1457
        if ($additionalcondition & self::INTEGER) {
3,876✔
1458
            if ($n - intval($n) != 0) {
2,805✔
1459
                throw new Exception($message);
238✔
1460
            }
1461
            return intval($n);
2,635✔
1462
        }
1463
        return floatval($n);
1,513✔
1464
    }
1465

1466
    /**
1467
     * Check whether a given array contains only numbers.
1468
     *
1469
     * @param mixed $ar
1470
     * @param bool $acceptempty
1471
     * @return bool
1472
     */
1473
    public static function is_numeric_array($ar, bool $acceptempty = true): bool {
1474
        if (!is_array($ar)) {
799✔
1475
            return false;
187✔
1476
        }
1477
        if (!$acceptempty && count($ar) === 0) {
646✔
1478
            return false;
17✔
1479
        }
1480
        foreach ($ar as $element) {
646✔
1481
            if (!is_numeric($element)) {
646✔
1482
                return false;
136✔
1483
            }
1484
        }
1485
        return true;
612✔
1486
    }
1487

1488
    /**
1489
     * Throw an Exception, fetching the localized string $identifier from the language file
1490
     * via Moodle's get_string() function.
1491
     *
1492
     * @param string $identifier identifier for the localized string
1493
     * @param string|object|array $a additional (third) parameter passed to get_string
1494
     * @throws Exception
1495
     */
1496
    private static function die(string $identifier, $a = null): void {
1497
        throw new Exception(get_string($identifier, 'qtype_formulas', $a));
833✔
1498
    }
1499

1500
}
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