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

FormulasQuestion / moodle-qtype_formulas / 13200038469

07 Feb 2025 12:40PM UTC coverage: 76.583% (+1.5%) from 75.045%
13200038469

Pull #62

github

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

2517 of 3116 new or added lines in 22 files covered. (80.78%)

146 existing lines in 6 files now uncovered.

2976 of 3886 relevant lines covered (76.58%)

431.97 hits per line

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

58.55
/classes/local/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 Exception;
20

21
/**
22
 * Parser 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 parser {
29
    /** @var null */
30
    const EOF = null;
31

32
    /** @var array list of all (raw) tokens */
33
    protected array $tokenlist;
34

35
    /** @var int number of (raw) tokens */
36
    private int $count;
37

38
    /** @var int position w.r.t. list of (raw) tokens */
39
    private int $position = -1;
40

41
    /** @var array list of all (parsed) statements */
42
    protected array $statements = [];
43

44
    /** @var array list of known variables */
45
    private array $variableslist = [];
46

47
    /**
48
     * Create a parser class and have it parse a given input. The input can be given as a string, in
49
     * which case it will first be sent to the lexer. If that step has already been made, the constructor
50
     * also accepts a list of tokens. The user can specify a list of known variables to help the
51
     * parser classify identifiers as functions or variables.
52
     *
53
     * @param string|array $tokenlist list of tokens as returned from the lexer or input string
54
     * @param array $knownvariables
55
     */
56
    public function __construct($tokenlist, array $knownvariables = []) {
57
        // If the input is given as a string, run it through the lexer first.
58
        if (is_string($tokenlist)) {
646✔
59
            $lexer = new lexer($tokenlist);
646✔
60
            $tokenlist = $lexer->get_tokens();
646✔
61
        }
62
        $this->tokenlist = $tokenlist;
646✔
63
        $this->count = count($tokenlist);
646✔
64
        $this->variableslist = $knownvariables;
646✔
65

66
        // Check for unbalanced / mismatched parentheses. There will be some redundancy, because
67
        // the shunting yard algorithm will also do some checks on its own, but doing it here allows better
68
        // and faster error reporting.
69
        $this->check_unbalanced_parens();
646✔
70

71
        // Go through all tokens and read either general expressions (assignments or expressions)
72
        // or for loops.
73
        $currenttoken = $this->peek();
357✔
74
        while ($currenttoken !== self::EOF) {
357✔
75
            $this->statements[] = $this->parse_the_right_thing($currenttoken);
357✔
76
            $currenttoken = $this->peek();
357✔
77
        }
78
    }
79

80
    /**
81
     * Invoke the parser for the thing at hand, currently either a for loop or a general
82
     * expression, e.g. an assignment or an answer given by a student.
83
     *
84
     * @param token $token the first token
85
     * @return for_loop|expression
86
     */
87
    private function parse_the_right_thing(token $token) {
88
        if ($token->type === token::RESERVED_WORD && $token->value === 'for') {
357✔
NEW
89
            return $this->parse_forloop();
×
90
        } else {
91
            return $this->parse_general_expression();
357✔
92
        }
93
    }
94

95
    /**
96
     * Check whether all parentheses are balanced. Otherweise, stop all further processing
97
     * and output an error message.
98
     *
99
     * @return void
100
     */
101
    private function check_unbalanced_parens(): void {
102
        $parenstack = [];
646✔
103
        foreach ($this->tokenlist as $token) {
646✔
104
            $type = $token->type;
646✔
105
            // All opening parens will have the 16-bit set, other tokens won't.
106
            if ($type & token::ANY_OPENING_PAREN) {
646✔
107
                $parenstack[] = $token;
459✔
108
            }
109
            // All closing parens will have the 32-bit set, other tokens won't.
110
            if ($type & token::ANY_CLOSING_PAREN) {
646✔
111
                $top = end($parenstack);
459✔
112
                // If stack is empty, we have a stray closing paren.
113
                if (!($top instanceof token)) {
459✔
114
                    $this->die(get_string('error_strayparen', 'qtype_formulas', $token->value), $token);
51✔
115
                }
116
                // Let's check whether the opening and closing parenthesis have the same type.
117
                // If they do, XORing them should leave just the 16- and the 32-bit. Otherwise,
118
                // we can stop here.
119
                if (($top->type ^ $type) !== 0b110000) {
408✔
120
                    $a = (object)['closer' => $token->value, 'opener' => $top->value, 'row' => $top->row, 'column' => $top->column];
136✔
121
                    $this->die(get_string('error_parenmismatch', 'qtype_formulas', $a), $token);
136✔
122
                }
123
                array_pop($parenstack);
272✔
124
            }
125
        }
126
        // If the stack of parentheses is not empty now, we have an unmatched opening parenthesis.
127
        if (!empty($parenstack)) {
459✔
128
            $unmatched = end($parenstack);
102✔
129
            $this->die(get_string('error_parennotclosed', 'qtype_formulas', $unmatched->value), $unmatched);
102✔
130
        }
131
    }
132

133
    /**
134
     * Find the matching parenthesis that closes the given opening paren.
135
     *
136
     * @param token $opener opening paren (, { or [
137
     * @return token
138
     */
139
    private function find_closing_paren(token $opener): token {
NEW
140
        $openertype = $opener->type;
×
NEW
141
        $i = 0;
×
NEW
142
        $nested = 0;
×
NEW
143
        $token = $this->peek();
×
NEW
144
        while ($token !== self::EOF) {
×
NEW
145
            $type = $token->type;
×
146
            // If we see the same type of opening paren, we enter a new nested level.
NEW
147
            if ($type === $openertype) {
×
NEW
148
                $nested++;
×
149
            }
150
            // XORing an opening paren's and its closing counterpart's type will have
151
            // the 16- and the 32-bit set.
NEW
152
            if (($type ^ $openertype) === 0b110000) {
×
NEW
153
                $nested--;
×
154
            }
155
            // We already know that parens are balanced, so a negative level of nesting
156
            // means we have reached the closing paren we were looking for.
NEW
157
            if ($nested < 0) {
×
NEW
158
                return $token;
×
159
            }
NEW
160
            $i++;
×
NEW
161
            $token = $this->peek($i);
×
162
        }
163
    }
164

165
    /**
166
     * Stop processing and indicate the human readable position (row/column) where the error occurred.
167
     *
168
     * @param string $message error message
169
     * @return void
170
     * @throws Exception
171
     */
172
    protected function die(string $message, ?token $offendingtoken = null): void {
173
        if (is_null($offendingtoken)) {
289✔
NEW
174
            $offendingtoken = $this->tokenlist[$this->position];
×
175
        }
176
        throw new \Exception($offendingtoken->row . ':' . $offendingtoken->column . ':' . $message);
289✔
177
    }
178

179
    /**
180
     * Check whether the token list contains at least one token with the given type and value.
181
     *
182
     * @param int $type the token type to look for
183
     * @param mixed $value the value to look for
184
     * @return bool
185
     */
186
    public function has_token_in_tokenlist(int $type, $value = null): bool {
187
        foreach ($this->tokenlist as $token) {
17✔
188
            // If the value does not matter, we also set the token's value to null.
189
            if ($value === null) {
17✔
NEW
190
                $token->value = null;
×
191
            }
192

193
            // We do not use strict comparison for the value.
194
            if ($token->type === $type && $token->value == $value) {
17✔
195
                return true;
17✔
196
            }
197
        }
198
        return false;
17✔
199
    }
200

201
    /**
202
     * Parse a general expression, i. e. an assignment or an answer expression as it could
203
     * be given by a student. Can be requested to stop when finding the first token of a
204
     * given type, if needed.
205
     *
206
     * @param int|null $stopat stop parsing when reaching a token with the given type
207
     * @return expression
208
     */
209
    private function parse_general_expression(?int $stopat = null): expression {
210
        // Start by reading the first token.
211
        $currenttoken = $this->read_next();
357✔
212
        $expression = [$currenttoken];
357✔
213

214
        while ($currenttoken !== self::EOF && $currenttoken->type !== $stopat) {
357✔
215
            $type = $currenttoken->type;
357✔
216
            $value = $currenttoken->value;
357✔
217
            $nexttoken = $this->peek();
357✔
218
            if ($nexttoken === self::EOF) {
357✔
219
                // The last token must not be an OPERATOR, a PREFIX, an ARG_SEPARATOR or a RANGE_SEPARATOR.
220
                if (in_array($type, [token::OPERATOR, token::PREFIX, token::ARG_SEPARATOR, token::RANGE_SEPARATOR])) {
357✔
221
                    // When coming from the random parser, the assignment operator is 'r=' instead of '=', but
222
                    // the user does not know that. We must instead report the value they entered.
NEW
223
                    if ($value === 'r=') {
×
NEW
224
                        $value = '=';
×
225
                    }
NEW
226
                    $this->die(get_string('error_unexpectedend', 'qtype_formulas', $value), $currenttoken);
×
227
                }
228
                // The last identifier of a statement cannot be a FUNCTION, because it would have
229
                // to be followed by parens. We don't register it as a known variable, because it
230
                // is not assigned a value at this moment.
231
                if ($type === token::IDENTIFIER) {
357✔
NEW
232
                    $currenttoken->type = token::VARIABLE;
×
233
                }
234
                break;
357✔
235
            }
236
            $nexttype = $nexttoken->type;
357✔
237
            $nextvalue = $nexttoken->value;
357✔
238

239
            // If the current token is a PREFIX and the next one is an IDENTIFIER, we will consider
240
            // that one as a FUNCTION. If the next token has already been classified as a function,
241
            // there is nothing to do; this can happen if we are coming from an answer_parser subclass.
242
            // Otherwise, this is a syntax error.
243
            if ($type === token::PREFIX) {
357✔
244
                if ($nexttype === token::IDENTIFIER || $nexttype === token::FUNCTION) {
17✔
245
                    $nexttype = ($nexttoken->type = token::FUNCTION);
17✔
246
                } else {
NEW
247
                    $this->die(get_string('error_prefix', 'qtype_formulas'));
×
248
                }
249
            }
250

251
            // If the token is already classified as a FUNCTION, it MUST be followed by an
252
            // opening parenthesis.
253
            if ($type === token::FUNCTION && $nexttype !== token::OPENING_PAREN) {
357✔
NEW
254
                $this->die(get_string('error_func_paren', 'qtype_formulas'));
×
255
            }
256

257
            // If the current token is an IDENTIFIER, we will classify it as a VARIABLE or a FUNCTION.
258
            // In order to be classified as a function, it must meet the following criteria:
259
            // - not be a known variable (unless preceded by the PREFIX, see above)
260
            // - be a known function name
261
            // - be followed by an opening paren
262
            // In all other cases, it will be classified as a VARIABLE. Note that being a known function
263
            // name alone is not enough, because we allow the user to define variables that have the same
264
            // name as predefined functions to ensure that the introduction of new functions will not
265
            // break existing questions.
266
            if ($type === token::IDENTIFIER) {
357✔
267
                $isnotavariable = !$this->is_known_variable($currenttoken);
238✔
268
                $isknownfunction = array_key_exists($value, functions::FUNCTIONS + evaluator::PHPFUNCTIONS);
238✔
269
                $nextisparen = $nexttype === token::OPENING_PAREN;
238✔
270
                if ($isnotavariable && $isknownfunction && $nextisparen) {
238✔
271
                    $type = ($currenttoken->type = token::FUNCTION);
68✔
272
                } else {
273
                    $type = ($currenttoken->type = token::VARIABLE);
221✔
274
                    $this->register_variable($currenttoken);
221✔
275
                }
276
            }
277

278
            // If we have a RANGE_SEPARATOR (:) token, we look ahead until we find a closing brace
279
            // or closing bracket, because ranges must not be used outside of sets or lists.
280
            // As we know all parentheses are balanced, it is enough to look for the closing one.
281
            if ($type === token::RANGE_SEPARATOR) {
357✔
282
                $lookahead = $nexttoken;
136✔
283
                $i = 1;
136✔
284
                while ($lookahead !== self::EOF) {
136✔
285
                    if (in_array($lookahead->type, [token::CLOSING_BRACE, token::CLOSING_BRACKET])) {
136✔
286
                        break;
136✔
287
                    }
288
                    $lookahead = $this->peek($i);
136✔
289
                    $i++;
136✔
290
                }
291
                // If we had to go all the way until the end of the token list, the range was not
292
                // used inside a list or a set.
293
                if ($lookahead === self::EOF) {
136✔
NEW
294
                    $this->die(get_string('error_rangeusage', 'qtype_formulas'), $currenttoken);
×
295
                }
296
            }
297

298
            // Check syntax for ternary operators:
299
            // We do not currently allow the short ternary operator aka "Elvis operator" (a ?: b)
300
            // which is a short form for (a ? a : b). Also, if we do not find a corresponding : part,
301
            // we die with a syntax error.
302
            if ($type === token::OPERATOR && $value === '?') {
357✔
303
                if ($nexttype === token::OPERATOR && $nextvalue === ':') {
34✔
NEW
304
                    $this->die(get_string('error_ternary_missmiddle', 'qtype_formulas'), $nexttoken);
×
305
                }
306
                $latype = $nexttype;
34✔
307
                $lavalue = $nextvalue;
34✔
308
                $i = 1;
34✔
309
                // Look ahead until we find the corresponding : part.
310
                while ($latype !== token::OPERATOR || $lavalue !== ':') {
34✔
311
                    // We have a syntax error, if...
312
                    // - we come to an END_OF_STATEMENT marker
313
                    // - we reach the end of the token list
314
                    // before having seen the : part.
315
                    $endofstatement = ($latype === token::END_OF_STATEMENT);
34✔
316
                    // If $i + $this->position is not smaller than $this->count - 1, the peek()
317
                    // function will return the EOF token.
318
                    $endoflist = ($i + $this->position >= $this->count - 1);
34✔
319
                    if ($endofstatement || $endoflist) {
34✔
NEW
320
                        $this->die(get_string('error_ternary_incomplete', 'qtype_formulas'), $currenttoken);
×
321
                    }
322
                    $lookahead = $this->peek($i);
34✔
323
                    $latype = $lookahead->type;
34✔
324
                    $lavalue = $lookahead->value;
34✔
325
                    $i++;
34✔
326
                }
327
            }
328

329
            // We do not allow two subsequent strings or a string followed by a number, because that's probably
330
            // a typo and we do not know for sure what to do with them. We make an exception for two subsequent
331
            // numbers and consider that as implicit multiplication, similar to what e. g. Wolfram Alpha does.
332
            if ($type === token::STRING && in_array($nexttype, [token::NUMBER, token::STRING])) {
357✔
NEW
333
                $this->die(get_string('error_forgotoperator', 'qtype_formulas'), $nexttoken);
×
334
            }
335
            if ($type === token::NUMBER && $nexttype === token::STRING) {
357✔
NEW
336
                $this->die(get_string('error_forgotoperator', 'qtype_formulas'), $nexttoken);
×
337
            }
338

339
            // We do not allow to subsequent commas, a comma following an opening parenthesis
340
            // or a comma followed by a closing parenthesis.
341
            $parenpluscomma = ($type & token::ANY_OPENING_PAREN) && $nexttype === token::ARG_SEPARATOR;
357✔
342
            $commaplusparen = $type === token::ARG_SEPARATOR && ($nexttype & token::ANY_CLOSING_PAREN);
357✔
343
            $twocommas = ($type === token::ARG_SEPARATOR && $nexttype === token::ARG_SEPARATOR);
357✔
344
            if ($parenpluscomma || $commaplusparen || $twocommas) {
357✔
NEW
345
                $this->die(get_string('error_invalidargsep', 'qtype_formulas'), $nexttoken);
×
346
            }
347

348
            // Similarly, We do not allow to subsequent colons, a colon following an opening parenthesis,
349
            // a colon following an argument separator or a colon followed by a closing parenthesis.
350
            $parenpluscolon = ($type & token::ANY_OPENING_PAREN) && $nexttype === token::RANGE_SEPARATOR;
357✔
351
            $colonplusparen = $type === token::RANGE_SEPARATOR && ($nexttype & token::ANY_CLOSING_PAREN);
357✔
352
            $twocolons = ($type === token::RANGE_SEPARATOR && $nexttype === token::RANGE_SEPARATOR);
357✔
353
            $commapluscolon = ($type === token::ARG_SEPARATOR && $nexttype === token::RANGE_SEPARATOR);
357✔
354
            $colonpluscomma = ($type === token::RANGE_SEPARATOR && $nexttype === token::ARG_SEPARATOR);
357✔
355
            if ($parenpluscolon || $colonplusparen || $twocolons || $commapluscolon || $colonpluscomma) {
357✔
NEW
356
                $this->die(get_string('error_invalidrangesep', 'qtype_formulas'), $nexttoken);
×
357
            }
358

359
            // If we're one token away from the end of the statement, we just read and discard the end-of-statement marker.
360
            if ($nexttype === token::END_OF_STATEMENT || $nexttype === token::END_GROUP) {
357✔
361
                $this->read_next();
17✔
362
                break;
17✔
363
            }
364
            // Otherwise, let's read the next token and append it to the list of tokens for this statement.
365
            $currenttoken = $this->read_next();
357✔
366
            $expression[] = $currenttoken;
357✔
367
        }
368

369
        // Feed the expression to the shunting yard algorithm and return the result.
370
        return new expression(shunting_yard::infix_to_rpn($expression));
357✔
371
    }
372

373
    /**
374
     * Retrieve the parsed statements.
375
     *
376
     * @return array
377
     */
378
    public function get_statements(): array {
379
        return $this->statements;
289✔
380
    }
381

382
    /**
383
     * Look at the next (or one of the next) token, without moving the processing index any further.
384
     * Returns NULL if peeking beyond the end of the token list.
385
     *
386
     * @param int $skip skip a certain number of tokens
387
     * @return token|null
388
     */
389
    private function peek(int $skip = 0): ?token {
390
        if ($this->position < $this->count - $skip - 1) {
357✔
391
            return $this->tokenlist[$this->position + $skip + 1];
357✔
392
        }
393
        return self::EOF;
357✔
394
    }
395

396
    /**
397
     * Read the next token from the token list and move the processing index forward by one position.
398
     * Returns NULL if we have reached the end of the list.
399
     *
400
     * @return token|null
401
     */
402
    private function read_next(): ?token {
403
        $nexttoken = $this->peek();
357✔
404
        if ($nexttoken !== self::EOF) {
357✔
405
            $this->position++;
357✔
406
        }
407
        return $nexttoken;
357✔
408
    }
409

410
    /**
411
     * Check whether a given identifier is a known variable.
412
     *
413
     * @param token $token token containing the identifier
414
     * @return bool
415
     */
416
    protected function is_known_variable(token $token): bool {
417
        return in_array($token->value, $this->variableslist);
238✔
418
    }
419

420
    /**
421
     * Register an identifier as a known variable.
422
     *
423
     * @param token $token
424
     * @return void
425
     */
426
    private function register_variable(token $token): void {
427
        // Do not register a variable twice.
428
        if ($this->is_known_variable($token)) {
221✔
429
            return;
34✔
430
        }
431
        $this->variableslist[] = $token->value;
221✔
432
    }
433

434
    /**
435
     * Return the list of all known variables.
436
     *
437
     * @return array
438
     */
439
    public function export_known_variables(): array {
NEW
440
        return $this->variableslist;
×
441
    }
442

443
    /**
444
     * This function parses a for loop.
445
     * The general syntax of a for loop is for (<var>:<range/list>) { <statements> }, but the braces can be
446
     * left out if there is only one statement. The range can be defined with the loop, but it can also
447
     * be stored in a variable.
448
     * Notes:
449
     * - The variable will NOT be local to the loop. It will be visible in the entire scope and keep its last value.
450
     * - It is possible to use a variable that has already been defined. In that case, it will be overwritten.
451
     * - It is possible to use variables for the start and/or end of the range and also for the step size.
452
     * - The range is evaluated ONLY ONCE at the initialization of the loop. So if you use variables in the range
453
     * and you change those variables inside the loop, this will have no effect.
454
     * - It is possible to change the value of the iterator variable inside the loop. However, at each iteration
455
     * it will be set to the next planned value regardless of what you did to it.
456
     *
457
     * @return for_loop
458
     */
459
    private function parse_forloop(): for_loop {
NEW
460
        $variable = null;
×
NEW
461
        $range = [];
×
NEW
462
        $statements = [];
×
463

464
        // Consume the 'for' token.
NEW
465
        $this->read_next();
×
466

467
        // Next must be an opening parenthesis.
NEW
468
        $currenttoken = $this->peek();
×
NEW
469
        if (!$currenttoken || $currenttoken->type !== token::OPENING_PAREN) {
×
NEW
470
            $this->die(get_string('error_for_expectparen', 'qtype_formulas'), $currenttoken);
×
471
        }
472
        // Consume the opening parenthesis.
NEW
473
        $currenttoken = $this->read_next();
×
474

475
        // Next must be a variable name.
NEW
476
        $currenttoken = $this->peek();
×
NEW
477
        if (!$currenttoken || $currenttoken->type !== token::IDENTIFIER) {
×
NEW
478
            $this->die(get_string('error_for_expectidentifier', 'qtype_formulas'), $currenttoken);
×
479
        }
NEW
480
        $currenttoken = $this->read_next();
×
NEW
481
        $currenttoken->type = token::VARIABLE;
×
NEW
482
        $variable = $currenttoken;
×
483

484
        // Next must be a colon.
NEW
485
        $currenttoken = $this->peek();
×
NEW
486
        if (!$currenttoken || $currenttoken->type !== token::RANGE_SEPARATOR) {
×
NEW
487
            $this->die(get_string('error_for_expectcolon', 'qtype_formulas'), $currenttoken);
×
488
        }
NEW
489
        $currenttoken = $this->read_next();
×
490

491
        // Next must be an opening bracket or a variable. The variable should contain a list,
492
        // but we cannot check that at this point. Note that at this point, IDENTIFIER tokens
493
        // have not yet been classified into VARIABLE or FUNCTION tokens.
NEW
494
        $currenttoken = $this->peek();
×
NEW
495
        $isbracket = ($currenttoken->type === token::OPENING_BRACKET);
×
NEW
496
        $isvariable = ($currenttoken->type === token::IDENTIFIER);
×
NEW
497
        if (empty($currenttoken) || (!$isbracket && !$isvariable)) {
×
NEW
498
            $this->die(get_string('error_expectbracketorvarname', 'qtype_formulas'), $currenttoken);
×
499
        }
500

NEW
501
        if ($isbracket) {
×
502
            // If we had an opening bracket, read up to the closing bracket. We are sure there
503
            // is one, because the parser has already checked for mismatched / unbalanced parens.
NEW
504
            $range = $this->parse_general_expression(token::CLOSING_BRACKET);
×
505
        } else {
506
            // Otherwise, we set the token's type to VARIABLE, as it must be one and things will
507
            // blow up later during evaluation if it is not. Then, we define the range to be
508
            // an expression of just this one token. And we don't forget to consume the token.
NEW
509
            $currenttoken->type = token::VARIABLE;
×
NEW
510
            $range = new expression([$currenttoken]);
×
NEW
511
            $this->read_next();
×
512
        }
513

514
        // Next must be a closing parenthesis.
NEW
515
        $currenttoken = $this->peek();
×
NEW
516
        if (!$currenttoken || $currenttoken->type !== token::CLOSING_PAREN) {
×
NEW
517
            $this->die(get_string('error_expectclosingparen', 'qtype_formulas'), $currenttoken);
×
518
        }
NEW
519
        $currenttoken = $this->read_next();
×
520

521
        // Next must either be an opening brace or the start of a statement.
NEW
522
        $currenttoken = $this->peek();
×
NEW
523
        if (!$currenttoken) {
×
NEW
524
            $this->die(get_string('error_expectbraceorstatement', 'qtype_formulas'), $currenttoken);
×
525
        }
526

527
        // If the token is an opening brace, we have to parse all upcoming lines until the
528
        // matching closing brace. Otherwise, we parse one single line. In any case,
529
        // what we read might be a nested for loop, so we process everything recursively.
NEW
530
        if ($currenttoken->type === token::OPENING_BRACE) {
×
531
            // Consume the brace.
NEW
532
            $this->read_next();
×
NEW
533
            $closer = $this->find_closing_paren($currenttoken);
×
NEW
534
            $closer->type = token::END_GROUP;
×
NEW
535
            $currenttoken = $this->peek();
×
536
            // Parse each statement.
NEW
537
            while ($currenttoken && $currenttoken->type !== token::END_GROUP) {
×
NEW
538
                $statements[] = $this->parse_the_right_thing($currenttoken);
×
NEW
539
                $currenttoken = $this->peek();
×
540
            }
541
            // Consume the closing brace.
NEW
542
            $this->read_next();
×
543
            // Check whether the next token (if it exists) is a semicolon. If it is, we consume it also.
NEW
544
            $nexttoken = $this->peek();
×
NEW
545
            if (isset($nexttoken) && $nexttoken->type === token::END_OF_STATEMENT) {
×
NEW
546
                $this->read_next();
×
547
            }
548
        } else {
NEW
549
            $statements[] = $this->parse_the_right_thing($currenttoken);
×
550
        }
551

NEW
552
        return new for_loop($variable, $range, $statements);
×
553
    }
554

555
    /**
556
     * Return the token list.
557
     *
558
     * @return array
559
     */
560
    public function get_tokens(): array {
561
        return $this->tokenlist;
51✔
562
    }
563
}
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