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

FormulasQuestion / moodle-qtype_formulas / 24044503888

06 Apr 2026 06:21PM UTC coverage: 97.22% (-0.3%) from 97.498%
24044503888

Pull #264

github

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

80 of 92 new or added lines in 11 files covered. (86.96%)

3 existing lines in 1 file now uncovered.

4652 of 4785 relevant lines covered (97.22%)

959.31 hits per line

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

95.2
/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
    /** @var array list of operators that may exceptionally appear at the end of the input */
30
    protected $allowedoperatorsatend = ['%'];
31

32
    /**
33
     * Create a parser for student answers. This class does additional filtering (e. g. block
34
     * forbidden operators) and syntax checking according to the answer type. It also translates
35
     * the ^ symbol to the ** operator.
36
     *
37
     * @param string|array $tokenlist list of tokens as returned from the lexer or input string
38
     * @param array $knownvariables
39
     * @param bool $caretmeanspower whether ^ should be interpreted as exponentiation operator
40
     * @param bool $formodelanswer whether we are parsing a teacher's model answer (thus allowing \ prefix)
41
     */
42
    public function __construct(
43
        $tokenlist,
44
        array $knownvariables = [],
45
        bool $caretmeanspower = true,
46
        bool $formodelanswer = false
47
    ) {
48
        // If the input is given as a string, run it through the lexer first. Also, if we aren't parsing
49
        // a model answer (coming from the teacher), we replace all commas by points, because there is no
50
        // situation where the comma would be a valid character. Replacement is only done if the admin
51
        // settings allow the use of the decimal comma.
52
        if (is_string($tokenlist)) {
5,390✔
53
            if (!$formodelanswer && get_config('qtype_formulas', 'allowdecimalcomma')) {
5,390✔
54
                $tokenlist = str_replace(',', '.', $tokenlist);
11✔
55
            }
56
            $lexer = new lexer($tokenlist);
5,390✔
57
            $tokenlist = $lexer->get_tokens();
5,390✔
58
        }
59

60
        foreach ($tokenlist as $token) {
5,390✔
61
            // In the context of student answers, the caret (^) *always* means exponentiation (**) instead
62
            // of XOR. In model answers entered by the teacher, the caret *only* means exponentiation
63
            // for algebraic formulas, but not for the other answer types.
64
            if ($caretmeanspower) {
5,291✔
65
                if ($token->type === token::OPERATOR && $token->value === '^') {
5,291✔
66
                    $token->value = '**';
616✔
67
                }
68
            }
69

70
            // Students are not allowed to use the PREFIX operator.
71
            if (!$formodelanswer && $token->type === token::PREFIX) {
5,291✔
72
                $this->die(get_string('error_prefix', 'qtype_formulas'), $token);
110✔
73
            }
74

75
            // Answers must currently not contain the semicolon.
76
            if ($token->type === token::END_OF_STATEMENT) {
5,181✔
77
                $this->die(get_string('error_unexpectedtoken', 'qtype_formulas', ';'), $token);
33✔
78
            }
79
        }
80

81
        // If we only have one single token and it is an empty string, we set it to the $EMPTY token.
82
        $firsttoken = reset($tokenlist);
5,390✔
83
        if (count($tokenlist) === 1 && $firsttoken->value === '') {
5,390✔
84
            // FIXME: temporarily disabling this
85
            // $tokenlist[0] = new token(token::EMPTY, '$EMPTY', $firsttoken->row, $firsttoken->column);
86
        }
87

88
        // Once this is done, we can parse the expression normally.
89
        parent::__construct($tokenlist, $knownvariables);
5,390✔
90
    }
91

92
    /**
93
     * Perform the right check according to a given answer type.
94
     *
95
     * @param int $type the answer type, a constant from the qtype_formulas class
96
     * @return bool
97
     */
98
    public function is_acceptable_for_answertype(int $type, bool $acceptempty = false): bool {
99
        // An empty answer is never acceptable regardless of the answer type, unless empty fields
100
        // are explicitly allowed for a question's part.
101
        // FIXME: this can be removed later
102
        if (empty($this->tokenlist)) {
4,609✔
103
            return $acceptempty;
1,661✔
104
        }
105
        $firsttoken = reset($this->tokenlist);
2,948✔
106
        if (count($this->tokenlist) === 1 && $firsttoken->type === token::EMPTY) {
2,948✔
NEW
107
            return $acceptempty;
×
108
        }
109

110
        if ($type === qtype_formulas::ANSWER_TYPE_NUMBER) {
2,948✔
111
            return $this->is_acceptable_number();
781✔
112
        }
113

114
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERIC) {
2,167✔
115
            return $this->is_acceptable_numeric();
660✔
116
        }
117

118
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA) {
1,507✔
119
            return $this->is_acceptable_numerical_formula();
803✔
120
        }
121

122
        if ($type === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
715✔
123
            if (count($this->tokenlist) === 1 && $this->tokenlist[0]->value === '') {
704✔
NEW
124
                return $acceptempty;
×
125
            }
126
            return $this->is_acceptable_algebraic_formula();
704✔
127
        }
128

129
        // If an invalid answer type has been specified, we simply return false.
130
        return false;
11✔
131
    }
132

133
    /**
134
     * Check whether the given answer contains only valid tokens for the answer type NUMBER, i. e.
135
     * - just a number, possibly with a decimal point
136
     * - no operators, except unary + or - at start
137
     * - possibly followed by e/E (maybe followed by + or -) plus an integer
138
     *
139
     * @return bool
140
     */
141
    private function is_acceptable_number(): bool {
142
        // The statement list must contain exactly one expression object.
143
        if (count($this->statements) !== 1) {
2,937✔
UNCOV
144
            return false;
×
145
        }
146

147
        $answertokens = $this->statements[0]->body;
2,937✔
148

149
        // The first element of the answer expression must be a token of type NUMBER or
150
        // CONSTANT, e.g. pi or π; we currently do not have other named constants.
151
        // Note: if the user has entered -5, this has now become [5, _].
152
        if (!in_array($answertokens[0]->type, [token::NUMBER, token::CONSTANT])) {
2,937✔
153
            return false;
572✔
154
        }
155
        array_shift($answertokens);
2,387✔
156

157
        // If there are no tokens left, everything is fine.
158
        if (empty($answertokens)) {
2,387✔
159
            return true;
583✔
160
        }
161

162
        // We accept one more token: an unary minus sign (OPERATOR '_'). An unary plus sign
163
        // would be possible, but it would already have been dropped. For backwards compatibility,
164
        // we do not accept multiple unary minus signs.
165
        if (count($answertokens) > 1) {
1,815✔
166
            return false;
1,540✔
167
        }
168
        $token = $answertokens[0];
286✔
169
        return ($token->type === token::OPERATOR && $token->value === '_');
286✔
170
    }
171

172
    /**
173
     * Check whether the given answer contains only valid tokens for the answer type NUMERIC, i. e.
174
     * - numbers
175
     * - operators +, -, *, /, ** or ^
176
     * - round parens ( and )
177
     * - pi or pi() or π
178
     * - no functions
179
     * - no variables
180
     *
181
     * @return bool
182
     */
183
    private function is_acceptable_numeric(): bool {
184
        // If it's a valid number expression, we have nothing to do.
185
        if ($this->is_acceptable_number()) {
1,892✔
186
            return true;
198✔
187
        }
188

189
        // The statement list must contain exactly one expression object.
190
        if (count($this->statements) !== 1) {
1,694✔
UNCOV
191
            return false;
×
192
        }
193

194
        $answertokens = $this->statements[0]->body;
1,694✔
195

196
        // Iterate over all tokens.
197
        foreach ($answertokens as $token) {
1,694✔
198
            // If we find a FUNCTION or VARIABLE token, we can stop, because those are not
199
            // allowed in the numeric answer type.
200
            if ($token->type === token::FUNCTION || $token->type === token::VARIABLE) {
1,694✔
201
                return false;
957✔
202
            }
203
            // If we find a STRING literal, we can stop, because those are not
204
            // allowed in the numeric answer type.
205
            if ($token->type === token::STRING) {
1,331✔
206
                return false;
44✔
207
            }
208
            // If it is an OPERATOR, it has to be +, -, *, /, ^, ** or the unary minus _.
209
            $allowedoperators = ['+', '-', '*', '/', '^', '**', '_'];
1,287✔
210
            if ($token->type === token::OPERATOR && !in_array($token->value, $allowedoperators)) {
1,287✔
211
                return false;
44✔
212
            }
213
            $isparen = ($token->type & token::ANY_PAREN);
1,287✔
214
            // Only round parentheses are allowed.
215
            if ($isparen && !in_array($token->value, ['(', ')'])) {
1,287✔
216
                return false;
44✔
217
            }
218
        }
219

220
        // Still here? Then let's check the syntax.
221
        return $this->is_valid_syntax();
605✔
222
    }
223

224
    /**
225
     * Check whether the given answer contains only valid tokens for the answer type NUMERICAL_FORMULA, i. e.
226
     * - numerical expression
227
     * - plus functions: sin, cos, tan, asin, acos, atan (but not atan2), sinh, cosh, tanh, asinh, acosh, atanh
228
     * - plus functions: sqrt, exp, log10, ln, lb, lg (but not log)
229
     * - plus functions: abs, ceil, floor
230
     * - plus functions: fact
231
     * - no variables
232
     *
233
     * @return bool
234
     */
235
    private function is_acceptable_numerical_formula(): bool {
236
        if ($this->is_acceptable_number() || $this->is_acceptable_numeric()) {
803✔
237
            return true;
506✔
238
        }
239

240
        // Checking whether the expression is valid as an algebraic formula, but with variables
241
        // being disallowed. This also makes sure that there is one single statement.
242
        if (!$this->is_acceptable_algebraic_formula(true)) {
297✔
243
            return false;
209✔
244
        }
245

246
        // Still here? Then it's all good.
247
        return true;
99✔
248
    }
249

250
    /**
251
     * Check whether the given answer contains only valid tokens for the answer type ALGEBRAIC, i. e.
252
     * - everything allowed for numerical formulas
253
     * - variables (TODO: maybe only allow registered variables, would avoid student mistake "ab" instead of "a b" or "a*b")
254
     *
255
     * @param bool $fornumericalformula whether we disallow the usage of variables and the PREFIX operator
256
     * @return bool
257
     */
258
    private function is_acceptable_algebraic_formula(bool $fornumericalformula = false): bool {
259
        if ($this->is_acceptable_number() || $this->is_acceptable_numeric()) {
990✔
260
            return true;
77✔
261
        }
262

263
        // The statement list must contain exactly one expression object.
264
        if (count($this->statements) !== 1) {
913✔
UNCOV
265
            return false;
×
266
        }
267

268
        $answertokens = $this->statements[0]->body;
913✔
269

270
        // Iterate over all tokens. If we find a FUNCTION token, we check whether it is in the white list.
271
        // Note: We currently restrict the list of allowed functions to functions with only one argument.
272
        // That assures full backwards compatibility, without limiting our future possibilities w.r.t. the
273
        // usage of the comma as a decimal separator. We do not currently allow the 'log' function (which
274
        // would mean the natural logarithm), because it was not allowed in earlier versions, creates ambiguity
275
        // and would accept two arguments.
276
        $functionwhitelist = [
913✔
277
            'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'sinh', 'cosh', 'tanh', 'asinh', 'acosh', 'atanh',
913✔
278
            'sqrt', 'exp', 'log10', 'lb', 'ln', 'lg', 'abs', 'ceil', 'floor', 'fact',
913✔
279
        ];
913✔
280
        $operatorwhitelist = ['+', '_', '-', '/', '*', '**', '^'];
913✔
281
        foreach ($answertokens as $token) {
913✔
282
            // Cut short, if it is a NUMBER or CONSTANT token.
283
            if (in_array($token->type, [token::NUMBER, token::CONSTANT])) {
913✔
284
                continue;
649✔
285
            }
286
            // If we find a STRING literal and we are testing for a numerical formula, we can stop,
287
            // because those are not allowed in that case.
288
            if ($fornumericalformula && $token->type === token::STRING) {
913✔
289
                return false;
22✔
290
            }
291

292
            if ($token->type === token::VARIABLE) {
891✔
293
                if ($fornumericalformula) {
649✔
294
                    return false;
110✔
295
                }
296
                // If a student writes 'sin 30', the token 'sin' will be classified as a variable,
297
                // because it is not followed by parentheses. For all numerical answer types, this
298
                // will invalidate the answer. Hence, the student will see a warning and can correct
299
                // their answer to 'sin(30)', which is what they probably meant. However, in algebraic
300
                // formulas, students are allowed to use variables, so the expression is syntactically
301
                // valid and will be interpreted as 'sin*30' which is most certainly wrong. The
302
                // following check will make sure that students do not use function names as variables.
303
                if (self::is_valid_function_name($token->value)) {
550✔
304
                    return false;
55✔
305
                }
306
                /* TODO: maybe we should reject unknown variables, because that avoids mistakes
307
                         like student writing a(x+y) = ax + ay instead of a*x or a x.
308
                if (!$this->is_known_variable($token)) {
309
                    return false;
310
                }*/
311
            }
312
            if ($token->type === token::FUNCTION && !in_array($token->value, $functionwhitelist)) {
770✔
313
                return false;
22✔
314
            }
315
            if ($token->type === token::OPERATOR && !in_array($token->value, $operatorwhitelist)) {
759✔
316
                return false;
99✔
317
            }
318
        }
319

320
        // Still here? Then let's check the syntax.
321
        return $this->is_valid_syntax();
638✔
322
    }
323

324
    /**
325
     * This function determines the index where the numeric part ends and the unit part begins, e.g.
326
     * for the answer "1.5e3 m^2", that index would be 6.
327
     * We know that the student cannot (legally) use variables in their answers of type number, numeric
328
     * or numerical formula. Also, we know that units will be classified as variables. Thus, we can
329
     * walk through the list of tokens until we reach the first "variable" (actually a unit) and then
330
     * we know where the unit starts.
331
     *
332
     * @return int
333
     */
334
    public function find_start_of_units(): int {
335
        foreach ($this->tokenlist as $token) {
770✔
336
            $isvariable = $token->type === token::VARIABLE;
770✔
337
            // If the % sign is used, we consider it as a unit, because students are not allowed to
338
            // use the modulo operator.
339
            $ispercent = $token->type === token::OPERATOR && $token->value === '%';
770✔
340
            if ($isvariable || $ispercent) {
770✔
341
                return $token->column - 1;
715✔
342
            }
343
        }
344
        // Still here? That means there is no unit, so it starts very, very far away...
345
        return PHP_INT_MAX;
55✔
346
    }
347

348
    /**
349
     * Iterate over all tokens and check whether the expression is *syntactically* valid.
350
     * Note that this does not necessarily mean that the expression can be evaluated:
351
     * - sqrt(-3) is syntactically valid, but it cannot be calculated
352
     * - asin(x*y) is syntactically valid, but cannot be evaluated if abs(x*y) > 1
353
     * - a/(b-b) is syntactically valid, but it cannot be evaluated
354
     * - a-*b is syntactically invalid, because the operators cannot be chained that way
355
     *
356
     * @return bool
357
     */
358
    private function is_valid_syntax(): bool {
359
        $tokens = $this->statements[0]->body;
1,221✔
360

361
        // Iterate over all tokens. Push literals (strings, number) and variables on the stack.
362
        // Operators and functions will consume them, but not evaluate anything. In the end, there
363
        // should be only one single element on the stack.
364
        $stack = [];
1,221✔
365
        foreach ($tokens as $token) {
1,221✔
366
            if (self::could_be_argument($token)) {
1,221✔
367
                $stack[] = $token;
1,210✔
368
            }
369
            if ($token->type === token::OPERATOR) {
1,221✔
370
                // Check whether the operator is unary. We also include operators that are not
371
                // actually allowed in a student's answer. Unary operators would operate on
372
                // the last token on the stack, but as we do not evaluate anything, we just
373
                // drop them.
374
                if (in_array($token->value, ['_', '!', '~'])) {
1,188✔
375
                    continue;
154✔
376
                }
377
                // All other operators are binary, because the student cannot use the ternary
378
                // operator in their answer. Also, they are not allowed other than round parens,
379
                // so there can be no %%rangebuild or similar pseudo-operators in the queue.
380
                // A binary operator would pop the two top elements, do its magic and then push
381
                // the result on the stack. So we first check that the two top-most elements are
382
                // literals (string, number, constant, variable), before dropping them. Note that
383
                // we do not check whether the elements are valid input values for the operator,
384
                // e. g. we would accept two strings (or the number zero) for a division operator.
385
                $first = array_pop($stack);
1,188✔
386
                if (!self::could_be_argument($first)) {
1,188✔
387
                    return false;
11✔
388
                }
389
                // We do not pop the second argument, because we will later need to put
390
                // the "result" of the operation back onto the stack anyway.
391
                $second = end($stack);
1,177✔
392
                if ($second === false || !self::could_be_argument($second)) {
1,177✔
393
                    return false;
99✔
394
                }
395
                // Check has passed. We do not put the operator on the stack, because it has
396
                // been "consumed" by operating on the two arguments.
397
                continue;
1,111✔
398
            }
399
            // For functions, the top element on the stack (always a number literal) will indicate
400
            // the number of arguments to consume. So we pop that element plus one less than what
401
            // it indicates, meaning we actually drop exactly the number of elements indicated
402
            // by that element.
403
            if ($token->type === token::FUNCTION) {
1,210✔
404
                $n = end($stack)->value;
253✔
405
                // If the top element on the stack was not a number, there must have been a syntax
406
                // error. This should not happen anymore, but it does no harm to keep the fallback.
407
                if (!is_numeric($n)) {
253✔
408
                    return false;
×
409
                }
410
                $stack = array_slice($stack, 0, -$n);
253✔
411
            }
412
        }
413

414
        // The element must not be the empty string. As empty() returns true for the number 0, we
415
        // check whether the element is numeric. If it is, that's fine. Also, the stack must have
416
        // exactly one element.
417
        $countok = count($stack) === 1;
1,122✔
418
        $element = reset($stack);
1,122✔
419
        $value = $element instanceof token ? $element->value : null;
1,122✔
420
        $notemptystring = !empty($value) || is_numeric($value);
1,122✔
421
        return $countok && $notemptystring;
1,122✔
422
    }
423

424
    /**
425
     * Check whether a given token can be used as an argument to a function or an operator,
426
     * i. e. whether it is a literal (string, number, constant) or a variable that could
427
     * contain a literal.
428
     *
429
     * @param ?token $token the token to be checked, null is allowed
430
     * @return bool
431
     */
432
    private static function could_be_argument(?token $token): bool {
433
        // TODO: We can use the null-safe operator once we drop support for PHP 7.4.
434
        if ($token === null) {
1,221✔
435
            return false;
11✔
436
        }
437
        return in_array($token->type, [token::STRING, token::NUMBER, token::VARIABLE, token::CONSTANT]);
1,221✔
438
    }
439
}
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