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

FormulasQuestion / moodle-qtype_formulas / 14663698887

25 Apr 2025 11:37AM UTC coverage: 95.959% (+0.1%) from 95.81%
14663698887

push

github

web-flow
rewrite on-the-fly validation for answers and units (#163)


This also adds new MathJax rendering of student answers and units in the teacher's edit form.

245 of 250 new or added lines in 7 files covered. (98.0%)

4013 of 4182 relevant lines covered (95.96%)

1506.0 hits per line

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

98.92
/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.
42
        if (is_string($tokenlist)) {
9,450✔
43
            $lexer = new lexer($tokenlist);
9,450✔
44
            $tokenlist = $lexer->get_tokens();
9,450✔
45
        }
46

47
        foreach ($tokenlist as $token) {
9,450✔
48
            // In the context of student answers, the caret (^) *always* means exponentiation (**) instead
49
            // of XOR. In model answers entered by the teacher, the caret *only* means exponentiation
50
            // for algebraic formulas, but not for the other answer types.
51
            if ($caretmeanspower) {
9,282✔
52
                if ($token->type === token::OPERATOR && $token->value === '^') {
9,282✔
53
                    $token->value = '**';
1,701✔
54
                }
55
            }
56

57
            // Students are not allowed to use the PREFIX operator.
58
            if (!$formodelanswer && $token->type === token::PREFIX) {
9,282✔
59
                $this->die(get_string('error_prefix', 'qtype_formulas'), $token);
294✔
60
            }
61
        }
62

63
        // Once this is done, we can parse the expression normally.
64
        parent::__construct($tokenlist, $knownvariables);
9,450✔
65
    }
66

67
    /**
68
     * Perform the right check according to a given answer type.
69
     *
70
     * @param int $type the answer type, a constant from the qtype_formulas class
71
     * @return bool
72
     */
73
    public function is_acceptable_for_answertype(int $type): bool {
74
        if ($type === qtype_formulas::ANSWER_TYPE_NUMBER) {
8,148✔
75
            return $this->is_acceptable_number();
1,491✔
76
        }
77

78
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERIC) {
6,657✔
79
            return $this->is_acceptable_numeric();
1,281✔
80
        }
81

82
        if ($type === qtype_formulas::ANSWER_TYPE_NUMERICAL_FORMULA) {
5,376✔
83
            return $this->is_acceptable_numerical_formula();
1,554✔
84
        }
85

86
        if ($type === qtype_formulas::ANSWER_TYPE_ALGEBRAIC) {
3,843✔
87
            return $this->is_acceptable_algebraic_formula();
3,822✔
88
        }
89

90
        // If an invalid answer type has been specified, we simply return false.
91
        return false;
21✔
92
    }
93

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

108
        $answertokens = $this->statements[0]->body;
7,287✔
109

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

118
        // If there are no tokens left, everything is fine.
119
        if (empty($answertokens)) {
5,796✔
120
            return true;
1,407✔
121
        }
122

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

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

150
        // The statement list must contain exactly one expression object.
151
        if (count($this->statements) !== 1) {
5,313✔
152
            return false;
735✔
153
        }
154

155
        $answertokens = $this->statements[0]->body;
4,578✔
156

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

176
        // Still here? Then it's all good.
177
        return true;
1,344✔
178
    }
179

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

196
        // Checking whether the expression is valid as an algebraic formula, but with variables
197
        // being disallowed. This also makes sure that there is one single statement.
198
        if (!$this->is_acceptable_algebraic_formula(true)) {
588✔
199
            return false;
420✔
200
        }
201

202
        // Still here? Then it's all good.
203
        return true;
189✔
204
    }
205

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

220
        // The statement list must contain exactly one expression object.
221
        if (count($this->statements) !== 1) {
3,528✔
222
            return false;
609✔
223
        }
224

225
        $answertokens = $this->statements[0]->body;
2,919✔
226

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

271
        // Still here? Then let's check the syntax.
272
        return $this->is_valid_syntax();
2,142✔
273
    }
274

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

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

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

347
        return (count($stack) === 1);
2,142✔
348
    }
349

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