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

FormulasQuestion / moodle-qtype_formulas / 17019138792

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

Pull #264

github

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

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

12 existing lines in 5 files now uncovered.

4381 of 4498 relevant lines covered (97.4%)

1618.81 hits per line

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

97.3
/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,828✔
46
            if (!$formodelanswer && get_config('qtype_formulas', 'allowdecimalcomma')) {
9,828✔
47
                $tokenlist = str_replace(',', '.', $tokenlist);
21✔
48
            }
49
            $lexer = new lexer($tokenlist);
9,828✔
50
            $tokenlist = $lexer->get_tokens();
9,828✔
51
        }
52

53
        foreach ($tokenlist as $token) {
9,828✔
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,639✔
58
                if ($token->type === token::OPERATOR && $token->value === '^') {
9,639✔
59
                    $token->value = '**';
1,176✔
60
                }
61
            }
62

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

69
        // If we only have one single token and it is an empty string, we set it to the $EMPTY token.
70
        $firsttoken = reset($tokenlist);
9,828✔
71
        if (count($tokenlist) === 1 && $firsttoken->value === '') {
9,828✔
72
            // FIXME: temporarily disabling this
73
            // $tokenlist[0] = new token(token::EMPTY, '$EMPTY', $firsttoken->row, $firsttoken->column);
74
        }
75

76
        // Once this is done, we can parse the expression normally.
77
        parent::__construct($tokenlist, $knownvariables);
9,828✔
78
    }
79

80
    /**
81
     * Perform the right check according to a given answer type.
82
     *
83
     * @param int $type the answer type, a constant from the qtype_formulas class
84
     * @return bool
85
     */
86
    public function is_acceptable_for_answertype(int $type, bool $acceptempty = false): bool {
87
        // An empty answer is never acceptable regardless of the answer type, unless empty fields
88
        // are explicitly allowed for a question's part.
89
        // FIXME: this can be removed later
90
        if (empty($this->tokenlist)) {
8,505✔
91
            return $acceptempty;
3,003✔
92
        }
93
        $firsttoken = reset($this->tokenlist);
5,502✔
94
        if (count($this->tokenlist) === 1 && $firsttoken->type === token::EMPTY) {
5,502✔
NEW
UNCOV
95
            return $acceptempty;
×
96
        }
97

98
        if ($type === qtype_formulas::ANSWER_TYPE_NUMBER) {
5,502✔
99
            return $this->is_acceptable_number();
1,449✔
100
        }
101

102
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERIC) {
4,053✔
103
            return $this->is_acceptable_numeric();
1,218✔
104
        }
105

106
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA) {
2,835✔
107
            return $this->is_acceptable_numerical_formula();
1,491✔
108
        }
109

110
        if ($type === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
1,365✔
111
            if (count($this->tokenlist) === 1 && $this->tokenlist[0]->value === '') {
1,344✔
NEW
UNCOV
112
                return $acceptempty;
×
113
            }
114
            return $this->is_acceptable_algebraic_formula();
1,344✔
115
        }
116

117
        // If an invalid answer type has been specified, we simply return false.
118
        return false;
21✔
119
    }
120

121
    /**
122
     * Check whether the given answer contains only valid tokens for the answer type NUMBER, i. e.
123
     * - just a number, possibly with a decimal point
124
     * - no operators, except unary + or - at start
125
     * - possibly followed by e/E (maybe followed by + or -) plus an integer
126
     *
127
     * @return bool
128
     */
129
    private function is_acceptable_number(): bool {
130
        // The statement list must contain exactly one expression object.
131
        if (count($this->statements) !== 1) {
5,481✔
132
            return false;
63✔
133
        }
134

135
        $answertokens = $this->statements[0]->body;
5,418✔
136

137
        // The first element of the answer expression must be a token of type NUMBER or
138
        // CONSTANT, e.g. pi or π; we currently do not have other named constants.
139
        // Note: if the user has entered -5, this has now become [5, _].
140
        if (!in_array($answertokens[0]->type, [token::NUMBER, token::CONSTANT])) {
5,418✔
141
            return false;
1,029✔
142
        }
143
        array_shift($answertokens);
4,431✔
144

145
        // If there are no tokens left, everything is fine.
146
        if (empty($answertokens)) {
4,431✔
147
            return true;
1,113✔
148
        }
149

150
        // We accept one more token: an unary minus sign (OPERATOR '_'). An unary plus sign
151
        // would be possible, but it would already have been dropped. For backwards compatibility,
152
        // we do not accept multiple unary minus signs.
153
        if (count($answertokens) > 1) {
3,339✔
154
            return false;
2,814✔
155
        }
156
        $token = $answertokens[0];
546✔
157
        return ($token->type === token::OPERATOR && $token->value === '_');
546✔
158
    }
159

160
    /**
161
     * Check whether the given answer contains only valid tokens for the answer type NUMERIC, i. e.
162
     * - numbers
163
     * - operators +, -, *, /, ** or ^
164
     * - round parens ( and )
165
     * - pi or pi() or π
166
     * - no functions
167
     * - no variables
168
     *
169
     * @return bool
170
     */
171
    private function is_acceptable_numeric(): bool {
172
        // If it's a valid number expression, we have nothing to do.
173
        if ($this->is_acceptable_number()) {
3,528✔
174
            return true;
378✔
175
        }
176

177
        // The statement list must contain exactly one expression object.
178
        if (count($this->statements) !== 1) {
3,150✔
179
            return false;
42✔
180
        }
181

182
        $answertokens = $this->statements[0]->body;
3,108✔
183

184
        // Iterate over all tokens.
185
        foreach ($answertokens as $token) {
3,108✔
186
            // If we find a FUNCTION or VARIABLE token, we can stop, because those are not
187
            // allowed in the numeric answer type.
188
            if ($token->type === token::FUNCTION || $token->type === token::VARIABLE) {
3,108✔
189
                return false;
1,785✔
190
            }
191
            // If we find a STRING literal, we can stop, because those are not
192
            // allowed in the numeric answer type.
193
            if ($token->type === token::STRING) {
2,415✔
194
                return false;
84✔
195
            }
196
            // If it is an OPERATOR, it has to be +, -, *, /, ^, ** or the unary minus _.
197
            $allowedoperators = ['+', '-', '*', '/', '^', '**', '_'];
2,331✔
198
            if ($token->type === token::OPERATOR && !in_array($token->value, $allowedoperators)) {
2,331✔
199
                return false;
84✔
200
            }
201
            $isparen = ($token->type & token::ANY_PAREN);
2,331✔
202
            // Only round parentheses are allowed.
203
            if ($isparen && !in_array($token->value, ['(', ')'])) {
2,331✔
204
                return false;
84✔
205
            }
206
        }
207

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

212
    /**
213
     * Check whether the given answer contains only valid tokens for the answer type NUMERICAL_FORMULA, i. e.
214
     * - numerical expression
215
     * - plus functions: sin, cos, tan, asin, acos, atan (but not atan2), sinh, cosh, tanh, asinh, acosh, atanh
216
     * - plus functions: sqrt, exp, log10, ln, lb, lg (but not log)
217
     * - plus functions: abs, ceil, floor
218
     * - plus functions: fact
219
     * - no variables
220
     *
221
     * @return bool
222
     */
223
    private function is_acceptable_numerical_formula(): bool {
224
        if ($this->is_acceptable_number() || $this->is_acceptable_numeric()) {
1,491✔
225
            return true;
966✔
226
        }
227

228
        // Checking whether the expression is valid as an algebraic formula, but with variables
229
        // being disallowed. This also makes sure that there is one single statement.
230
        if (!$this->is_acceptable_algebraic_formula(true)) {
525✔
231
            return false;
357✔
232
        }
233

234
        // Still here? Then it's all good.
235
        return true;
189✔
236
    }
237

238
    /**
239
     * Check whether the given answer contains only valid tokens for the answer type ALGEBRAIC, i. e.
240
     * - everything allowed for numerical formulas
241
     * - modulo operator %
242
     * - variables (TODO: maybe only allow registered variables, would avoid student mistake "ab" instead of "a b" or "a*b")
243
     *
244
     * @param bool $fornumericalformula whether we disallow the usage of variables and the PREFIX operator
245
     * @return bool
246
     */
247
    private function is_acceptable_algebraic_formula(bool $fornumericalformula = false): bool {
248
        if ($this->is_acceptable_number() || $this->is_acceptable_numeric()) {
1,848✔
249
            return true;
147✔
250
        }
251

252
        // The statement list must contain exactly one expression object.
253
        if (count($this->statements) !== 1) {
1,701✔
254
            return false;
21✔
255
        }
256

257
        $answertokens = $this->statements[0]->body;
1,680✔
258

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

281
            if ($token->type === token::VARIABLE) {
1,638✔
282
                if ($fornumericalformula) {
1,239✔
283
                    return false;
210✔
284
                }
285
                // If a student writes 'sin 30', the token 'sin' will be classified as a variable,
286
                // because it is not followed by parentheses. For all numerical answer types, this
287
                // will invalidate the answer. Hence, the student will see a warning and can correct
288
                // their answer to 'sin(30)', which is what they probably meant. However, in algebraic
289
                // formulas, students are allowed to use variables, so the expression is syntactically
290
                // valid and will be interpreted as 'sin*30' which is most certainly wrong. The
291
                // following check will make sure that students do not use function names as variables.
292
                if (self::is_valid_function_name($token->value)) {
1,050✔
293
                    return false;
105✔
294
                }
295
                /* TODO: maybe we should reject unknown variables, because that avoids mistakes
296
                         like student writing a(x+y) = ax + ay instead of a*x or a x.
297
                if (!$this->is_known_variable($token)) {
298
                    return false;
299
                }*/
300
            }
301
            if ($token->type === token::FUNCTION && !in_array($token->value, $functionwhitelist)) {
1,407✔
302
                return false;
42✔
303
            }
304
            if ($token->type === token::OPERATOR && !in_array($token->value, $operatorwhitelist)) {
1,386✔
305
                return false;
189✔
306
            }
307
        }
308

309
        // Still here? Then let's check the syntax.
310
        return $this->is_valid_syntax();
1,155✔
311
    }
312

313
    /**
314
     * This function determines the index where the numeric part ends and the unit part begins, e.g.
315
     * for the answer "1.5e3 m^2", that index would be 6.
316
     * We know that the student cannot (legally) use variables in their answers of type number, numeric
317
     * or numerical formula. Also, we know that units will be classified as variables. Thus, we can
318
     * walk through the list of tokens until we reach the first "variable" (actually a unit) and then
319
     * we know where the unit starts.
320
     *
321
     * @return int
322
     */
323
    public function find_start_of_units(): int {
324
        foreach ($this->tokenlist as $token) {
1,302✔
325
            if ($token->type === token::VARIABLE) {
1,302✔
326
                return $token->column - 1;
1,197✔
327
            }
328
        }
329
        // Still here? That means there is no unit, so it starts very, very far away...
330
        return PHP_INT_MAX;
105✔
331
    }
332

333
    /**
334
     * Iterate over all tokens and check whether the expression is *syntactically* valid.
335
     * Note that this does not necessarily mean that the expression can be evaluated:
336
     * - sqrt(-3) is syntactically valid, but it cannot be calculated
337
     * - asin(x*y) is syntactically valid, but cannot be evaluated if abs(x*y) > 1
338
     * - a/(b-b) is syntactically valid, but it cannot be evaluated
339
     * - a-*b is syntactically invalid, because the operators cannot be chained that way
340
     *
341
     * @return bool
342
     */
343
    private function is_valid_syntax(): bool {
344
        $tokens = $this->statements[0]->body;
1,155✔
345

346
        // Iterate over all tokens. Push literals (strings, number) and variables on the stack.
347
        // Operators and functions will consume them, but not evaluate anything. In the end, there
348
        // should be only one single element on the stack.
349
        $stack = [];
1,155✔
350
        foreach ($tokens as $token) {
1,155✔
351
            if (in_array($token->type, [token::STRING, token::NUMBER, token::VARIABLE, token::CONSTANT])) {
1,155✔
352
                $stack[] = $token->value;
1,155✔
353
            }
354
            if ($token->type === token::OPERATOR) {
1,155✔
355
                // Check whether the operator is unary. We also include operators that are not
356
                // actually allowed in a student's answer. Unary operators would operate on
357
                // the last token on the stack, but as we do not evaluate anything, we just
358
                // drop them.
359
                if (in_array($token->value, ['_', '!', '~'])) {
1,092✔
360
                    continue;
126✔
361
                }
362
                // All other operators are binary, because the student cannot use the ternary
363
                // operator in their answer. Also, they are not allowed other than round parens,
364
                // so there can be no %%rangebuild or similar pseudo-operators in the queue.
365
                // A binary operator would pop the two top elements, do its magic and then push
366
                // the result on the stack. As we do not evaluate anything, we simply drop the top
367
                // element.
368
                array_pop($stack);
1,092✔
369
            }
370
            // For functions, the top element on the stack (always a number literal) will indicate
371
            // the number of arguments to consume. So we pop that element plus one less than what
372
            // it indicates, meaning we actually drop exactly the number of elements indicated
373
            // by that element.
374
            if ($token->type === token::FUNCTION) {
1,155✔
375
                $n = end($stack);
504✔
376
                // If the top element on the stack was not a number, there must have been a syntax
377
                // error. This should not happen anymore, but it does no harm to keep the fallback.
378
                if (!is_numeric($n)) {
504✔
379
                    return false;
×
380
                }
381
                $stack = array_slice($stack, 0, -$n);
504✔
382
            }
383
        }
384

385
        // The element must not be the empty string. As empty() returns true for the number 0, we
386
        // check whether the element is numeric. If it is, that's fine. Also, the stack must have
387
        // exactly one element.
388
        $element = reset($stack);
1,155✔
389
        $countok = count($stack) === 1;
1,155✔
390
        $notemptystring = !empty($element) || is_numeric($element);
1,155✔
391
        return $countok && $notemptystring;
1,155✔
392
    }
393

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