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

FormulasQuestion / moodle-qtype_formulas / 15537037899

09 Jun 2025 02:31PM UTC coverage: 97.371% (-0.06%) from 97.435%
15537037899

Pull #228

github

web-flow
Merge b377a6497 into 3c12eaad6
Pull Request #228: Use localised numbers

16 of 19 new or added lines in 5 files covered. (84.21%)

1 existing line in 1 file now uncovered.

4037 of 4146 relevant lines covered (97.37%)

1580.36 hits per line

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

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

17
namespace qtype_formulas\local;
18

19
use qtype_formulas;
20

21
/**
22
 * Parser for answer expressions for qtype_formulas
23
 *
24
 * @package    qtype_formulas
25
 * @copyright  2022 Philipp Imhof
26
 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
27
 */
28
class answer_parser extends parser {
29
    /**
30
     * Create a parser for student answers. This class does additional filtering (e. g. block
31
     * forbidden operators) and syntax checking according to the answer type. It also translates
32
     * the ^ symbol to the ** operator.
33
     *
34
     * @param string|array $tokenlist list of tokens as returned from the lexer or input string
35
     * @param array $knownvariables
36
     * @param bool $caretmeanspower whether ^ should be interpreted as exponentiation operator
37
     * @param bool $formodelanswer whether we are parsing a teacher's model answer (thus allowing \ prefix)
38
     */
39
    public function __construct($tokenlist, array $knownvariables = [], bool $caretmeanspower = true,
40
            bool $formodelanswer = false) {
41
        // If the input is given as a string, run it through the lexer first. Also, if we aren't parsing
42
        // a model answer (coming from the teacher), we replace all commas by points, because there is no
43
        // situation where the comma would be a valid character. Replacement is only done if the admin
44
        // settings allow the use of the decimal comma.
45
        if (is_string($tokenlist)) {
9,471✔
46
            if (!$formodelanswer && get_config('qtype_formulas', 'allowdecimalcomma')) {
9,471✔
NEW
47
                $tokenlist = str_replace(',', '.', $tokenlist);
×
48
            }
49
            $lexer = new lexer($tokenlist);
9,471✔
50
            $tokenlist = $lexer->get_tokens();
9,471✔
51
        }
52

53
        foreach ($tokenlist as $token) {
9,471✔
54
            // In the context of student answers, the caret (^) *always* means exponentiation (**) instead
55
            // of XOR. In model answers entered by the teacher, the caret *only* means exponentiation
56
            // for algebraic formulas, but not for the other answer types.
57
            if ($caretmeanspower) {
9,303✔
58
                if ($token->type === token::OPERATOR && $token->value === '^') {
9,303✔
59
                    $token->value = '**';
1,701✔
60
                }
61
            }
62

63
            // Students are not allowed to use the PREFIX operator.
64
            if (!$formodelanswer && $token->type === token::PREFIX) {
9,303✔
65
                $this->die(get_string('error_prefix', 'qtype_formulas'), $token);
294✔
66
            }
67
        }
68

69
        // Once this is done, we can parse the expression normally.
70
        parent::__construct($tokenlist, $knownvariables);
9,471✔
71
    }
72

73
    /**
74
     * Perform the right check according to a given answer type.
75
     *
76
     * @param int $type the answer type, a constant from the qtype_formulas class
77
     * @return bool
78
     */
79
    public function is_acceptable_for_answertype(int $type): bool {
80
        if ($type === qtype_formulas::ANSWER_TYPE_NUMBER) {
8,169✔
81
            return $this->is_acceptable_number();
1,491✔
82
        }
83

84
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERIC) {
6,678✔
85
            return $this->is_acceptable_numeric();
1,281✔
86
        }
87

88
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA) {
5,397✔
89
            return $this->is_acceptable_numerical_formula();
1,554✔
90
        }
91

92
        if ($type === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
3,864✔
93
            return $this->is_acceptable_algebraic_formula();
3,843✔
94
        }
95

96
        // If an invalid answer type has been specified, we simply return false.
97
        return false;
21✔
98
    }
99

100
    /**
101
     * Check whether the given answer contains only valid tokens for the answer type NUMBER, i. e.
102
     * - just a number, possibly with a decimal point
103
     * - no operators, except unary + or - at start
104
     * - possibly followed by e/E (maybe followed by + or -) plus an integer
105
     *
106
     * @return bool
107
     */
108
    private function is_acceptable_number(): bool {
109
        // The statement list must contain exactly one expression object.
110
        if (count($this->statements) !== 1) {
8,148✔
111
            return false;
840✔
112
        }
113

114
        $answertokens = $this->statements[0]->body;
7,308✔
115

116
        // The first element of the answer expression must be a token of type NUMBER or
117
        // CONSTANT, e.g. pi or π; we currently do not have other named constants.
118
        // Note: if the user has entered -5, this has now become [5, _].
119
        if (!in_array($answertokens[0]->type, [token::NUMBER, token::CONSTANT])) {
7,308✔
120
            return false;
1,512✔
121
        }
122
        array_shift($answertokens);
5,817✔
123

124
        // If there are no tokens left, everything is fine.
125
        if (empty($answertokens)) {
5,817✔
126
            return true;
1,407✔
127
        }
128

129
        // We accept one more token: an unary minus sign (OPERATOR '_'). An unary plus sign
130
        // would be possible, but it would already have been dropped. For backwards compatibility,
131
        // we do not accept multiple unary minus signs.
132
        if (count($answertokens) > 1) {
4,431✔
133
            return false;
3,759✔
134
        }
135
        $token = $answertokens[0];
693✔
136
        return ($token->type === token::OPERATOR && $token->value === '_');
693✔
137
    }
138

139
    /**
140
     * Check whether the given answer contains only valid tokens for the answer type NUMERIC, i. e.
141
     * - numbers
142
     * - operators +, -, *, /, ** or ^
143
     * - round parens ( and )
144
     * - pi or pi() or π
145
     * - no functions
146
     * - no variables
147
     *
148
     * @return bool
149
     */
150
    private function is_acceptable_numeric(): bool {
151
        // If it's a valid number expression, we have nothing to do.
152
        if ($this->is_acceptable_number()) {
5,712✔
153
            return true;
378✔
154
        }
155

156
        // The statement list must contain exactly one expression object.
157
        if (count($this->statements) !== 1) {
5,334✔
158
            return false;
735✔
159
        }
160

161
        $answertokens = $this->statements[0]->body;
4,599✔
162

163
        // Iterate over all tokens.
164
        foreach ($answertokens as $token) {
4,599✔
165
            // If we find a FUNCTION or VARIABLE token, we can stop, because those are not
166
            // allowed in the numeric answer type.
167
            if ($token->type === token::FUNCTION || $token->type === token::VARIABLE) {
4,599✔
168
                return false;
2,961✔
169
            }
170
            // If it is an OPERATOR, it has to be +, -, *, /, ^, ** or the unary minus _.
171
            $allowedoperators = ['+', '-', '*', '/', '^', '**', '_'];
3,318✔
172
            if ($token->type === token::OPERATOR && !in_array($token->value, $allowedoperators)) {
3,318✔
173
                return false;
168✔
174
            }
175
            $isparen = ($token->type & token::ANY_PAREN);
3,318✔
176
            // Only round parentheses are allowed.
177
            if ($isparen && !in_array($token->value, ['(', ')'])) {
3,318✔
178
                return false;
126✔
179
            }
180
        }
181

182
        // Still here? Then it's all good.
183
        return true;
1,344✔
184
    }
185

186
    /**
187
     * Check whether the given answer contains only valid tokens for the answer type NUMERICAL_FORMULA, i. e.
188
     * - numerical expression
189
     * - plus functions: sin, cos, tan, asin, acos, atan (but not atan2), sinh, cosh, tanh, asinh, acosh, atanh
190
     * - plus functions: sqrt, exp, log10, ln, lb, lg (but not log)
191
     * - plus functions: abs, ceil, floor
192
     * - plus functions: fact
193
     * - no variables
194
     *
195
     * @return bool
196
     */
197
    private function is_acceptable_numerical_formula(): bool {
198
        if ($this->is_acceptable_number() || $this->is_acceptable_numeric()) {
1,554✔
199
            return true;
966✔
200
        }
201

202
        // Checking whether the expression is valid as an algebraic formula, but with variables
203
        // being disallowed. This also makes sure that there is one single statement.
204
        if (!$this->is_acceptable_algebraic_formula(true)) {
588✔
205
            return false;
420✔
206
        }
207

208
        // Still here? Then it's all good.
209
        return true;
189✔
210
    }
211

212
    /**
213
     * Check whether the given answer contains only valid tokens for the answer type ALGEBRAIC, i. e.
214
     * - everything allowed for numerical formulas
215
     * - modulo operator %
216
     * - variables (TODO: maybe only allow registered variables, would avoid student mistake "ab" instead of "a b" or "a*b")
217
     *
218
     * @param bool $fornumericalformula whether we disallow the usage of variables and the PREFIX operator
219
     * @return bool
220
     */
221
    private function is_acceptable_algebraic_formula(bool $fornumericalformula = false): bool {
222
        if ($this->is_acceptable_number() || $this->is_acceptable_numeric()) {
4,410✔
223
            return true;
861✔
224
        }
225

226
        // The statement list must contain exactly one expression object.
227
        if (count($this->statements) !== 1) {
3,549✔
228
            return false;
609✔
229
        }
230

231
        $answertokens = $this->statements[0]->body;
2,940✔
232

233
        // Iterate over all tokens. If we find a FUNCTION token, we check whether it is in the white list.
234
        // Note: We currently restrict the list of allowed functions to functions with only one argument.
235
        // That assures full backwards compatibility, without limiting our future possibilities w.r.t. the
236
        // usage of the comma as a decimal separator. We do not currently allow the 'log' function (which
237
        // would mean the natural logarithm), because it was not allowed in earlier versions, creates ambiguity
238
        // and would accept two arguments.
239
        $functionwhitelist = [
2,940✔
240
            'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
2,940✔
241
            'sqrt', 'exp', 'log10', 'lb', 'ln', 'lg', 'abs', 'ceil', 'floor', 'fact',
2,940✔
242
        ];
2,940✔
243
        $operatorwhitelist = ['+', '_', '-', '/', '*', '**', '^', '%'];
2,940✔
244
        foreach ($answertokens as $token) {
2,940✔
245
            // Cut short, if it is a NUMBER or CONSTANT token.
246
            if (in_array($token->type, [token::NUMBER, token::CONSTANT])) {
2,940✔
247
                continue;
2,037✔
248
            }
249
            if ($token->type === token::VARIABLE) {
2,940✔
250
                if ($fornumericalformula) {
2,205✔
251
                    return false;
210✔
252
                }
253
                // If a student writes 'sin 30', the token 'sin' will be classified as a variable,
254
                // because it is not followed by parentheses. For all numerical answer types, this
255
                // will invalidate the answer. Hence, the student will see a warning and can correct
256
                // their answer to 'sin(30)', which is what they probably meant. However, in algebraic
257
                // formulas, students are allowed to use variables, so the expression is syntactically
258
                // valid and will be interpreted as 'sin*30' which is most certainly wrong. The
259
                // following check will make sure that students do not use function names as variables.
260
                if (self::is_valid_function_name($token->value)) {
2,016✔
261
                    return false;
231✔
262
                }
263
                /* TODO: maybe we should reject unknown variables, because that avoids mistakes
264
                         like student writing a(x+y) = ax + ay instead of a*x or a x.
265
                if (!$this->is_known_variable($token)) {
266
                    return false;
267
                }*/
268
            }
269
            if ($token->type === token::FUNCTION && !in_array($token->value, $functionwhitelist)) {
2,625✔
270
                return false;
63✔
271
            }
272
            if ($token->type === token::OPERATOR && !in_array($token->value, $operatorwhitelist)) {
2,583✔
273
                return false;
336✔
274
            }
275
        }
276

277
        // Still here? Then let's check the syntax.
278
        return $this->is_valid_syntax();
2,163✔
279
    }
280

281
    /**
282
     * This function determines the index where the numeric part ends and the unit part begins, e.g.
283
     * for the answer "1.5e3 m^2", that index would be 6.
284
     * We know that the student cannot (legally) use variables in their answers of type number, numeric
285
     * or numerical formula. Also, we know that units will be classified as variables. Thus, we can
286
     * walk through the list of tokens until we reach the first "variable" (actually a unit) and then
287
     * we know where the unit starts.
288
     *
289
     * @return int
290
     */
291
    public function find_start_of_units(): int {
292
        foreach ($this->tokenlist as $token) {
1,302✔
293
            if ($token->type === token::VARIABLE) {
1,302✔
294
                return $token->column - 1;
1,197✔
295
            }
296
        }
297
        // Still here? That means there is no unit, so it starts very, very far away...
298
        return PHP_INT_MAX;
105✔
299
    }
300

301
    /**
302
     * Iterate over all tokens and check whether the expression is *syntactically* valid.
303
     * Note that this does not necessarily mean that the expression can be evaluated:
304
     * - sqrt(-3) is syntactically valid, but it cannot be calculated
305
     * - asin(x*y) is syntactically valid, but cannot be evaluated if abs(x*y) > 1
306
     * - a/(b-b) is syntactically valid, but it cannot be evaluated
307
     * - a-*b is syntactically invalid, because the operators cannot be chained that way
308
     *
309
     * @return bool
310
     */
311
    private function is_valid_syntax(): bool {
312
        $tokens = $this->statements[0]->body;
2,163✔
313

314
        // Iterate over all tokens. Push literals (strings, number) and variables on the stack.
315
        // Operators and functions will consume them, but not evaluate anything. In the end, there
316
        // should be only one single element on the stack.
317
        $stack = [];
2,163✔
318
        foreach ($tokens as $token) {
2,163✔
319
            if (in_array($token->type, [token::STRING, token::NUMBER, token::VARIABLE, token::CONSTANT])) {
2,163✔
320
                $stack[] = $token->value;
2,163✔
321
            }
322
            if ($token->type === token::OPERATOR) {
2,163✔
323
                // Check whether the operator is unary. We also include operators that are not
324
                // actually allowed in a student's answer. Unary operators would operate on
325
                // the last token on the stack, but as we do not evaluate anything, we just
326
                // drop them.
327
                if (in_array($token->value, ['_', '!', '~'])) {
2,016✔
328
                    continue;
231✔
329
                }
330
                // All other operators are binary, because the student cannot use the ternary
331
                // operator in their answer. Also, they are not allowed other than round parens,
332
                // so there can be no %%rangebuild or similar pseudo-operators in the queue.
333
                // A binary operator would pop the two top elements, do its magic and then push
334
                // the result on the stack. As we do not evaluate anything, we simply drop the top
335
                // element.
336
                array_pop($stack);
2,016✔
337
            }
338
            // For functions, the top element on the stack (always a number literal) will indicate
339
            // the number of arguments to consume. So we pop that element plus one less than what
340
            // it indicates, meaning we actually drop exactly the number of elements indicated
341
            // by that element.
342
            if ($token->type === token::FUNCTION) {
2,163✔
343
                $n = end($stack);
903✔
344
                // If the top element on the stack was not a number, there must have been a syntax
345
                // error. This should not happen anymore, but it does no harm to keep the fallback.
346
                if (!is_numeric($n)) {
903✔
347
                    return false;
×
348
                }
349
                $stack = array_slice($stack, 0, -$n);
903✔
350
            }
351
        }
352

353
        return (count($stack) === 1);
2,163✔
354
    }
355

356
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc